#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
batch-print-pdf: 批量打印 PDF 工具

用法:
    batch-print-pdf [选项] file1.pdf file2.pdf ...

选项:
    --fit a4-landscape       全部输出到 A4 横纸 (内容铺满)
    --fit a4-portrait        全部输出到 A4 竖纸 (内容铺满, 默认)
    --fit none               不重排版, 保持原始页面尺寸
                             (注意: 各 PDF 尺寸不一时打印机驱动可能裁切)
    --content-orient FILE:DIR  逐份指定"内容朝向" (portrait | landscape):
                             竖意为内容竖着看, 横意为内容横着看; 与全局纸张
                             方向 (--fit) 不一致时自动旋转 90° 填充, 收到的
                             纸需要转着看. 可重复或逗号分隔, 例:
                             --content-orient a.pdf:landscape
                             --content-orient "a.pdf:landscape,b.pdf:portrait"
                             FILE 是 basename, 不带路径
    --no-merge               单文件打印场景, 跳过合并
    --duplex                 双面打印模式: 奇数页的输入文件后自动补 1 张空白页,
                             避免下一份的首页粘到上一份末页的反面 (默认关)
    --insert-blank-after N   在合并后的第 N 页之后插入 1 张空白页;
                             可重复或逗号分隔, 例: --insert-blank-after 5,12
                             页号是合并(且 duplex 处理)后的最终全局页号
    --no-options-dialog      强制跳过 GUI 选项对话框 (脚本/自动化场景)

GUI 行为:
    桌面端右键调用 (无任何 --xxx 选项) 时会先弹一个 GTK 选项对话框,
    顶部选全局纸张方向, 中间是文件列表 (可调顺序 + 每份指定内容朝向),
    再往下是双面/插页选项. 上次的选择会记到 ~/.config/batch-print-pdf/last.json.
    命令行里只要传过任意 --xxx 选项 (或 --no-options-dialog) 就直接跳过对话框.

设计原则:
    最终输出的 PDF 永远是单一 A4 尺寸, 打印机绝不会因为页面尺寸混排而裁切.
    "纸张方向" = 打印机送进去的纸是竖是横; "内容朝向" = 该份 PDF 内容希望
    被怎样阅读. 两者不一致时旋转 90° 适配纸张.

行为:
    [GUI 选项对话框] -> 合并 PDF -> [duplex/手动插入空白页] -> 重排版到 A4
    -> 弹 GTK 打印对话框 -> 送打印机
"""
import os
import sys
import tempfile
import subprocess
import shutil
from pathlib import Path

# A4 in PostScript points (1/72 inch). Portrait 595 x 842, Landscape 842 x 595.
A4_PORTRAIT = (595.0, 842.0)
A4_LANDSCAPE = (842.0, 595.0)


# ---------------------- helpers ----------------------

def notify(summary, body="", icon="printer"):
    try:
        subprocess.run(
            ["notify-send", "-i", icon, summary, body],
            check=False, timeout=3,
        )
    except Exception:
        pass


def die(msg, code=1):
    print(f"[batch-print-pdf] ERROR: {msg}", file=sys.stderr)
    notify("批量打印失败", msg, icon="dialog-error")
    sys.exit(code)


def have(cmd):
    return shutil.which(cmd) is not None


def filter_pdfs(paths):
    pdfs, skipped = [], []
    for p in paths:
        if not os.path.isfile(p):
            skipped.append(p)
            continue
        if p.lower().endswith(".pdf"):
            pdfs.append(os.path.abspath(p))
        else:
            skipped.append(p)
    return pdfs, skipped


# ---------------------- merge ----------------------

def count_pages(pdf_path):
    """读 PDF 页数. 优先 pypdf, 退到 pdfinfo."""
    try:
        from pypdf import PdfReader
        return len(PdfReader(pdf_path).pages)
    except ImportError:
        pass
    except Exception:
        pass
    try:
        from PyPDF2 import PdfReader
        return len(PdfReader(pdf_path).pages)
    except ImportError:
        pass
    except Exception:
        pass
    if have("pdfinfo"):
        try:
            out = subprocess.check_output(["pdfinfo", pdf_path], text=True)
            for line in out.splitlines():
                if line.startswith("Pages:"):
                    return int(line.split()[1])
        except Exception:
            pass
    return None  # 未知


def merge_pdfs(pdf_paths, out_path):
    """合并 PDF, 优先级: pdfunite > gs > pypdf > PyPDF2"""
    if have("pdfunite"):
        try:
            subprocess.run(["pdfunite", *pdf_paths, out_path], check=True)
            return "pdfunite"
        except subprocess.CalledProcessError as e:
            print(f"[batch-print-pdf] pdfunite failed: {e}", file=sys.stderr)

    if have("gs"):
        try:
            subprocess.run(
                ["gs", "-dBATCH", "-dNOPAUSE", "-q",
                 "-sDEVICE=pdfwrite",
                 f"-sOutputFile={out_path}",
                 *pdf_paths],
                check=True,
            )
            return "gs"
        except subprocess.CalledProcessError as e:
            print(f"[batch-print-pdf] gs failed: {e}", file=sys.stderr)

    try:
        from pypdf import PdfWriter
        w = PdfWriter()
        for p in pdf_paths:
            w.append(p)
        with open(out_path, "wb") as f:
            w.write(f)
        return "pypdf"
    except ImportError:
        pass
    except Exception as e:
        print(f"[batch-print-pdf] pypdf failed: {e}", file=sys.stderr)

    try:
        from PyPDF2 import PdfMerger
        m = PdfMerger()
        for p in pdf_paths:
            m.append(p)
        m.write(out_path)
        m.close()
        return "PyPDF2"
    except ImportError:
        pass

    die(
        "找不到合并 PDF 的工具。请安装其中之一:\n"
        "  sudo apt install poppler-utils    # 提供 pdfunite (推荐)\n"
        "  sudo apt install ghostscript       # 提供 gs\n"
        "  pip install --user pypdf           # Python 库"
    )


# ---------------------- insert blank pages ----------------------

def insert_blank_pages(in_path, out_path, positions):
    """
    在合并后的 PDF 里, 在 positions 中每个 N 之后插入 1 张空白页.
    - positions 是合并后(尚未插入)的全局页号 list, 1-based.
      N=0 表示在最前面插; N=total 表示在最后面追加.
    - 空白页尺寸取被插页(第 N 页, 或末尾追加时取最后一页)的 mediabox,
      保证缩放/装订时不会出现尺寸跳变.
    返回工具名, 失败返回 None (调用方应回退到不插).
    """
    if not positions:
        # 复制一份到 out_path 即可, 但简化: 直接让 caller 处理 — 我们这里不调.
        return None

    pypdf_mod, pypdf_name = _import_pypdf()
    if pypdf_mod is None:
        print("[batch-print-pdf] WARN: 缺 pypdf/PyPDF2, 跳过插入空白页", file=sys.stderr)
        return None

    if pypdf_name == "pypdf":
        from pypdf import PdfReader, PdfWriter
    else:
        from PyPDF2 import PdfReader, PdfWriter

    reader = PdfReader(in_path)
    total = len(reader.pages)
    # 合法化: 0 ~ total
    valid_positions = sorted({p for p in positions if 0 <= p <= total})
    if not valid_positions:
        return None

    writer = PdfWriter()
    # 用 dict 把 "在第 i 页后插几张" 反映出来 (我们这里每个位置就 1 张, 但为了将来好扩展用 count)
    insert_after = {p: 1 for p in valid_positions}

    # 处理 N=0 (在最前面插)
    for _ in range(insert_after.get(0, 0)):
        # 用第一页尺寸; 若文档是空的(理论不会), 用 A4
        if total > 0:
            mb = reader.pages[0].mediabox
            w, h = float(mb.width), float(mb.height)
        else:
            w, h = A4_PORTRAIT
        writer.add_blank_page(width=w, height=h)

    for idx, page in enumerate(reader.pages, start=1):
        writer.add_page(page)
        n_insert = insert_after.get(idx, 0)
        if n_insert:
            mb = page.mediabox
            w, h = float(mb.width), float(mb.height)
            for _ in range(n_insert):
                writer.add_blank_page(width=w, height=h)

    with open(out_path, "wb") as f:
        writer.write(f)
    return pypdf_name


def compute_duplex_positions(page_counts):
    """
    给一组输入文件的页数(按合并顺序), 返回需要在哪些"全局累计页号之后"
    补 1 张空白页. 最后一个文件无论奇偶都不补 (打完即止, 没有"下一份").
    page_counts: [3, 2, 4]  -> 累计末位 3, 5, 9 -> 第 1 个奇 -> [3]
    page_counts: [4, 3, 2]  -> 累计 4, 7, 9 -> 第 2 个奇 -> [7]
    page_counts: [3, 3]     -> 累计 3, 6 -> 第 1 个奇 -> [3]
    """
    positions = []
    cumulative = 0
    for i, n in enumerate(page_counts):
        cumulative += n
        # 最后一个文件不补
        if i == len(page_counts) - 1:
            break
        if n is not None and n % 2 == 1:
            positions.append(cumulative)
    return positions


def parse_position_list(values):
    """
    解析 --insert-blank-after 的值 list. 支持:
      ["5", "12"]      -> [5, 12]
      ["5,12"]         -> [5, 12]
      ["5,12", "20"]   -> [5, 12, 20]
    """
    result = []
    for v in values:
        for part in v.split(","):
            part = part.strip()
            if not part:
                continue
            try:
                n = int(part)
            except ValueError:
                die(f"--insert-blank-after 需要整数页号, 收到: {part}")
            if n < 0:
                die(f"--insert-blank-after 页号必须 >= 0, 收到: {n}")
            result.append(n)
    return result


# ---------------------- repaginate (fit) ----------------------

def _import_pypdf():
    """Try pypdf first (modern), fallback to PyPDF2."""
    try:
        import pypdf
        return pypdf, "pypdf"
    except ImportError:
        try:
            import PyPDF2 as pypdf
            return pypdf, "PyPDF2"
        except ImportError:
            return None, None


def repaginate_to_a4(in_path, out_path, target_size, keep_source_rotation=False):
    """把 in_path 的每一页等比缩放并居中到 target_size (W, H), 用 pypdf."""
    return _repaginate_1up_pypdf(in_path, out_path, target_size, keep_source_rotation)


def _repaginate_1up_pypdf(in_path, out_path, target_size, keep_source_rotation=False):
    """
    1 张原始页 → 1 张 target_size (等比缩放居中). 用 pypdf.
    keep_source_rotation=False (默认): 源与目标方向不一致时把源页转 90°, 铺满槽位.
    keep_source_rotation=True: 不转, 中间留白 (用户主动指定内容朝向, 不强行铺满).
    """
    pypdf_mod, pypdf_name = _import_pypdf()
    if pypdf_mod is None:
        return _repaginate_via_gs(in_path, out_path, target_size)

    print(f"[batch-print-pdf] 重排版: {pypdf_name}, target={target_size}, "
          f"keep_source_rotation={keep_source_rotation}")

    if pypdf_name == "pypdf":
        from pypdf import PdfReader, PdfWriter, PageObject, Transformation
    else:
        from PyPDF2 import PdfReader, PdfWriter, PageObject, Transformation

    reader = PdfReader(in_path)
    writer = PdfWriter()
    target_w, target_h = target_size

    for src_page in reader.pages:
        mb = src_page.mediabox
        src_w = float(mb.width)
        src_h = float(mb.height)
        if src_w <= 0 or src_h <= 0:
            continue

        # 自动旋转: 源与目标方向不一致时把源页转 90 度
        # keep_source_rotation=True 时跳过, 留给用户指定的内容朝向
        slot_landscape = target_w > target_h
        src_landscape = src_w > src_h
        rotated = (slot_landscape != src_landscape) and not keep_source_rotation
        if rotated:
            # 先物理旋转源页(改 mediabox + 加 /Rotate)
            src_page.rotate(90)
            src_w, src_h = src_h, src_w

        # 等比缩放
        scale = min(target_w / src_w, target_h / src_h)
        new_w = src_w * scale
        new_h = src_h * scale
        offset_x = (target_w - new_w) / 2
        offset_y = (target_h - new_h) / 2

        new_page = PageObject.create_blank_page(
            writer, width=target_w, height=target_h,
        )
        t = (Transformation()
             .scale(sx=scale, sy=scale)
             .translate(tx=offset_x, ty=offset_y))
        try:
            new_page.merge_transformed_page(src_page, t, expand=False)
        except TypeError:
            new_page.mergeTransformedPage(src_page, t)
        writer.add_page(new_page)

    with open(out_path, "wb") as f:
        writer.write(f)
    return pypdf_name


def _repaginate_via_gs(in_path, out_path, target_size):
    """Fallback: 用 ghostscript 把 PDF 全部转换到 A4 大小."""
    if not have("gs"):
        die("缺少 pypdf/PyPDF2, 也找不到 ghostscript, 无法重排版到 A4")
    target_w, target_h = target_size
    # gs 的 -g<W>x<H> 单位是像素 (1/72 inch * 1pt = device dot)
    width_pt = int(target_w)
    height_pt = int(target_h)
    try:
        subprocess.run([
            "gs", "-dBATCH", "-dNOPAUSE", "-q",
            "-sDEVICE=pdfwrite",
            "-dPDFFitPage",
            f"-dDEVICEWIDTHPOINTS={width_pt}",
            f"-dDEVICEHEIGHTPOINTS={height_pt}",
            "-dFIXEDMEDIA",
            f"-sOutputFile={out_path}",
            in_path,
        ], check=True)
        return "gs"
    except subprocess.CalledProcessError as e:
        die(f"gs 重排版失败: {e}")


def detect_pdf_orientation(pdf_path):
    """
    探测 PDF 的"主导方向": portrait | landscape | None(读不出).
    用第一页 mediabox 的 width vs height 判断.
    多页 PDF 若各页方向不一, 仍以第一页为准 (实际场景里同一份 PDF 内方向多数一致).
    """
    pypdf_mod, pypdf_name = _import_pypdf()
    if pypdf_mod is None:
        return None
    try:
        if pypdf_name == "pypdf":
            from pypdf import PdfReader
        else:
            from PyPDF2 import PdfReader
        r = PdfReader(pdf_path)
        if not r.pages:
            return None
        mb = r.pages[0].mediabox
        w, h = float(mb.width), float(mb.height)
        return "landscape" if w > h else "portrait"
    except Exception:
        return None


def parse_content_orient_list(values, pdfs):
    """
    解析 --content-orient 的值 list.
    values 形如 ["a.pdf:landscape", "b.pdf:portrait,c.pdf:landscape"]
    pdfs 是已过滤的绝对路径 list, 用来做 basename 匹配.
    返回 dict {abs_path: "portrait"|"landscape"}, 找不到的 basename 静默忽略并打 WARN.
    """
    by_basename = {os.path.basename(p): p for p in pdfs}
    result = {}
    for v in values:
        for entry in v.split(","):
            entry = entry.strip()
            if not entry:
                continue
            if ":" not in entry:
                die(f"--content-orient 格式错误, 需要 FILE:portrait|landscape, 收到: {entry}")
            name, orient = entry.rsplit(":", 1)
            name = name.strip()
            orient = orient.strip().lower()
            if orient not in ("portrait", "landscape"):
                die(f"--content-orient 方向需要 portrait|landscape, 收到: {orient}")
            if name in by_basename:
                result[by_basename[name]] = orient
            else:
                print(f"[batch-print-pdf] WARN: --content-orient 未匹配到文件 {name!r}, 已忽略",
                      file=sys.stderr)
    return result


def repaginate_per_file_v3(pdfs, work_dir, paper_orientation, content_orientations):
    """
    v3 逐份重排版:
    - paper_orientation: "portrait" | "landscape" — 全局纸张方向, 决定输出 A4 尺寸
    - content_orientations: dict {abs_path: "portrait"|"landscape"} — 逐份指定的
      "内容朝向"; 不在 dict 里的份默认按"探测到的源方向"处理
    返回逐份重排后的文件路径 list (顺序与 pdfs 一致).

    规则:
    - target_size 永远是 paper_orientation 对应的 A4 尺寸 (统一)
    - 该份"内容朝向" == paper_orientation: 不旋转, 等比缩入
    - 该份"内容朝向" != paper_orientation: 旋转 90° 后缩入 (内容铺满, 收到的纸要转着看)
    """
    target = A4_PORTRAIT if paper_orientation == "portrait" else A4_LANDSCAPE
    out_paths = []
    for i, pdf in enumerate(pdfs):
        # 决定该份内容朝向: 优先用户指定 -> 探测源方向 -> 兜底 portrait
        content = content_orientations.get(pdf) or detect_pdf_orientation(pdf) or "portrait"
        # 旋转判断: content == paper => 不转 (keep_source_rotation=True)
        #          content != paper => 自动 90° (keep_source_rotation=False)
        keep = (content == paper_orientation)
        out = os.path.join(work_dir, f"v3_{i:03d}.pdf")
        print(f"[batch-print-pdf] 第 {i+1} 份 {os.path.basename(pdf)}: "
              f"内容朝向={content}, 纸张={paper_orientation}, "
              f"{'直接缩入' if keep else '旋转 90° 后缩入'}")
        repaginate_to_a4(pdf, out, target, keep_source_rotation=keep)
        out_paths.append(out)
    return out_paths


# ---------------------- print ----------------------

def print_with_gtk_dialog(pdf_path, job_title):
    try:
        import gi
        gi.require_version("Gtk", "3.0")
        gi.require_version("Poppler", "0.18")
        from gi.repository import Gtk, Poppler, Gio
    except (ImportError, ValueError) as e:
        print(f"[batch-print-pdf] GI unavailable ({e}), falling back", file=sys.stderr)
        return fallback_print(pdf_path, job_title)

    uri = Gio.File.new_for_path(pdf_path).get_uri()
    try:
        doc = Poppler.Document.new_from_file(uri, None)
    except Exception as e:
        die(f"无法读取合并后的 PDF: {e}")

    op = Gtk.PrintOperation()
    op.set_job_name(job_title)
    op.set_n_pages(doc.get_n_pages())
    op.set_unit(Gtk.Unit.POINTS)
    op.set_use_full_page(True)
    op.set_embed_page_setup(True)
    op.set_show_progress(True)

    def on_draw(operation, context, page_nr):
        cr = context.get_cairo_context()
        page = doc.get_page(page_nr)
        page.render_for_printing(cr)

    op.connect("draw_page", on_draw)

    result = op.run(Gtk.PrintOperationAction.PRINT_DIALOG, None)

    if result == Gtk.PrintOperationResult.ERROR:
        err = op.get_error()
        die(f"打印失败: {err.message if err else '未知错误'}")
    elif result == Gtk.PrintOperationResult.CANCEL:
        print("[batch-print-pdf] 用户取消", file=sys.stderr)
        return False
    return True


def fallback_print(pdf_path, job_title):
    if not have("lp"):
        die("系统未安装 CUPS (lp 命令), 无法打印.")
    try:
        subprocess.run(["lp", "-t", job_title, pdf_path], check=True)
        notify("已送打印队列", f"{job_title} 已送默认打印机")
        return True
    except subprocess.CalledProcessError as e:
        die(f"lp 打印失败: {e}")


# ---------------------- GUI options dialog ----------------------

CONFIG_DIR = os.path.expanduser("~/.config/batch-print-pdf")
CONFIG_FILE = os.path.join(CONFIG_DIR, "last.json")


def load_last_options():
    """读上次保存的对话框选项. 任何错误都返回默认值, 不打扰用户."""
    defaults = {
        "fit": "a4-landscape",
        "duplex": False,
        "insert_blank_after": "",
    }
    try:
        import json
        with open(CONFIG_FILE, "r", encoding="utf-8") as f:
            data = json.load(f)
        for k, v in defaults.items():
            if k in data:
                defaults[k] = data[k]
    except (FileNotFoundError, json.JSONDecodeError, OSError):
        pass
    return defaults


def save_last_options(opts):
    """把选项存到 last.json. 失败静默忽略."""
    try:
        import json
        os.makedirs(CONFIG_DIR, exist_ok=True)
        data = {
            "fit": opts.get("fit", "a4-landscape"),
            "duplex": bool(opts.get("duplex", False)),
            "insert_blank_after": ",".join(opts.get("insert_blank_after_raw", []) or []),
        }
        with open(CONFIG_FILE, "w", encoding="utf-8") as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
    except OSError:
        pass


def show_options_dialog(pdfs, current_opts):
    """
    弹一个 GTK 选项对话框. 返回更新后的 opts dict, 用户取消返回 None.
    若 GTK 不可用, 返回 current_opts (相当于跳过对话框).
    """
    try:
        import gi
        gi.require_version("Gtk", "3.0")
        from gi.repository import Gtk, GLib
    except (ImportError, ValueError) as e:
        print(f"[batch-print-pdf] GTK 不可用 ({e}), 跳过选项对话框", file=sys.stderr)
        return current_opts

    GLib_escape = GLib.markup_escape_text

    # 上次保存的默认值
    last = load_last_options()

    # 预扫描页数, 给用户参考
    total_pages = 0
    page_counts = []
    for p in pdfs:
        n = count_pages(p)
        if n is None:
            n = 0
        page_counts.append(n)
        total_pages += n

    dlg = Gtk.Dialog(
        title="批量打印 PDF — 选项",
        flags=Gtk.DialogFlags.MODAL,
    )
    dlg.add_button("取消", Gtk.ResponseType.CANCEL)
    btn_ok = dlg.add_button("继续 → 打印", Gtk.ResponseType.OK)
    btn_ok.get_style_context().add_class("suggested-action")
    dlg.set_default_response(Gtk.ResponseType.OK)
    dlg.set_default_size(480, -1)
    dlg.set_border_width(12)

    box = dlg.get_content_area()
    box.set_spacing(10)

    # 概览
    summary = Gtk.Label()
    summary.set_xalign(0)
    if total_pages > 0:
        summary.set_markup(
            f"<b>{len(pdfs)}</b> 个 PDF, 共 <b>{total_pages}</b> 页"
        )
    else:
        summary.set_markup(f"<b>{len(pdfs)}</b> 个 PDF")
    box.pack_start(summary, False, False, 0)

    box.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0)

    # ---- 排版 (= 全局纸张方向) ----
    fit_label = Gtk.Label()
    fit_label.set_xalign(0)
    fit_label.set_markup("<b>打印机纸张方向</b> <small>(所有页统一为同一种 A4 尺寸, 避免裁切)</small>")
    box.pack_start(fit_label, False, False, 0)

    fit_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
    fit_box.set_margin_start(12)
    rb_landscape = Gtk.RadioButton.new_with_label_from_widget(None, "A4 横纸")
    rb_portrait = Gtk.RadioButton.new_with_label_from_widget(rb_landscape, "A4 竖纸")
    rb_none = Gtk.RadioButton.new_with_label_from_widget(rb_landscape, "不重排版, 保持原尺寸 (各份尺寸不一时打印机可能裁切)")
    fit_radios = {"a4-landscape": rb_landscape, "a4-portrait": rb_portrait, "none": rb_none}
    fit_radios.get(last["fit"], rb_landscape).set_active(True)
    for rb in (rb_landscape, rb_portrait, rb_none):
        fit_box.pack_start(rb, False, False, 0)
    box.pack_start(fit_box, False, False, 0)

    box.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0)

    # ---- 文件列表 (顺序 + 内容朝向) ----
    files_label = Gtk.Label()
    files_label.set_xalign(0)
    files_label.set_markup(
        "<b>文件 (按打印顺序)</b> "
        "<small>↑↓ 调整顺序; 内容朝向跟纸张方向不一致时会自动旋转 90°</small>"
    )
    box.pack_start(files_label, False, False, 0)

    # 把文件 + 探测方向准备好; 用一个可变 list of dict, GUI 操作时直接改
    file_state = []  # [{path, name, pages, src_orient, content_orient_widget, row_widget, ...}]
    for p, n in zip(pdfs, page_counts):
        src = detect_pdf_orientation(p) or "portrait"
        file_state.append({
            "path": p,
            "name": os.path.basename(p),
            "pages": n,
            "src": src,
        })

    # 滚动列表 (最多大约 200px 高度, 多了滚)
    files_scroll = Gtk.ScrolledWindow()
    files_scroll.set_policy(Gtk.PolicyType.NEVER, Gtk.PolicyType.AUTOMATIC)
    files_scroll.set_min_content_height(min(220, max(60, len(pdfs) * 36)))
    files_scroll.set_shadow_type(Gtk.ShadowType.IN)

    files_list_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
    files_scroll.add(files_list_box)
    box.pack_start(files_scroll, False, False, 0)

    def _build_file_row(idx, item):
        row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=6)
        row.set_margin_start(6)
        row.set_margin_end(6)
        row.set_margin_top(2)
        row.set_margin_bottom(2)

        btn_up = Gtk.Button.new_from_icon_name("go-up-symbolic", Gtk.IconSize.BUTTON)
        btn_dn = Gtk.Button.new_from_icon_name("go-down-symbolic", Gtk.IconSize.BUTTON)
        btn_up.set_tooltip_text("上移")
        btn_dn.set_tooltip_text("下移")
        btn_up.set_sensitive(idx > 0)
        btn_dn.set_sensitive(idx < len(file_state) - 1)
        row.pack_start(btn_up, False, False, 0)
        row.pack_start(btn_dn, False, False, 0)

        name_lbl = Gtk.Label()
        name_lbl.set_xalign(0)
        name_lbl.set_hexpand(True)
        name_lbl.set_ellipsize(3)  # PANGO_ELLIPSIZE_END = 3
        pages_str = f"{item['pages']}p" if item['pages'] else "?p"
        src_str = "竖" if item["src"] == "portrait" else "横"
        name_lbl.set_markup(
            f"{GLib_escape(item['name'])} "
            f"<small>({pages_str}, 原:{src_str})</small>"
        )
        row.pack_start(name_lbl, True, True, 0)

        # 内容朝向下拉
        combo = Gtk.ComboBoxText()
        combo.append("portrait", "竖")
        combo.append("landscape", "横")
        # 默认值 = 上次选过的 (item['content']) 或 探测到的源方向
        combo.set_active_id(item.get("content", item["src"]))
        # combo 变化时刷新双面警告 (晚绑定: 此函数稍后定义)
        combo.connect("changed", lambda _c: _refresh_duplex_warn())
        item["combo"] = combo
        row.pack_start(combo, False, False, 0)

        item["row"] = row
        item["btn_up"] = btn_up
        item["btn_dn"] = btn_dn

        btn_up.connect("clicked", lambda _b, i=idx: _move(i, -1))
        btn_dn.connect("clicked", lambda _b, i=idx: _move(i, +1))
        return row

    def _rebuild_list():
        for child in files_list_box.get_children():
            files_list_box.remove(child)
        for i, item in enumerate(file_state):
            # 保留 combo 当前选择
            if "combo" in item:
                item["content"] = item["combo"].get_active_id()
            row = _build_file_row(i, item)
            files_list_box.pack_start(row, False, False, 0)
        files_list_box.show_all()
        # 重建后刷新双面警告 (函数稍后定义, 首次调用时未定义则跳过)
        try:
            _refresh_duplex_warn()
        except NameError:
            pass

    def _move(idx, delta):
        new = idx + delta
        if 0 <= new < len(file_state):
            file_state[idx], file_state[new] = file_state[new], file_state[idx]
            _rebuild_list()

    _rebuild_list()

    box.pack_start(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), False, False, 0)

    # ---- duplex ----
    cb_duplex = Gtk.CheckButton.new_with_label("双面打印自动补空白页 (--duplex)")
    cb_duplex.set_active(bool(last["duplex"]))
    box.pack_start(cb_duplex, False, False, 0)

    duplex_hint = Gtk.Label()
    duplex_hint.set_xalign(0)
    duplex_hint.set_margin_start(24)
    duplex_hint.set_markup(
        "<small>每份奇数页 PDF 后自动补 1 张白纸,"
        "保证每份新 PDF 都从纸的正面开始</small>"
    )
    box.pack_start(duplex_hint, False, False, 0)

    # 双面 + 横竖混排警告: 当前任一份的"内容朝向 != 纸张方向"且勾了双面时显示
    duplex_warn = Gtk.Label()
    duplex_warn.set_xalign(0)
    duplex_warn.set_margin_start(24)
    duplex_warn.set_line_wrap(True)
    duplex_warn.set_max_width_chars(54)
    box.pack_start(duplex_warn, False, False, 0)

    def _refresh_duplex_warn(*_args):
        if not cb_duplex.get_active():
            duplex_warn.set_text("")
            return
        # 当前纸张方向
        if rb_landscape.get_active():
            paper = "landscape"
        elif rb_portrait.get_active():
            paper = "portrait"
        else:
            duplex_warn.set_text("")
            return  # fit=none 时不警告 (用户自己负责)
        # 检测被旋转的份
        rotated = []
        for it in file_state:
            content = it["combo"].get_active_id() if "combo" in it else it["src"]
            if content != paper:
                rotated.append(it["name"])
        if rotated:
            names = "、".join(rotated[:3]) + ("…" if len(rotated) > 3 else "")
            duplex_warn.set_markup(
                f"<small><span foreground=\"#c33\">"
                f"⚠ 检测到 {len(rotated)} 份内容被旋转 90° 印在 A4 {('竖' if paper=='portrait' else '横')}纸上"
                f" ({GLib_escape(names)}); "
                f"双面打印翻面后这些份会上下颠倒, 建议拆任务打印或调整内容朝向"
                f"</span></small>"
            )
        else:
            duplex_warn.set_text("")

    cb_duplex.connect("toggled", _refresh_duplex_warn)
    rb_landscape.connect("toggled", _refresh_duplex_warn)
    rb_portrait.connect("toggled", _refresh_duplex_warn)
    rb_none.connect("toggled", _refresh_duplex_warn)
    # 把 combo 的 changed 钩子在 _build_file_row 里挂上 (重建会重新挂)
    # 这里第一次手动触发一次, 让重建后的列表立即反映
    _refresh_duplex_warn()

    # ---- 手动插页 ----
    insert_label = Gtk.Label()
    insert_label.set_xalign(0)
    insert_label.set_markup("<b>手动插空白页</b> <small>(可选)</small>")
    box.pack_start(insert_label, False, False, 0)

    entry_row = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8)
    entry_row.set_margin_start(12)
    entry = Gtk.Entry()
    entry.set_placeholder_text("例: 5,12,20")
    entry.set_text(last.get("insert_blank_after", ""))
    entry.set_hexpand(True)
    entry_row.pack_start(entry, True, True, 0)
    box.pack_start(entry_row, False, False, 0)

    insert_hint = Gtk.Label()
    insert_hint.set_xalign(0)
    insert_hint.set_margin_start(24)
    if total_pages > 0:
        hint_text = (
            f"<small>合并(并 duplex 处理)后的全局页号. "
            f"共 ~{total_pages} 页. 0=最前面, 末尾追加可填总页数</small>"
        )
    else:
        hint_text = "<small>合并(并 duplex 处理)后的全局页号. 0=最前面</small>"
    insert_hint.set_markup(hint_text)
    box.pack_start(insert_hint, False, False, 0)

    dlg.show_all()
    response = dlg.run()

    if response != Gtk.ResponseType.OK:
        dlg.destroy()
        return None

    # 收集结果
    new_opts = dict(current_opts)  # 保留 no_merge 等不在对话框里的字段
    for k, rb in fit_radios.items():
        if rb.get_active():
            new_opts["fit"] = k
            break
    # 文件列表: 顺序 + 逐份内容朝向
    new_opts["pdf_order"] = [it["path"] for it in file_state]
    new_opts["content_orient_dict"] = {
        it["path"]: it["combo"].get_active_id()
        for it in file_state
        if "combo" in it
    }
    new_opts["duplex"] = cb_duplex.get_active()
    insert_text = entry.get_text().strip()
    if insert_text:
        new_opts["insert_blank_after_raw"] = [insert_text]
    else:
        new_opts["insert_blank_after_raw"] = []

    dlg.destroy()
    save_last_options(new_opts)
    return new_opts


# ---------------------- args ----------------------

def parse_args(argv):
    """Tiny manual argparser to keep zero deps. Returns (opts, positional)."""
    opts = {
        "fit": "a4-landscape",
        "content_orient_raw": [],   # list of strings, parsed later (needs pdf list)
        "pdf_order": [],            # GUI 调整后的最终顺序; CLI 路径保持空
        "no_merge": False,
        "duplex": False,
        "insert_blank_after_raw": [],  # list of strings, parsed later
        "no_options_dialog": False,
        "_user_passed_options": False,  # 内部标记: 命令行有显式 --xxx 时跳过 GUI 对话框
    }
    positional = []
    it = iter(argv)
    for arg in it:
        if arg == "--fit":
            opts["fit"] = next(it, "a4-landscape")
            opts["_user_passed_options"] = True
        elif arg.startswith("--fit="):
            opts["fit"] = arg.split("=", 1)[1]
            opts["_user_passed_options"] = True
        elif arg == "--content-orient":
            opts["content_orient_raw"].append(next(it, ""))
            opts["_user_passed_options"] = True
        elif arg.startswith("--content-orient="):
            opts["content_orient_raw"].append(arg.split("=", 1)[1])
            opts["_user_passed_options"] = True
        elif arg == "--no-merge":
            opts["no_merge"] = True
            opts["_user_passed_options"] = True
        elif arg == "--duplex":
            opts["duplex"] = True
            opts["_user_passed_options"] = True
        elif arg == "--no-duplex":
            opts["duplex"] = False
            opts["_user_passed_options"] = True
        elif arg == "--insert-blank-after":
            opts["insert_blank_after_raw"].append(next(it, ""))
            opts["_user_passed_options"] = True
        elif arg.startswith("--insert-blank-after="):
            opts["insert_blank_after_raw"].append(arg.split("=", 1)[1])
            opts["_user_passed_options"] = True
        elif arg == "--no-options-dialog":
            opts["no_options_dialog"] = True
        elif arg in ("-h", "--help"):
            print(__doc__)
            sys.exit(0)
        elif arg.startswith("--"):
            die(f"未知选项: {arg}")
        else:
            positional.append(arg)

    if opts["fit"] not in ("a4-landscape", "a4-portrait", "none"):
        die(f"--fit 仅支持: a4-landscape | a4-portrait | none, 收到: {opts['fit']}")
    return opts, positional


# ---------------------- main ----------------------

def main():
    if len(sys.argv) < 2:
        print(__doc__, file=sys.stderr)
        sys.exit(2)

    opts, paths = parse_args(sys.argv[1:])

    if not paths:
        die("请至少传一个 PDF 路径")

    pdfs, skipped = filter_pdfs(paths)
    if not pdfs:
        die("没有有效的 PDF 文件")

    if skipped:
        print("[batch-print-pdf] 跳过非 PDF 或不存在的文件:", file=sys.stderr)
        for s in skipped:
            print(f"    {s}", file=sys.stderr)

    # Step 0: 决定是否弹 GUI 选项对话框
    # 触发条件: 用户没传任何 --xxx 选项 + 没显式 --no-options-dialog + 有图形会话
    should_show_dialog = (
        not opts["_user_passed_options"]
        and not opts["no_options_dialog"]
        and (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
    )
    if should_show_dialog:
        new_opts = show_options_dialog(pdfs, opts)
        if new_opts is None:
            print("[batch-print-pdf] 用户取消", file=sys.stderr)
            sys.exit(0)
        opts = new_opts

    # GUI 可能调整了 PDF 顺序; 否则保持文件管理器/命令行顺序
    if opts.get("pdf_order"):
        pdfs = opts["pdf_order"]

    # 解析逐份内容朝向 (CLI 传的) — GUI 路径会直接用 dict 覆盖
    content_orient = opts.get("content_orient_dict") or {}
    if opts["content_orient_raw"]:
        content_orient = parse_content_orient_list(opts["content_orient_raw"], pdfs)

    # 统一 A4 模式: --fit none 时跳过逐份重排, 直接合并 (尺寸不一由用户负责)
    use_unified_a4 = opts["fit"] != "none"
    paper_orientation = "portrait" if opts["fit"] == "a4-portrait" else "landscape"

    print(f"[batch-print-pdf] 待处理 {len(pdfs)} 个 PDF, fit={opts['fit']}, "
          f"content_orient={content_orient or '-'}, "
          f"duplex={opts['duplex']}, "
          f"insert_blank_after={opts['insert_blank_after_raw'] or '-'}")
    job_title = f"批量打印-{len(pdfs)}个PDF"

    with tempfile.TemporaryDirectory(prefix="batchprint-") as td:
        # Step 1: 逐份重排到统一 A4 (除非 --fit none) → 合并
        if use_unified_a4:
            paged_files = repaginate_per_file_v3(
                pdfs, td,
                paper_orientation=paper_orientation,
                content_orientations=content_orient,
            )
            if len(paged_files) == 1:
                current = paged_files[0]
            else:
                merged = os.path.join(td, "merged.pdf")
                tool = merge_pdfs(paged_files, merged)
                print(f"[batch-print-pdf] 逐份重排 + 合并完成 ({tool}): {merged}")
                current = merged
        elif len(pdfs) == 1:
            current = pdfs[0]
        else:
            merged = os.path.join(td, "merged.pdf")
            tool = merge_pdfs(pdfs, merged)
            print(f"[batch-print-pdf] 合并完成 ({tool}): {merged}")
            current = merged

        # Step 2: 计算空白页插入位置 (duplex 自动 + 手动指定, 合并去重)
        positions = []
        if opts["duplex"] and len(pdfs) > 1:
            page_counts = [count_pages(p) for p in pdfs]
            if any(c is None for c in page_counts):
                print("[batch-print-pdf] WARN: 部分文件无法读页数, --duplex 跳过这些文件",
                      file=sys.stderr)
            duplex_pos = compute_duplex_positions(page_counts)
            print(f"[batch-print-pdf] --duplex 输入页数={page_counts}, 自动补页位置(全局)={duplex_pos}")
            positions.extend(duplex_pos)

        if opts["insert_blank_after_raw"]:
            manual_pos = parse_position_list(opts["insert_blank_after_raw"])
            print(f"[batch-print-pdf] --insert-blank-after 手动位置={manual_pos}")
            positions.extend(manual_pos)

        if positions:
            # 去重 + 排序; 同位置同方向只插 1 张 (不累加, 避免误用)
            positions = sorted(set(positions))
            paded = os.path.join(td, "paded.pdf")
            tool = insert_blank_pages(current, paded, positions)
            if tool:
                print(f"[batch-print-pdf] 插入空白页完成 ({tool}): 共插 {len(positions)} 张")
                current = paded
            else:
                print("[batch-print-pdf] WARN: 插入空白页失败, 使用原合并 PDF", file=sys.stderr)

        # Step 3: 打印
        print_with_gtk_dialog(current, job_title)


if __name__ == "__main__":
    main()
