#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
TSAI-ISO 工具
=============
Python3 + GTK3 + 命令行 的类 Ubuntu 系统 ISO 处理工具。

功能：
  1. extract  解压 ISO  —— 提取 ISO9660 根目录（iso-work）+ 解包 minimal.squashfs
     （得到可直接 chroot 的根文件系统 root/）
  2. pack     打包 ISO  —— 把源根目录 mksquashfs 进 iso-work/casper，重新生成
     minimal.size / md5sum.txt，再用 grub-mkrescue 产出可启动 ISO
  3. chroot   打开 chroot —— 绑定挂载 /proc /sys /dev 后，在 ptyxis 终端里打开 chroot
  4. gui      启动图形界面（默认，无参数时自动进入）

工作目录结构（默认 ~/.tsai-iso）：
  iso-work/  提取出的 ISO 内容（boot/grub、casper 等，相当于 iso-work）
  root/      解包出的根文件系统（可 chroot / 打包）
  out/       打包产物 ISO

依赖：xorriso, squashfs-tools, grub-common（grub-mkrescue）, ptyxis, python3-gi,
      以及可用的 sudo（推荐配置无密码 sudo）。
"""

import argparse
import json
import os
import shlex
import shutil
import subprocess
import sys
import threading
import time

APP_NAME = "TSAI-ISO 工具"
DEFAULT_WORK = os.path.expanduser("~/.tsai-iso")

CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".tsai-iso")
CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")

REQUIRED_TOOLS = {
    "xorriso":     "xorriso",
    "unsquashfs":  "squashfs-tools",
    "mksquashfs":  "squashfs-tools",
    "grub-mkrescue": "grub-common",
    "ptyxis":      "ptyxis",
}

CHROOT_MOUNTS = ["proc", "sys", "dev", "dev/pts"]


# ---------------------------------------------------------------------------
# 基础工具
# ---------------------------------------------------------------------------

def sudo_mode():
    """CLI 环境用普通 sudo（可在终端交互输入密码）；GUI 用 sudo -n（无密码）"""
    return ["sudo", "-n"] if os.environ.get("TSAI_ISO_GUI") else ["sudo"]


def paths(work):
    return {
        "work": work,
        "iso_work": os.path.join(work, "iso-work"),
        "root": os.path.join(work, "root"),
        "out": os.path.join(work, "out"),
    }


def log_stdout(msg):
    print(msg, flush=True)


def load_config():
    """读取上次保存的 GUI 选项。"""
    try:
        with open(CONFIG_FILE, "r", encoding="utf-8") as f:
            cfg = json.load(f)
            return cfg if isinstance(cfg, dict) else {}
    except Exception:
        return {}


def save_config(data):
    """保存 GUI 选项到 ~/.tsai-iso/config.json。"""
    try:
        os.makedirs(CONFIG_DIR, exist_ok=True)
        with open(CONFIG_FILE, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
    except Exception:
        pass


def notify(summary, body="", urgency="normal"):
    """发送桌面通知（notify-send）。失败静默忽略。"""
    tool = shutil.which("notify-send")
    if not tool:
        return False
    try:
        subprocess.run([tool, "-a", APP_NAME, "-u", urgency,
                        summary, body[:500]],
                       check=False, timeout=5)
        return True
    except Exception:
        return False


_last_was_replace = False


def log_stdout(msg, replace=False):
    """CLI 日志：replace=True 时在同一行原位刷新（进度条），否则换行追加。"""
    global _last_was_replace
    if replace:
        sys.stdout.write("\r" + msg + "\x1b[K")
        sys.stdout.flush()
        _last_was_replace = True
    else:
        if _last_was_replace:
            sys.stdout.write("\n")
            _last_was_replace = False
        print(msg, flush=True)


def _log_line(line, log, replace):
    line = line.rstrip("\r\n")
    if line.strip():
        log(line, replace=replace)


def run_cmd(cmd, log=log_stdout, check=True, cwd=None):
    """运行命令并实时透传输出到 log 回调。

    使用无缓冲二进制管道读取，并把 `\\r`（进度条原位刷新）归一化为
    replace 日志，保证 mksquashfs / xorriso / unsquashfs 的进度实时可见。
    """
    log("$ " + " ".join(shlex.quote(str(a)) for a in cmd))
    proc = subprocess.Popen(cmd, stdout=subprocess.PIPE,
                            stderr=subprocess.STDOUT, bufsize=0, cwd=cwd)
    pending = ""
    while True:
        chunk = proc.stdout.read(65536)
        if not chunk:
            break
        pending += chunk.decode("utf-8", "replace")
        while "\n" in pending or "\r" in pending:
            nl = pending.find("\n")
            cr = pending.find("\r")
            if cr == -1:
                line, pending = pending.split("\n", 1)
                _log_line(line, log, False)
            elif nl == -1 or cr < nl:
                line, pending = pending.split("\r", 1)
                if pending.startswith("\n"):
                    pending = pending[1:]
                    _log_line(line, log, False)
                else:
                    _log_line(line, log, True)
            else:
                line, pending = pending.split("\n", 1)
                _log_line(line, log, False)
    if pending.strip():
        _log_line(pending, log, False)
    proc.wait()
    if check and proc.returncode != 0:
        raise RuntimeError("命令失败 (rc=%d): %s" % (proc.returncode, " ".join(map(str, cmd))))
    return proc.returncode


def check_tools(log=log_stdout):
    missing = []
    for tool, pkg in REQUIRED_TOOLS.items():
        if not shutil_which(tool):
            missing.append("%s (%s)" % (tool, pkg))
    if missing:
        raise RuntimeError("缺少依赖工具: " + ", ".join(missing))
    # 校验 sudo
    if subprocess.call(sudo_mode() + ["true"], stdout=subprocess.DEVNULL,
                       stderr=subprocess.DEVNULL) != 0:
        raise RuntimeError("sudo 不可用（GUI 模式要求无密码 sudo）")


def shutil_which(name):
    from shutil import which
    return which(name)


def ensure_dirs(p):
    os.makedirs(p, exist_ok=True)


def detect_squashfs(iso_work, override=None, log=log_stdout):
    """在 iso-work/casper 中定位主 squashfs。优先 override / minimal.squashfs，
    否则取最大的 *.squashfs。"""
    if override and os.path.exists(override):
        return override
    casper = os.path.join(iso_work, "casper")
    if not os.path.isdir(casper):
        raise RuntimeError("找不到 casper 目录: %s（请先解压 ISO）" % casper)
    for cand in ("minimal.squashfs", "filesystem.squashfs"):
        p = os.path.join(casper, cand)
        if os.path.exists(p):
            return p
    sqs = [os.path.join(casper, f) for f in os.listdir(casper)
           if f.endswith(".squashfs") and os.path.isfile(os.path.join(casper, f))]
    if not sqs:
        raise RuntimeError("casper 目录中没有 .squashfs: %s" % casper)
    sqs.sort(key=os.path.getsize, reverse=True)
    return sqs[0]


# ---------------------------------------------------------------------------
# 核心操作
# ---------------------------------------------------------------------------

def extract_iso_work(iso, work, log=log_stdout, force=False):
    """仅提取 ISO9660 内容到 iso-work/（不解包 squashfs）。"""
    P = paths(work)
    if not iso or not os.path.exists(iso):
        raise RuntimeError("ISO 文件不存在: %s" % iso)
    ensure_dirs(P["iso_work"])
    log("==> [1/2] 提取 ISO 根目录 -> %s" % P["iso_work"])
    run_cmd(sudo_mode() + ["xorriso", "-osirrox", "on",
                           "-indev", iso, "-extract", "/", P["iso_work"]],
            log=log)
    return P["iso_work"]


def extract_root(work, squashfs=None, log=log_stdout):
    """解包主 squashfs 到 root/。"""
    P = paths(work)
    if not squashfs:
        squashfs = detect_squashfs(P["iso_work"], log=log)
    ensure_dirs(P["root"])
    log("==> [2/2] 解包 %s -> %s" % (squashfs, P["root"]))
    run_cmd(sudo_mode() + ["unsquashfs", "-f", "-processors",
                           str(os.cpu_count() or 1), "-d", P["root"], squashfs],
            log=log)
    return P["root"]


def extract_iso(iso, work, squashfs=None, skip_root=False, log=log_stdout):
    """完整解压：iso-work + root。"""
    P = paths(work)
    extract_iso_work(iso, work, log=log)
    if skip_root:
        log("==> 已跳过 root 解压（--no-root）")
    else:
        extract_root(work, squashfs=squashfs, log=log)
    log("==> 解压完成。工作目录: %s" % P["work"])
    log("    iso-work: %s" % P["iso_work"])
    log("    root    : %s" % P["root"])
    return P["work"]


def pack_iso(source, work, out=None, volid="TSAI", base_iso=None,
             squashfs_override=None, log=log_stdout):
    """打包源根目录为可启动 ISO（流程对齐 pack-iso-nocopy.sh）。"""
    P = paths(work)
    if not source or not os.path.isdir(source):
        raise RuntimeError("源根目录不存在: %s" % source)
    if not out:
        out = os.path.join(P["out"], "tsai-%s.iso" % time.strftime("%Y%m%d-%H%M%S"))

    # iso-work 若不存在，先从基础 ISO 提取（仅提取引导结构，不解压 root）
    if not os.path.exists(os.path.join(P["iso_work"], "casper")):
        if not base_iso or not os.path.exists(base_iso):
            raise RuntimeError(
                "iso-work 不存在，且未提供基础 ISO 用于初始化（请用 --iso 或先执行 extract）")
        log("==> iso-work 不存在，先从基础 ISO 初始化引导结构")
        extract_iso_work(base_iso, work, log=log)

    squashfs = detect_squashfs(P["iso_work"], override=squashfs_override, log=log)
    log("==> 目标 squashfs: %s" % squashfs)

    # 先卸载源目录下可能的 bind mount，避免打包宿主机进程
    for mp in CHROOT_MOUNTS:
        run_cmd(sudo_mode() + ["umount", os.path.join(source, mp)],
                log=log, check=False)

    log("==> [1/4] mksquashfs 打包源目录（gzip 128K）")
    run_cmd(sudo_mode() + ["rm", "-f", squashfs], log=log)
    run_cmd(sudo_mode() + ["mksquashfs", source, squashfs,
                           "-noappend", "-comp", "gzip", "-b", "131072",
                           "-processors", str(os.cpu_count() or 1)], log=log)

    log("==> [2/4] 更新 casper/minimal.size")
    size_file = os.path.join(P["iso_work"], "casper", "minimal.size")
    du = subprocess.run(sudo_mode() + ["du", "-sb", source],
                        stdout=subprocess.PIPE, text=True)
    if du.returncode != 0:
        raise RuntimeError("du -sb 失败")
    size = du.stdout.split()[0]
    log("    源目录大小: %s bytes" % size)
    run_cmd(sudo_mode() + ["bash", "-c",
                           "echo %s > %s" % (shlex.quote(size), shlex.quote(size_file))],
            log=log)

    log("==> [3/4] 重新生成 md5sum.txt")
    run_cmd(sudo_mode() + ["bash", "-c",
            "find . -type f ! -name md5sum.txt -exec md5sum {} + > md5sum.txt"],
            log=log, cwd=P["iso_work"])

    log("==> [4/4] grub-mkrescue 生成 ISO -> %s" % out)
    ensure_dirs(os.path.dirname(out))
    run_cmd(sudo_mode() + ["grub-mkrescue", "-o", out,
                           "-V", volid, "-iso-level", "3", P["iso_work"]], log=log)

    log("==> 打包完成: %s" % out)
    if os.path.exists(out):
        size_gib = os.path.getsize(out) / 1024 ** 3
        log("    大小: %.2f GiB" % size_gib)
        notify("打包完成", "已生成可启动 ISO\n%s\n大小 %.2f GiB" % (out, size_gib))
    else:
        notify("打包完成", "已生成 ISO\n%s" % out)
    return out


def chroot_launch(root=None, work=None, log=log_stdout):
    """绑定挂载后在 ptyxis 中打开 chroot。"""
    P = paths(work) if work else None
    if root is None and P:
        root = P["root"]
    if not root or not os.path.isdir(root):
        raise RuntimeError("chroot 根目录不存在: %s（请先 extract）" % root)
    root = os.path.realpath(root)

    script = """#!/usr/bin/env bash
set -u
ROOT=%s
log(){ echo "==> $*"; }
log "绑定挂载 /proc /sys /dev /dev/pts ..."
for mp in proc sys dev dev/pts; do
    mkdir -p "$ROOT/$mp" 2>/dev/null || true
    mount --bind "/$mp" "$ROOT/$mp" 2>/dev/null || true
done
[ -f /etc/resolv.conf ] && cp /etc/resolv.conf "$ROOT/etc/resolv.conf" 2>/dev/null || true
log "进入 chroot: $ROOT  (exit 退出并卸载挂载)"
chroot "$ROOT" /bin/bash -c 'cd /root 2>/dev/null; exec /bin/bash'
rc=$?
log "退出 chroot，卸载挂载 ..."
umount "$ROOT/dev/pts" 2>/dev/null || true
umount "$ROOT/dev" 2>/dev/null || true
umount "$ROOT/sys" 2>/dev/null || true
umount "$ROOT/proc" 2>/dev/null || true
exit $rc
""" % shlex.quote(root)

    import tempfile
    fd, spath = tempfile.mkstemp(prefix="tsai-chroot-", suffix=".sh", dir="/tmp")
    with os.fdopen(fd, "w") as f:
        f.write(script)
    os.chmod(spath, 0o755)

    cmd = ["ptyxis", "-x", "sudo %s" % spath]
    log("$ " + " ".join(cmd))
    subprocess.Popen(cmd, start_new_session=True)
    log("==> 已在 ptyxis 中打开 chroot: %s" % root)
    return spath


# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------

def build_parser():
    ap = argparse.ArgumentParser(
        prog="tsai-iso-tool",
        description="类 Ubuntu 系统 ISO 解压 / 打包 / chroot 工具（GTK + CLI）")
    common = argparse.ArgumentParser(add_help=False)
    common.add_argument("--work", default=DEFAULT_WORK,
                        help="工作目录（默认 ~/.tsai-iso）")
    ap.add_argument("--work", default=DEFAULT_WORK,
                    help="工作目录（默认 ~/.tsai-iso）")
    sub = ap.add_subparsers(dest="cmd")

    p_extract = sub.add_parser("extract", parents=[common],
                               help="解压 ISO：iso-work + root")
    p_extract.add_argument("--iso", required=True, help="源 ISO 文件")
    p_extract.add_argument("--squashfs", default=None,
                           help="指定要解包的 squashfs（默认自动探测）")
    p_extract.add_argument("--no-root", action="store_true",
                           help="只提取 ISO 内容，跳过 root 解压")
    p_extract.set_defaults(func=cli_extract)

    p_pack = sub.add_parser("pack", parents=[common], help="打包 ISO")
    p_pack.add_argument("--source", required=True, help="源根目录（将被 mksquashfs）")
    p_pack.add_argument("--iso", default=None, help="基础 ISO（iso-work 缺失时用于初始化引导结构）")
    p_pack.add_argument("--out", default=None, help="输出 ISO 路径")
    p_pack.add_argument("--volid", default="TSAI", help="ISO 卷标（默认 TSAI）")
    p_pack.add_argument("--squashfs", default=None, help="覆盖目标 squashfs 文件名（默认探测）")
    p_pack.set_defaults(func=cli_pack)

    p_chroot = sub.add_parser("chroot", parents=[common],
                              help="在 ptyxis 中打开 chroot")
    p_chroot.add_argument("--root", default=None, help="根目录（默认 <work>/root）")
    p_chroot.set_defaults(func=cli_chroot)

    p_gui = sub.add_parser("gui", parents=[common], help="启动图形界面")
    p_gui.set_defaults(func=cli_gui)
    return ap


def cli_extract(args):
    extract_iso(args.iso, args.work, squashfs=args.squashfs, skip_root=args.no_root)


def cli_pack(args):
    try:
        pack_iso(args.source, args.work, out=args.out, volid=args.volid,
                 base_iso=args.iso, squashfs_override=args.squashfs)
    except Exception as e:
        notify("打包失败", str(e)[:400], urgency="critical")
        raise


def cli_chroot(args):
    chroot_launch(root=args.root, work=args.work)


def cli_gui(args):
    run_gui(work=args.work)


# ---------------------------------------------------------------------------
# GTK GUI
# ---------------------------------------------------------------------------

def _import_chstyle():
    """按 /usr/chindows 惯例加载 chindows 统一主题；找不到则返回 None。"""
    try:
        import chindows_theme.style as _cs
        return _cs
    except ImportError:
        pass
    d = os.path.dirname(os.path.abspath(__file__))
    while d and not os.path.isdir(os.path.join(d, "chindows_theme")):
        p = os.path.dirname(d)
        if p == d:
            break
        d = p
    if d:
        if d not in sys.path:
            sys.path.insert(0, d)
        try:
            import chindows_theme.style as _cs
            return _cs
        except Exception:
            pass
    for cand in ("/usr/chindows", "/opt/chindows"):
        if os.path.isdir(os.path.join(cand, "chindows_theme")) and cand not in sys.path:
            sys.path.insert(0, cand)
        try:
            import chindows_theme.style as _cs
            return _cs
        except Exception:
            pass
    return None


try:
    import gi
    gi.require_version("Gtk", "3.0")
    from gi.repository import Gtk, GLib
    chstyle = _import_chstyle()
    GTK_IMPORT_ERR = None
except Exception as _gtk_err:
    Gtk = GLib = None
    chstyle = None
    GTK_IMPORT_ERR = _gtk_err


def run_gui(work=None):
    if Gtk is None:
        print("无法加载 GTK3: %s（请安装 python3-gi / gir1.2-gtk-3.0）" % GTK_IMPORT_ERR,
              file=sys.stderr)
        sys.exit(1)

    os.environ["TSAI_ISO_GUI"] = "1"
    if chstyle is not None:
        chstyle.apply_gtk3()          # /usr/chindows 统一主题
    Gtk.Window.set_default_icon_name("media-optical")
    GLib.set_prgname("tsai-iso-tool")

    win = ToolWindow(work)
    win.connect("destroy", Gtk.main_quit)
    win.show_all()
    Gtk.main()


_BASE = Gtk.Window if Gtk is not None else object


class ToolWindow(_BASE):
    def __init__(self, work):
        super().__init__(title=APP_NAME)
        self.set_default_size(860, 620)
        self.set_border_width(12)

        self._cfg = load_config()
        self.work = os.path.abspath(self._cfg.get("work") or work)
        P = paths(self.work)

        vbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=8)

        # ---- 工作目录 ----
        work_row = self._row("工作目录",
                             self._entry(P["work"], "~/.tsai-iso（解压/打包的输出区）"),
                             browse_kind="folder")
        self.work_entry = work_row["entry"]
        vbox.pack_start(work_row["box"], False, False, 0)

        # ---- ISO / 源目录 / 输出 ----
        iso_row = self._row("ISO 文件",
                            self._entry("", "解压来源，或打包时初始化 iso-work 用"),
                            browse_kind="file")
        self.iso_entry = iso_row["entry"]
        vbox.pack_start(iso_row["box"], False, False, 0)

        src_row = self._row("源根目录",
                            self._entry(P["root"], "打包源，默认 <工作目录>/root"),
                            browse_kind="folder")
        self.src_entry = src_row["entry"]
        vbox.pack_start(src_row["box"], False, False, 0)

        out_row = self._row("输出 ISO",
                            self._entry(os.path.join(P["out"], "tsai-%s.iso" % time.strftime("%Y%m%d"))),
                            browse_kind="save")
        self.out_entry = out_row["entry"]
        vbox.pack_start(out_row["box"], False, False, 0)

        vol_row = self._row("卷标", self._entry("TSAI"), browse_kind=None)
        self.vol_entry = vol_row["entry"]
        vbox.pack_start(vol_row["box"], False, False, 0)

        # ---- 选项 ----
        opt_box = Gtk.Box(spacing=16)
        self.skip_root_chk = Gtk.CheckButton(label="跳过 root 解压（只提取 ISO 内容）")
        self.force_pack_chk = Gtk.CheckButton(label="打包前强制卸载 bind 挂载")
        self.force_pack_chk.set_active(True)
        opt_box.pack_start(self.skip_root_chk, False, False, 0)
        opt_box.pack_start(self.force_pack_chk, False, False, 0)
        vbox.pack_start(opt_box, False, False, 0)

        # ---- 操作按钮 ----
        btn_box = Gtk.Box(spacing=8)
        self.btn_extract = Gtk.Button(label="① 解压 ISO")
        self.btn_extract.connect("clicked", self._on_extract)
        self.btn_pack = Gtk.Button(label="② 打包 ISO")
        self.btn_pack.connect("clicked", self._on_pack)
        self.btn_chroot = Gtk.Button(label="③ 打开 Chroot (ptyxis)")
        self.btn_chroot.connect("clicked", self._on_chroot)
        self.btn_git = Gtk.Button(label="打开工作目录")
        self.btn_git.connect("clicked", self._on_open_work)
        for b in (self.btn_extract, self.btn_pack, self.btn_chroot):
            b.get_style_context().add_class("suggested-action")
        for b in (self.btn_extract, self.btn_pack, self.btn_chroot, self.btn_git):
            btn_box.pack_start(b, True, True, 0)
        vbox.pack_start(btn_box, False, False, 0)

        self.buttons = [self.btn_extract, self.btn_pack, self.btn_chroot]

        # ---- 日志 ----
        sw = Gtk.ScrolledWindow()
        sw.set_policy(Gtk.PolicyType.AUTOMATIC, Gtk.PolicyType.AUTOMATIC)
        self.log_view = Gtk.TextView()
        self.log_view.set_editable(False)
        self.log_view.set_monospace(True)
        self.log_view.set_wrap_mode(Gtk.WrapMode.CHAR)
        if chstyle is not None and getattr(chstyle, "FONTS", None):
            fam, sz = chstyle.FONTS["mono"]
            prov = Gtk.CssProvider()
            prov.load_from_data(
                "textview { font-family: '%s'; font-size: %dpt; }" % (fam, sz))
            self.log_view.get_style_context().add_provider(
                prov, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION)
        sw.add(self.log_view)
        vbox.pack_start(sw, True, True, 0)

        self.statusbar = Gtk.Statusbar()
        vbox.pack_start(self.statusbar, False, False, 0)

        self.add(vbox)
        self._apply_opts()
        self.connect("delete-event", self._on_delete)
        self._set_busy(False)
        self._append("就绪。工作目录: %s" % self.work)

    # ---- 配置持久化 ----
    def _apply_opts(self):
        cfg = self._cfg

        def _set(entry, key, default=""):
            v = cfg.get(key)
            if v is not None:
                entry.set_text(str(v))

        _set(self.work_entry, "work")
        _set(self.iso_entry, "iso")
        _set(self.src_entry, "source")
        _set(self.out_entry, "out")
        _set(self.vol_entry, "volid", "TSAI")
        if "skip_root" in cfg:
            self.skip_root_chk.set_active(bool(cfg["skip_root"]))
        if "force_pack" in cfg:
            self.force_pack_chk.set_active(bool(cfg["force_pack"]))
        w, h = cfg.get("width"), cfg.get("height")
        if w and h:
            try:
                self.resize(int(w), int(h))
            except Exception:
                pass

    def _save_opts(self, size=True):
        cfg = {
            "work": self.work_entry.get_text().strip(),
            "iso": self.iso_entry.get_text().strip(),
            "source": self.src_entry.get_text().strip(),
            "out": self.out_entry.get_text().strip(),
            "volid": self.vol_entry.get_text().strip(),
            "skip_root": self.skip_root_chk.get_active(),
            "force_pack": self.force_pack_chk.get_active(),
        }
        if size:
            w, h = self.get_size()
            if w > 0 and h > 0:
                cfg["width"] = w
                cfg["height"] = h
        save_config(cfg)
        self._cfg = cfg

    def _on_delete(self, widget, event):
        self._save_opts()
        return False

    # ---- UI 构造辅助 ----
    def _row(self, label, entry, browse_kind):
        box = Gtk.Box(spacing=6)
        lbl = Gtk.Label.new(label)
        lbl.set_xalign(0)
        lbl.set_width_chars(9)
        box.pack_start(lbl, False, False, 0)
        box.pack_start(entry, True, True, 0)
        if browse_kind:
            btn = Gtk.Button(label="浏览…")
            btn.connect("clicked", self._on_browse, entry, browse_kind)
            box.pack_start(btn, False, False, 0)
        return {"box": box, "entry": entry}

    def _entry(self, text, placeholder=""):
        e = Gtk.Entry()
        e.set_text(text)
        if placeholder:
            e.set_placeholder_text(placeholder)
        return e

    def _on_browse(self, btn, entry, kind):
        import gi
        from gi.repository import Gtk
        if kind == "file":
            d = Gtk.FileChooserDialog("选择 ISO 文件", self, Gtk.FileChooserAction.OPEN,
                                      ("取消", Gtk.ResponseType.CANCEL,
                                       "打开", Gtk.ResponseType.OK))
        elif kind == "folder":
            d = Gtk.FileChooserDialog("选择目录", self, Gtk.FileChooserAction.SELECT_FOLDER,
                                      ("取消", Gtk.ResponseType.CANCEL,
                                       "选择", Gtk.ResponseType.OK))
        else:  # save
            d = Gtk.FileChooserDialog("输出 ISO 位置", self, Gtk.FileChooserAction.SAVE,
                                      ("取消", Gtk.ResponseType.CANCEL,
                                       "保存", Gtk.ResponseType.OK))
            d.set_do_overwrite_confirmation(True)
        if kind in ("file", "save"):
            f = Gtk.FileFilter()
            f.set_name("ISO 镜像")
            f.add_pattern("*.iso")
            d.add_filter(f)
        if entry.get_text():
            d.set_filename(entry.get_text())
        if d.run() == Gtk.ResponseType.OK:
            entry.set_text(d.get_filename())
        d.destroy()

    def _on_open_work(self, btn):
        try:
            from gi.repository import Gio
            Gio.AppInfo.launch_default_for_uri("file://" + self.work, None)
        except Exception:
            subprocess.Popen(["xdg-open", self.work])

    # ---- 日志 ----
    def _append(self, text, replace=False):
        import gi
        gi.require_version("Gtk", "3.0")
        from gi.repository import GLib
        GLib.idle_add(self._append_idle, text, replace)

    def _append_idle(self, text, replace=False):
        buf = self.log_view.get_buffer()
        end = buf.get_end_iter()
        if replace:
            start = buf.get_start_iter()
            all_text = buf.get_text(start, end, False)
            body = all_text.rstrip("\n")
            idx = body.rfind("\n")
            if idx >= 0:
                buf.delete(buf.get_iter_at_offset(idx + 1), end)
            else:
                buf.delete(start, end)
            end = buf.get_end_iter()
        buf.insert(end, text + "\n")
        adj = self.log_view.get_parent().get_vadjustment()
        adj.set_value(adj.get_upper())
        return False

    def _set_busy(self, busy):
        for b in self.buttons:
            b.set_sensitive(not busy)

    # ---- 操作分发 ----
    def _run_op(self, title, fn):
        self._set_busy(True)
        self.statusbar.push(0, "%s 进行中…" % title)

        def worker():
            try:
                fn()
                self._append("[OK] %s 完成" % title)
            except Exception as e:
                self._append("[ERROR] %s 失败: %s" % (title, e))
                notify(title + " 失败", str(e)[:400], urgency="critical")
            finally:
                GLib.idle_add(self._finish_op, title)

        threading.Thread(target=worker, daemon=True).start()

    def _finish_op(self, title):
        self._set_busy(False)
        self.statusbar.push(0, "%s 已结束" % title)
        self._save_opts(size=False)

    def _on_extract(self, btn):
        iso = self.iso_entry.get_text().strip()
        if not iso:
            self._append("[提示] 请先选择 ISO 文件")
            return
        work = self.work_entry.get_text().strip() or self.work
        self.work = os.path.abspath(work)
        skip = self.skip_root_chk.get_active()
        self._run_op("解压", lambda: extract_iso(
            iso, self.work, skip_root=skip, log=self._append))

    def _on_pack(self, btn):
        source = self.src_entry.get_text().strip()
        out = self.out_entry.get_text().strip()
        base_iso = self.iso_entry.get_text().strip() or None
        volid = self.vol_entry.get_text().strip() or "TSAI"
        work = self.work_entry.get_text().strip() or self.work
        self.work = os.path.abspath(work)
        if not source:
            self._append("[提示] 请填写源根目录")
            return
        self._run_op("打包", lambda: pack_iso(
            source, self.work, out=out, volid=volid, base_iso=base_iso,
            log=self._append))

    def _on_chroot(self, btn):
        work = self.work_entry.get_text().strip() or self.work
        self.work = os.path.abspath(work)
        self._run_op("Chroot", lambda: chroot_launch(work=self.work, log=self._append))


# ---------------------------------------------------------------------------

def main():
    ap = build_parser()
    args = ap.parse_args()
    if not args.cmd:
        run_gui(work=args.work)
        return
    args.func(args)


if __name__ == "__main__":
    main()
