"""
A1 SwapMod Packer 的 PySide6 图形用户界面模块。

提供完整的桌面 GUI，用于：
- 拖放/添加多个 .3mf 输入文件
- 配置换盘 G-code、打包选项、输出规则
- 单个或批量构建打包的 3MF 文件
- 显示板缩略图预览、合计统计和构建日志

主入口为 ``main()`` 函数，启动 QApplication 并显示 ``MainWindow``。
"""

from __future__ import annotations

import json
import os
import re
import subprocess
import sys
import zipfile
from multiprocessing import freeze_support
from pathlib import Path
from typing import Any, Callable

# --- 平台适配：Windows 下使用 FreeType 字体引擎以避免字体渲染问题 ---
if sys.platform.startswith("win") and "QT_QPA_PLATFORM" not in os.environ:
    os.environ["QT_QPA_PLATFORM"] = "windows:fontengine=freetype"

try:
    from PySide6.QtCore import QEasingCurve, QEvent, QPropertyAnimation, Qt, QTimer, QUrl
    from PySide6.QtGui import QDragEnterEvent, QDragMoveEvent, QDropEvent, QFont, QIcon, QIntValidator, QKeyEvent, QPixmap
    from PySide6.QtWidgets import (
        QApplication,
        QAbstractItemView,
        QCheckBox,
        QComboBox,
        QFileDialog,
        QFormLayout,
        QGridLayout,
        QGraphicsOpacityEffect,
        QGroupBox,
        QHBoxLayout,
        QHeaderView,
        QLabel,
        QLineEdit,
        QMainWindow,
        QMessageBox,
        QPushButton,
        QStyle,
        QTableWidget,
        QTableWidgetItem,
        QTextEdit,
        QToolButton,
        QVBoxLayout,
        QWidget,
    )
except ImportError as exc:  # pragma: no cover
    raise SystemExit("PySide6 is required to run the GUI. Install it with: pip install PySide6") from exc

from . import APP_NAME, APP_TITLE
from .i18n import t, switch as i18n_switch, current_locale
from .batch import IndividualBuildTask, individual_batch_worker_count, run_individual_batch_builds
from .core import (
    BuildOptions,
    DEFAULT_ZIP_COMPRESS_LEVEL,
    PlateJob,
    ThreeMfSummary,
    build_packed_3mf,
    format_duration,
    format_filament,
    list_gcode_members,
    list_swap_gcode_files,
    read_3mf_summary,
)
from .builder import preview_members_for_gcode_member, resolve_output_gcode_member
from .paths import default_patch_config_path, default_swap_gcode_dir, program_root, user_settings_path
from .planning import (
    DEFAULT_OUTPUT_PATTERN,
    OutputNamingOptions,
    OutputSummary,
    make_unique_for_run,
    resolve_output_path,
    summarize_jobs_for_output,
    three_mf_summary_from_mapping,
)

# --- 自定义 Qt 数据角色，用于在表格单元格中存储摘要和路径信息 ---
SUMMARY_ROLE = Qt.UserRole + 100   # 存储 ThreeMfSummary 数据的角色
PATH_ROLE = Qt.UserRole + 101      # 存储文件绝对路径的角色

# --- 表格列索引常量 ---
ORDER_COLUMN = 0      # 顺序列（上移/下移按钮）
FILE_COLUMN = 1       # 文件名列
COPIES_COLUMN = 2     # 份数列
TIME_COLUMN = 3       # 预估时间列
FILAMENT_COLUMN = 4   # 耗材用量列

# --- 3MF 预览图匹配正则：Metadata/plate_N.png 或 Metadata/plate_N_small.png ---
PREVIEW_IMAGE_RE = re.compile(r"^Metadata/plate_(\d+)(?:_small)?\.png$", re.IGNORECASE)


def preview_image_sort_key(member_name: str) -> tuple[int, int, str]:
    """返回预览图排序键：(是否小图, 板号, 原始名称)。

    排序规则：大图优先于小图，板号升序，同号按名称排序。
    """
    match = PREVIEW_IMAGE_RE.match(member_name)
    plate_number = int(match.group(1)) if match else 9999
    is_small = 1 if member_name.lower().endswith("_small.png") else 0
    return (is_small, plate_number, member_name.lower())


def first_preview_image_member(member_names: list[str], gcode_member: str | None = None) -> str | None:
    """从 3MF 存档成员列表中查找最佳预览图。

    优先匹配与指定 gcode_member 关联的预览图，其次匹配所有 plate_N.png。
    """
    available_members = set(member_names)
    if gcode_member is not None:
        candidates = [name for name in preview_members_for_gcode_member(gcode_member) if name in available_members]
        if candidates:
            return sorted(candidates, key=preview_image_sort_key)[0]
    candidates = [name for name in member_names if PREVIEW_IMAGE_RE.match(name)]
    if not candidates:
        candidates = [
            name
            for name in member_names
            if name.lower().startswith("metadata/") and name.lower().endswith(".png")
        ]
    if not candidates:
        return None
    return sorted(candidates, key=preview_image_sort_key)[0]


class SuccessToast(QLabel):
    """构建成功时的浮动提示条组件。

    提供淡入-停留-淡出动画效果，自动居中于父窗口底部。
    不会拦截鼠标事件，点击可穿透到下层控件。
    """

    FADE_MS = 1000   # 淡入/淡出动画时长（毫秒）
    HOLD_MS = 5000   # 停留显示时长（毫秒）

    def __init__(self, parent: QWidget) -> None:
        """初始化浮动提示条，创建透明度动画和停留计时器。"""
        super().__init__(parent)
        self.setAlignment(Qt.AlignCenter)
        self.setWordWrap(True)
        self.setAttribute(Qt.WA_TransparentForMouseEvents)
        self.setStyleSheet(
            """
            QLabel {
                background: #f4fff6;
                color: #183f22;
                border: 3px solid #2e7d32;
                border-radius: 8px;
                padding: 14px 24px;
                font-size: 16px;
                font-weight: 700;
            }
            """
        )
        self._opacity_effect = QGraphicsOpacityEffect(self)
        self.setGraphicsEffect(self._opacity_effect)
        # 淡入动画
        self._fade_in = QPropertyAnimation(self._opacity_effect, b"opacity", self)
        self._fade_in.setDuration(self.FADE_MS)
        self._fade_in.setEasingCurve(QEasingCurve.InOutQuad)
        # 淡出动画
        self._fade_out = QPropertyAnimation(self._opacity_effect, b"opacity", self)
        self._fade_out.setDuration(self.FADE_MS)
        self._fade_out.setEasingCurve(QEasingCurve.InOutQuad)
        self._fade_out.finished.connect(self.hide)
        # 停留计时器
        self._hold_timer = QTimer(self)
        self._hold_timer.setSingleShot(True)
        self._hold_timer.timeout.connect(self._start_fade_out)
        # 监听父窗口大小变化，自动重新定位
        parent.installEventFilter(self)
        self.hide()

    def show_message(self, message: str) -> None:
        """显示指定消息文本，启动淡入-停留-淡出动画序列。"""
        self._hold_timer.stop()
        self._fade_in.stop()
        self._fade_out.stop()
        self.setText(message)
        self._fit_to_parent()
        self._position()
        self._opacity_effect.setOpacity(0.0)
        self.show()
        self.raise_()
        self._fade_in.setStartValue(0.0)
        self._fade_in.setEndValue(1.0)
        self._fade_in.start()
        self._hold_timer.start(self.FADE_MS + self.HOLD_MS)

    def eventFilter(self, watched: object, event: QEvent) -> bool:
        """监听父窗口 Resize 事件，自动调整提示条尺寸和位置。"""
        if watched is self.parentWidget() and event.type() == QEvent.Resize and self.isVisible():
            self._fit_to_parent()
            self._position()
        return super().eventFilter(watched, event)

    def _fit_to_parent(self) -> None:
        """根据父窗口宽度自适应提示条的最大宽度。"""
        parent = self.parentWidget()
        if parent is None:
            return
        self.setMaximumWidth(max(360, min(720, parent.width() - 48)))
        self.adjustSize()

    def _position(self) -> None:
        """将提示条定位到父窗口底部居中位置。"""
        parent = self.parentWidget()
        if parent is None:
            return
        margin = 24
        x = max(margin, (parent.width() - self.width()) // 2)
        y = max(margin, parent.height() - self.height() - margin)
        self.move(x, y)

    def _start_fade_out(self) -> None:
        """启动淡出动画，从当前透明度渐变至完全透明。"""
        self._fade_out.stop()
        self._fade_out.setStartValue(self._opacity_effect.opacity())
        self._fade_out.setEndValue(0.0)
        self._fade_out.start()


class DropTableWidget(QTableWidget):
    """支持拖放 .3mf 文件的表格控件。

    允许用户将 .3mf 文件或文件夹从文件管理器拖放到表格中。
    支持按 Delete 键删除选中行。
    """

    def __init__(
        self,
        on_files_dropped: Callable[[list[Path]], None],
        on_delete_pressed: Callable[[], None],
        parent: QWidget | None = None,
    ) -> None:
        """初始化拖放表格。

        参数：
            on_files_dropped: 文件拖放完成时的回调，接收 Path 列表。
            on_delete_pressed: 按下 Delete 键时的回调。
        """
        super().__init__(parent)
        self.on_files_dropped = on_files_dropped
        self.on_delete_pressed = on_delete_pressed
        self.setAcceptDrops(True)
        self.viewport().setAcceptDrops(True)
        self.viewport().installEventFilter(self)
        self.setDragDropMode(QAbstractItemView.DropOnly)

    def keyPressEvent(self, event: QKeyEvent) -> None:
        """处理键盘事件：Delete 键删除选中行。"""
        if event.key() == Qt.Key_Delete and self.selectedIndexes():
            self.on_delete_pressed()
            event.accept()
            return
        super().keyPressEvent(event)

    def eventFilter(self, watched: object, event: QEvent) -> bool:
        """过滤视口拖放事件，仅接受包含 .3mf 文件的拖放操作。"""
        if watched is self.viewport():
            if event.type() in {QEvent.DragEnter, QEvent.DragMove}:
                drag_event = event  # type: ignore[assignment]
                if self._has_3mf_urls(drag_event):
                    drag_event.acceptProposedAction()
                    return True
            if event.type() == QEvent.Drop:
                drop_event = event  # type: ignore[assignment]
                paths = self._paths_from_urls(drop_event.mimeData().urls())
                if paths:
                    self.on_files_dropped(paths)
                    drop_event.acceptProposedAction()
                    return True
        return super().eventFilter(watched, event)

    def dragEnterEvent(self, event: QDragEnterEvent) -> None:
        """拖入事件：仅接受包含 .3mf 文件的拖放。"""
        if self._has_3mf_urls(event):
            event.acceptProposedAction()
            return
        super().dragEnterEvent(event)

    def dragMoveEvent(self, event: QDragMoveEvent) -> None:
        """拖移事件：仅接受包含 .3mf 文件的拖放。"""
        if self._has_3mf_urls(event):
            event.acceptProposedAction()
            return
        super().dragMoveEvent(event)

    def dropEvent(self, event: QDropEvent) -> None:
        """释放事件：提取 .3mf 文件路径并触发回调。"""
        paths = self._paths_from_urls(event.mimeData().urls())
        if paths:
            self.on_files_dropped(paths)
            event.acceptProposedAction()
            return
        super().dropEvent(event)

    def _has_3mf_urls(self, event: Any) -> bool:
        """检查拖放事件是否包含至少一个 .3mf 文件 URL。"""
        if not event.mimeData().hasUrls():
            return False
        return bool(self._paths_from_urls(event.mimeData().urls()))

    def _paths_from_urls(self, urls: list[QUrl]) -> list[Path]:
        """从 URL 列表中提取 .3mf 文件路径，支持文件和文件夹。"""
        result: list[Path] = []
        for url in urls:
            local_path = url.toLocalFile()
            if not local_path:
                continue
            path = Path(local_path)
            if path.is_dir():
                result.extend(sorted(item for item in path.iterdir() if item.is_file() and item.suffix.lower() == ".3mf"))
            elif path.is_file() and path.suffix.lower() == ".3mf":
                result.append(path)
        return result


class MainWindow(QMainWindow):
    """应用程序主窗口。

    职责：
    - 管理 3MF 输入文件列表（添加、移除、排序、份数调整）
    - 提供打包选项配置界面
    - 显示缩略图预览和输出预览
    - 执行单个/批量构建并显示结果日志
    - 持久化用户设置到本地 JSON 文件
    """

    def __init__(self) -> None:
        """初始化主窗口：创建 UI、加载设置、连接信号。"""
        super().__init__()
        self.setWindowTitle(APP_TITLE)
        self.resize(960, 640)
        self.setAcceptDrops(True)
        self._updating_table = False       # 防重入标记：表格正在批量更新中
        self._loading_settings = True      # 防重入标记：正在从设置恢复 UI 状态
        self._settings = self.load_settings()
        self._shared_growth_enabled = False  # 窗口高度足够时，文件区和日志区共享扩展空间
        self.build_ui()
        self.load_swap_gcode_to_combo()
        self.restore_settings_to_ui()
        self.connect_option_signals()
        # 确保语言状态与保存的 locale 一致
        saved_locale = self.language_combo.currentData()
        if saved_locale:
            i18n_switch(saved_locale)
        self._refresh_ui_texts()
        self._loading_settings = False
        self.update_total_summary()
        self.update_output_preview()

    # =========================================================================
    # 设置存取
    # =========================================================================

    def load_settings(self) -> dict[str, Any]:
        """从用户设置 JSON 文件加载设置，返回字典。"""
        path = user_settings_path()
        if path.exists():
            try:
                data = json.loads(path.read_text(encoding="utf-8"))
                return data if isinstance(data, dict) else {}
            except Exception:
                return {}
        return {}

    def save_settings(self) -> None:
        """将当前设置字典写入用户设置 JSON 文件。"""
        path = user_settings_path()
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(json.dumps(self._settings, indent=2), encoding="utf-8")

    # =========================================================================
    # UI 构建
    # =========================================================================

    def build_ui(self) -> None:
        """构建完整的主窗口用户界面。

        布局结构（自上而下）：
        1. 文件输入区（表格 + 缩略图预览 + 操作按钮）
        2. 打包选项区（网格布局）
        3. 输出区（目录、文件名规则、预览）
        4. 日志区（只读文本框）
        """
        central = QWidget(self)
        root = QVBoxLayout(central)
        root.setSpacing(4)
        self.root_layout = root

        # ---- 顶部栏：语言选择（右上角） ----
        top_bar = QHBoxLayout()
        top_bar.addStretch(1)
        self.language_label = QLabel(t("gui.options.language") + ":")
        self.language_combo = QComboBox()
        self.language_combo.addItem("简体中文", "zh_CN")
        self.language_combo.addItem("English", "en")
        self.language_combo.setFixedWidth(120)
        top_bar.addWidget(self.language_label)
        top_bar.addWidget(self.language_combo)
        root.addLayout(top_bar)

        # =====================================================================
        # --- 文件输入区 ---
        # =====================================================================
        file_group = QGroupBox(t("gui.file_group.title"))
        self.file_group = file_group
        file_layout = QVBoxLayout(file_group)
        file_body = QHBoxLayout()

        # --- 文件表格 ---
        table_layout = QVBoxLayout()
        self.table = DropTableWidget(self.add_paths, self.remove_selected)
        self.table.setColumnCount(5)
        self.table.setHorizontalHeaderLabels([t("gui.file_group.table.header.order"), t("gui.file_group.table.header.file"), t("gui.file_group.table.header.copies"), t("gui.file_group.table.header.time"), t("gui.file_group.table.header.filament")])
        self.table.horizontalHeader().setSectionResizeMode(ORDER_COLUMN, QHeaderView.ResizeToContents)
        self.table.horizontalHeader().setSectionResizeMode(FILE_COLUMN, QHeaderView.Stretch)
        self.table.horizontalHeader().setSectionResizeMode(COPIES_COLUMN, QHeaderView.ResizeToContents)
        self.table.horizontalHeader().setSectionResizeMode(TIME_COLUMN, QHeaderView.ResizeToContents)
        self.table.horizontalHeader().setSectionResizeMode(FILAMENT_COLUMN, QHeaderView.ResizeToContents)
        self.table.setColumnWidth(ORDER_COLUMN, 78)
        self.table.verticalHeader().setDefaultSectionSize(26)
        self.table.verticalHeader().hide()
        self.table.setMinimumHeight(120)
        self.table.setSelectionBehavior(QTableWidget.SelectRows)
        self.table.setAlternatingRowColors(True)
        self.table.setToolTip(t("gui.file_group.table.tooltip"))
        self.table.itemChanged.connect(self.on_table_item_changed)
        self.table.itemSelectionChanged.connect(self.update_thumbnail_preview)
        table_layout.addWidget(self.table)

        # --- 合计统计标签 ---
        self.total_summary_label = QLabel(t("gui.file_group.total_summary.empty"))
        table_layout.addWidget(self.total_summary_label)
        file_body.addLayout(table_layout, 1)

        # --- 缩略图预览 ---
        self.preview_group = QGroupBox(t("gui.preview.title"))
        preview_layout = QVBoxLayout(self.preview_group)
        self.thumbnail_label = QLabel(t("gui.preview.select_hint"))
        self.thumbnail_label.setAlignment(Qt.AlignCenter)
        self.thumbnail_label.setFixedSize(240, 170)
        self.thumbnail_label.setStyleSheet(
            """
            QLabel {
                background: #f8f8f8;
                border: 1px solid #cfcfcf;
                color: #666;
            }
            """
        )
        self.thumbnail_name_label = QLabel("")
        self.thumbnail_name_label.setWordWrap(True)
        self.thumbnail_name_label.setMaximumWidth(280)
        preview_layout.addWidget(self.thumbnail_label)
        preview_layout.addWidget(self.thumbnail_name_label)
        preview_layout.addStretch(1)
        file_body.addWidget(self.preview_group)
        file_layout.addLayout(file_body)

        # --- 文件操作按钮栏 ---
        file_buttons = QHBoxLayout()
        self.add_button = QPushButton(t("gui.button.add_3mf"))
        self.remove_button = QPushButton(t("gui.button.remove"))
        self.remove_all_button = QPushButton(t("gui.button.remove_all"))
        self.apply_default_copies_button = QPushButton(t("gui.button.apply_copies"))
        self.build_button = QPushButton(t("gui.button.build"))
        self.build_button.setMinimumHeight(48)
        self.build_button.setMinimumWidth(160)
        build_font = self.build_button.font()
        build_font.setPointSize(build_font.pointSize() + 2)
        build_font.setBold(True)
        self.build_button.setFont(build_font)

        self.add_button.clicked.connect(self.add_files)
        self.remove_button.clicked.connect(self.remove_selected)
        self.remove_all_button.clicked.connect(self.remove_all)
        self.apply_default_copies_button.clicked.connect(self.apply_default_copies_to_selected)
        self.build_button.clicked.connect(self.build_output)

        for button in (self.add_button, self.remove_button, self.remove_all_button, self.apply_default_copies_button):
            button.setMinimumHeight(32)
            file_buttons.addWidget(button)
        file_buttons.addStretch(1)
        file_buttons.addWidget(self.build_button)
        file_layout.addLayout(file_buttons)
        root.addWidget(file_group, 1)

        # =====================================================================
        # --- 打包选项区 ---
        # =====================================================================
        self.options_group = QGroupBox(t("gui.options.group.title"))
        grid = QGridLayout(self.options_group)
        grid.setColumnStretch(1, 1)
        grid.setColumnStretch(3, 1)
        grid.setHorizontalSpacing(16)
        grid.setVerticalSpacing(4)

        # ---- 控件创建 ----
        # 换盘 G-code 选择
        self.swap_gcode_combo = QComboBox()
        self.swap_gcode_combo.setMinimumWidth(240)
        self.swap_gcode_combo.setMaximumWidth(440)
        self.refresh_button = QPushButton(t("gui.options.refresh"))
        self.open_folder_button = QPushButton(t("gui.options.open_folder"))
        self.refresh_button.setFixedWidth(self.refresh_button.sizeHint().width())
        self.open_folder_button.setFixedWidth(self.open_folder_button.sizeHint().width())
        self.refresh_button.clicked.connect(self.load_swap_gcode_to_combo)
        self.open_folder_button.clicked.connect(self.open_swap_gcode_folder)

        # 默认份数
        self.default_copies_edit = QLineEdit("1")
        self.default_copies_edit.setValidator(QIntValidator(1, 9999))
        self.default_copies_edit.setFixedWidth(64)

        # 热床降温
        self.bed_cooldown_check = QCheckBox(t("gui.options.bed_cooldown"))
        self.bed_cooldown_check.setChecked(True)
        self.cool_bed_edit = QLineEdit("45")
        self.cool_bed_edit.setValidator(QIntValidator(0, 120))
        self.cool_bed_edit.setFixedWidth(48)
        bed_row = QHBoxLayout()
        bed_row.addWidget(self.bed_cooldown_check)
        bed_row.addWidget(self.cool_bed_edit)
        self.bed_cooldown_suffix_label = QLabel(t("gui.options.bed_cooldown.suffix"))
        bed_row.addWidget(self.bed_cooldown_suffix_label)
        bed_row.addStretch(1)

        # 换盘后等待时间（勾选后启用，不勾选则不插入等待指令）
        self.eject_wait_check = QCheckBox(t("gui.options.eject_wait"))
        self.eject_wait_check.setChecked(True)
        self.wait_edit = QLineEdit("30")
        self.wait_edit.setValidator(QIntValidator(0, 3600))
        self.wait_edit.setFixedWidth(56)
        eject_wait_row = QHBoxLayout()
        eject_wait_row.addWidget(self.eject_wait_check)
        eject_wait_row.addWidget(self.wait_edit)
        self.wait_suffix_label = QLabel(t("gui.options.wait_suffix"))
        eject_wait_row.addWidget(self.wait_suffix_label)
        eject_wait_row.addStretch(1)

        # 剩余时间板号显示
        self.show_plate_number_check = QCheckBox(t("gui.options.plate_number"))
        self.show_plate_number_check.setChecked(True)

        # 最后换盘
        self.swap_final_check = QCheckBox(t("gui.options.final_swap"))
        self.swap_final_check.setChecked(True)

        # G-code 补丁
        self.patch_check = QCheckBox(t("gui.options.gcode_patches"))
        self.patch_check.setToolTip(t("gui.options.gcode_patches.tooltip"))
        self.patch_check.setChecked(True)
        patch_row = QHBoxLayout()
        self.open_patch_button = QPushButton(t("gui.options.open_config"))
        self.open_patch_button.clicked.connect(self.open_patch_config)
        patch_row.addWidget(self.patch_check)
        patch_row.addWidget(self.open_patch_button)
        patch_row.addStretch(1)

        # 3MF 元数据模式
        self.metadata_combo = QComboBox()
        self.metadata_combo.addItem(t("gui.options.metadata.source"), "source")
        self.metadata_combo.addItem(t("gui.options.metadata.sum"), "sum")
        self.metadata_combo.setFixedWidth(220)

        # ZIP 压缩级别
        self.zip_level_combo = QComboBox()
        for level in range(1, 10):
            self.zip_level_combo.addItem(t("gui.options.zip_compression.level_format", level=level), level)
        self.zip_level_combo.setCurrentIndex(DEFAULT_ZIP_COMPRESS_LEVEL - 1)
        self.zip_level_combo.setFixedWidth(120)
        self.zip_level_combo.setToolTip(t("gui.options.zip_compression.tooltip"))

        # 独立批处理模式
        self.individual_batch_check = QCheckBox(t("gui.options.batch_mode"))
        self.individual_batch_check.setToolTip(
            t("gui.options.batch_mode.tooltip")
        )

        # 构建后清空 / 跳过重复
        self.clear_after_build_check = QCheckBox(t("gui.options.clear_after_build"))
        self.clear_after_build_check.setChecked(False)
        self.skip_duplicates_check = QCheckBox(t("gui.options.skip_duplicates"))
        self.skip_duplicates_check.setChecked(True)

        # 标签列固定宽度（左对齐），控件列自动对齐（stretch=1 平分空间）
        grid.setColumnMinimumWidth(0, 120)
        grid.setColumnMinimumWidth(2, 90)
        for label_text, row, col in [
            (t("gui.options.swap_gcode.label"), 0, 0),
            (t("gui.options.default_copies"), 1, 0),
            (t("gui.options.eject_wait.label"), 2, 0),
            (t("gui.options.final_swap.label"), 3, 0),
            (t("gui.options.metadata.label"), 4, 0),
            (t("gui.options.batch_mode.label"), 5, 0),
            (t("gui.options.bed_cooldown.label"), 1, 2),
            (t("gui.options.plate_number.label"), 2, 2),
            (t("gui.options.gcode_patches.label"), 3, 2),
            (t("gui.options.zip_compression.label"), 4, 2),
            (t("gui.options.input_handling"), 5, 2),
            (t("gui.options.clear_after_build.label"), 6, 0),
        ]:
            label = QLabel(label_text)
            label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
            grid.addWidget(label, row, col)

        # 第 0 行：换盘 G-code（下拉框跨 col1-2，按钮放 col3 对齐右侧控件）
        grid.addWidget(self.swap_gcode_combo, 0, 1, 1, 2)
        swap_btn_row = QHBoxLayout()
        swap_btn_row.addWidget(self.refresh_button)
        swap_btn_row.addWidget(self.open_folder_button)
        swap_btn_row.addStretch(1)
        grid.addLayout(swap_btn_row, 0, 3)

        # 第 1 行：默认份数 | 热床降温
        grid.addWidget(self.default_copies_edit, 1, 1)
        grid.addLayout(bed_row, 1, 3)

        # 第 2 行：换盘后等待 | 剩余时间板号
        grid.addLayout(eject_wait_row, 2, 1)
        grid.addWidget(self.show_plate_number_check, 2, 3)

        # 第 3 行：最后换盘 | G-code 补丁
        grid.addWidget(self.swap_final_check, 3, 1)
        grid.addLayout(patch_row, 3, 3)

        # 第 4 行：3MF 元数据 | ZIP 压缩
        grid.addWidget(self.metadata_combo, 4, 1)
        grid.addWidget(self.zip_level_combo, 4, 3)

        # 第 5 行：批处理模式 | 输入处理
        grid.addWidget(self.individual_batch_check, 5, 1)
        grid.addWidget(self.skip_duplicates_check, 5, 3)

        # 第 6 行：构建成功后清空输入列表（独立一行）
        grid.addWidget(self.clear_after_build_check, 6, 1, 1, 3)

        root.addWidget(self.options_group)

        # =====================================================================
        # --- 输出区 ---
        # =====================================================================
        self.output_group = QGroupBox(t("gui.output.group.title"))
        output_layout = QFormLayout(self.output_group)

        # 输出目录
        output_dir_row = QHBoxLayout()
        self.output_dir_edit = QLineEdit()
        self.output_dir_edit.setPlaceholderText(t("gui.output.dir.placeholder"))
        self.output_dir_edit.setMinimumWidth(240)
        self.output_dir_edit.setMaximumWidth(520)
        self.browse_output_dir_button = QPushButton(t("gui.output.dir.browse"))
        self.browse_output_dir_button.setFixedWidth(self.browse_output_dir_button.sizeHint().width())
        self.browse_output_dir_button.clicked.connect(self.choose_output_dir)
        output_dir_row.addWidget(self.output_dir_edit, 1)
        output_dir_row.addWidget(self.browse_output_dir_button)
        self.output_dir_label = QLabel(t("gui.output.dir.label"))
        output_layout.addRow(self.output_dir_label, output_dir_row)

        # 输出文件名规则
        self.output_name_edit = QLineEdit(DEFAULT_OUTPUT_PATTERN)
        self.output_name_edit.setPlaceholderText(t("gui.output.filename_rule.placeholder"))
        self.output_name_edit.setMinimumWidth(240)
        self.output_name_edit.setMaximumWidth(520)
        filename_rule_row = QHBoxLayout()
        self.output_rule_help_button = QPushButton(t("gui.output.filename_rule.help"))
        self.output_rule_help_button.setFixedWidth(34)
        self.output_rule_help_button.setToolTip(t("gui.output.filename_rule.help.tooltip"))
        self.output_rule_help_button.clicked.connect(self.show_output_rule_help)
        filename_rule_row.addWidget(self.output_name_edit, 1)
        filename_rule_row.addWidget(self.output_rule_help_button)
        self.output_filename_rule_label = QLabel(t("gui.output.filename_rule"))
        output_layout.addRow(self.output_filename_rule_label, filename_rule_row)

        # 输出预览
        self.output_preview_label = QLabel("-")
        self.output_preview_label_title = QLabel(t("gui.output.preview"))
        output_layout.addRow(self.output_preview_label_title, self.output_preview_label)

        root.addWidget(self.output_group)

        # =====================================================================
        # --- 日志区 ---
        # =====================================================================
        self.log = QTextEdit()
        self.log.setReadOnly(True)
        self.log.setMinimumHeight(80)
        root.addWidget(self.log, 0)

        self.setCentralWidget(central)
        self.success_toast = SuccessToast(central)
        self.update_vertical_growth_policy()

    # =========================================================================
    # 窗口事件
    # =========================================================================

    def update_vertical_growth_policy(self) -> None:
        """根据窗口高度动态调整文件区和日志区的垂直扩展比例。

        窗口高度 >= 980px 时，文件区和日志区按 4:1 共享扩展空间；
        否则文件区独占扩展空间。
        """
        if not hasattr(self, "root_layout") or not hasattr(self, "file_group") or not hasattr(self, "log"):
            return
        shared_growth = self.height() >= 980
        if shared_growth == self._shared_growth_enabled:
            return
        self._shared_growth_enabled = shared_growth
        self.root_layout.setStretchFactor(self.file_group, 4 if shared_growth else 1)
        self.root_layout.setStretchFactor(self.log, 1 if shared_growth else 0)

    def resizeEvent(self, event: QEvent) -> None:
        """窗口大小变化时，更新垂直扩展策略并刷新缩略图。"""
        super().resizeEvent(event)
        self.update_vertical_growth_policy()
        self.update_thumbnail_preview()

    # =========================================================================
    # 设置同步
    # =========================================================================

    def connect_option_signals(self) -> None:
        """将所有选项控件的值变更信号连接到设置保存函数。"""
        self.swap_gcode_combo.currentIndexChanged.connect(self.save_current_settings)
        self.default_copies_edit.textChanged.connect(self.save_current_settings)
        self.bed_cooldown_check.stateChanged.connect(self.save_current_settings)
        self.cool_bed_edit.textChanged.connect(self.save_current_settings)
        self.eject_wait_check.stateChanged.connect(self.save_current_settings)
        self.eject_wait_check.toggled.connect(self.wait_edit.setEnabled)
        self.wait_edit.textChanged.connect(self.save_current_settings)
        self.show_plate_number_check.stateChanged.connect(self.save_current_settings)
        self.swap_final_check.stateChanged.connect(self.save_current_settings)
        self.patch_check.stateChanged.connect(self.save_current_settings)
        self.metadata_combo.currentIndexChanged.connect(self.save_current_settings)
        self.zip_level_combo.currentIndexChanged.connect(self.save_current_settings)
        self.individual_batch_check.stateChanged.connect(self.on_individual_batch_toggled)
        self.clear_after_build_check.stateChanged.connect(self.save_current_settings)
        self.skip_duplicates_check.stateChanged.connect(self.save_current_settings)
        self.output_dir_edit.textChanged.connect(self.on_output_rule_changed)
        self.output_name_edit.textChanged.connect(self.on_output_rule_changed)
        self.language_combo.currentIndexChanged.connect(self.on_language_changed)

    def restore_settings_to_ui(self) -> None:
        """从加载的设置字典恢复所有 UI 控件的状态。"""
        options = self._settings.get("packing_options", {})
        if not isinstance(options, dict):
            return
        self.default_copies_edit.setText(str(int(options.get("default_copies", 1))))
        self.bed_cooldown_check.setChecked(bool(options.get("bed_cooldown_enabled", True)))
        self.cool_bed_edit.setText(str(int(options.get("cool_bed_temp", 45))))
        self.eject_wait_check.setChecked(bool(options.get("eject_wait_enabled", True)))
        self.wait_edit.setText(str(int(options.get("wait_after_eject_seconds", 30))))
        self.show_plate_number_check.setChecked(bool(options.get("show_plate_number", True)))
        self.swap_final_check.setChecked(bool(options.get("swap_after_final", True)))
        self.patch_check.setChecked(bool(options.get("apply_gcode_patches", True)))
        self.individual_batch_check.setChecked(bool(options.get("individual_batch_mode", False)))
        self.clear_after_build_check.setChecked(bool(options.get("clear_after_build", False)))
        self.skip_duplicates_check.setChecked(bool(options.get("skip_duplicates", True)))
        saved_locale = str(options.get("locale", "zh_CN"))
        locale_index = self.language_combo.findData(saved_locale)
        if locale_index >= 0:
            self.language_combo.setCurrentIndex(locale_index)
        self.output_dir_edit.setText(str(options.get("output_directory", "")))
        self.output_name_edit.setText(str(options.get("output_filename_rule", DEFAULT_OUTPUT_PATTERN)))
        metadata_mode = options.get("metadata_mode", "sum")
        metadata_index = self.metadata_combo.findData(metadata_mode)
        if metadata_index >= 0:
            self.metadata_combo.setCurrentIndex(metadata_index)
        try:
            zip_level = int(options.get("zip_compress_level", DEFAULT_ZIP_COMPRESS_LEVEL))
        except (TypeError, ValueError):
            zip_level = DEFAULT_ZIP_COMPRESS_LEVEL
        zip_level_index = self.zip_level_combo.findData(min(9, max(1, zip_level)))
        if zip_level_index >= 0:
            self.zip_level_combo.setCurrentIndex(zip_level_index)
        swap_gcode = options.get("swap_gcode") or self._settings.get("last_swap_gcode")
        if swap_gcode:
            index = self.swap_gcode_combo.findData(str(swap_gcode))
            if index < 0:
                index = self.swap_gcode_combo.findText(Path(str(swap_gcode)).name)
            if index >= 0:
                self.swap_gcode_combo.setCurrentIndex(index)

    def collect_current_settings(self) -> dict[str, Any]:
        """从 UI 控件收集当前所有打包选项，返回设置字典。"""
        return {
            "swap_gcode": self.swap_gcode_combo.currentData() or "",
            "default_copies": int(self.default_copies_edit.text() or 1),
            "bed_cooldown_enabled": self.bed_cooldown_check.isChecked(),
            "cool_bed_temp": int(self.cool_bed_edit.text() or 45),
            "eject_wait_enabled": self.eject_wait_check.isChecked(),
            "wait_after_eject_seconds": int(self.wait_edit.text() or 30),
            "show_plate_number": self.show_plate_number_check.isChecked(),
            "swap_after_final": self.swap_final_check.isChecked(),
            "apply_gcode_patches": self.patch_check.isChecked(),
            "metadata_mode": self.metadata_combo.currentData(),
            "zip_compress_level": int(self.zip_level_combo.currentData() or DEFAULT_ZIP_COMPRESS_LEVEL),
            "individual_batch_mode": self.individual_batch_check.isChecked(),
            "clear_after_build": self.clear_after_build_check.isChecked(),
            "skip_duplicates": self.skip_duplicates_check.isChecked(),
            "locale": self.language_combo.currentData() or "zh_CN",
            "output_directory": self.output_dir_edit.text().strip(),
            "output_filename_rule": self.output_name_edit.text().strip() or DEFAULT_OUTPUT_PATTERN,
        }

    def save_current_settings(self) -> None:
        """将当前 UI 设置保存到本地 JSON 文件（加载阶段跳过）。"""
        if self._loading_settings:
            return
        self._settings["packing_options"] = self.collect_current_settings()
        self._settings["last_swap_gcode"] = self.swap_gcode_combo.currentData() or ""
        self.save_settings()

    def on_output_rule_changed(self) -> None:
        """输出规则变更时：更新输出预览并保存设置。"""
        self.update_output_preview()
        self.save_current_settings()

    def on_individual_batch_toggled(self, state: int) -> None:
        """独立批处理模式切换时：显示说明对话框，更新预览并保存。"""
        self.update_output_preview()
        self.save_current_settings()
        if self._loading_settings:
            return
        if state != 0:
            QMessageBox.information(
                self,
                t("gui.dialog.individual_batch.title"),
                t("gui.dialog.individual_batch.text"),
            )

    def show_output_rule_help(self) -> None:
        """显示输出文件名规则的帮助对话框。"""
        QMessageBox.information(
            self,
            t("gui.dialog.filename_help.title"),
            t("gui.dialog.filename_help.text"),
        )

    # =========================================================================
    # 换盘 G-code 管理
    # =========================================================================

    def load_swap_gcode_to_combo(self) -> None:
        """扫描换盘 G-code 目录，填充下拉列表，恢复上次选择。"""
        current = self.swap_gcode_combo.currentData() or self._settings.get("last_swap_gcode")
        options = self._settings.get("packing_options", {})
        if isinstance(options, dict):
            current = current or options.get("swap_gcode")
        self.swap_gcode_combo.clear()
        files = list_swap_gcode_files(default_swap_gcode_dir())
        for path in files:
            self.swap_gcode_combo.addItem(path.name, str(path))
        if current:
            index = self.swap_gcode_combo.findData(str(current))
            if index < 0:
                index = self.swap_gcode_combo.findText(Path(str(current)).name)
            if index >= 0:
                self.swap_gcode_combo.setCurrentIndex(index)
        if not files:
            self.log.append(t("gui.log.no_swap_files", dir=str(default_swap_gcode_dir())))

    def open_path(self, path: Path) -> None:
        """跨平台打开文件或文件夹（Windows/Mac/Linux）。"""
        target = path if path.exists() else path.parent
        target.parent.mkdir(parents=True, exist_ok=True)
        if sys.platform.startswith("win"):
            os.startfile(str(target))  # type: ignore[attr-defined]
        elif sys.platform == "darwin":
            subprocess.run(["open", str(target)], check=False)
        else:
            subprocess.run(["xdg-open", str(target)], check=False)

    def open_swap_gcode_folder(self) -> None:
        """在系统文件管理器中打开换盘 G-code 目录。"""
        folder = default_swap_gcode_dir()
        folder.mkdir(parents=True, exist_ok=True)
        self.open_path(folder)

    def open_patch_config(self) -> None:
        """打开 G-code 补丁配置文件。"""
        path = default_patch_config_path()
        if not path.exists():
            QMessageBox.information(self, t("gui.dialog.warning.title"), t("gui.dialog.patch_config_not_found", path=str(path)))
            return
        self.open_path(path)

    # =========================================================================
    # 文件输入管理（表格行操作）
    # =========================================================================

    def add_files(self) -> None:
        """通过文件对话框添加 3MF 文件到输入列表。"""
        files, _ = QFileDialog.getOpenFileNames(self, t("gui.dialog.add_files_title"), "", f'{t("gui.dialog.file_filter.3mf")};;{t("gui.dialog.file_filter.all")}')
        self.add_paths([Path(file_name) for file_name in files])

    def create_order_widget(self, row: int) -> QWidget:
        """创建行排序控件（上移/下移按钮 + 行号标签）。"""
        widget = QWidget()
        layout = QHBoxLayout(widget)
        layout.setContentsMargins(2, 0, 2, 0)
        layout.setSpacing(2)

        number_label = QLabel(str(row + 1))
        number_label.setAlignment(Qt.AlignCenter)
        number_label.setMinimumWidth(18)

        up_button = QToolButton()
        up_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowUp))
        up_button.setToolTip(t("gui.file_group.table.move_up"))
        up_button.setAutoRaise(True)
        up_button.setFixedSize(22, 22)
        up_button.setEnabled(row > 0)
        up_button.clicked.connect(lambda _checked=False, current_row=row: self.move_row(current_row, -1))

        down_button = QToolButton()
        down_button.setIcon(self.style().standardIcon(QStyle.StandardPixmap.SP_ArrowDown))
        down_button.setToolTip(t("gui.file_group.table.move_down"))
        down_button.setAutoRaise(True)
        down_button.setFixedSize(28, 28)
        down_button.setEnabled(row < self.table.rowCount() - 1)
        down_button.clicked.connect(lambda _checked=False, current_row=row: self.move_row(current_row, 1))

        layout.addWidget(number_label)
        layout.addWidget(up_button)
        layout.addWidget(down_button)
        return widget

    def update_order_controls(self) -> None:
        """刷新所有行的排序控件和份数控件（行号、上下按钮状态）。"""
        for row in range(self.table.rowCount()):
            self.table.setCellWidget(row, ORDER_COLUMN, self.create_order_widget(row))
            self.table.setCellWidget(row, COPIES_COLUMN, self.create_copies_edit(row, self.get_row_copies(row)))

    def create_copies_edit(self, row: int, copies: int) -> QLineEdit:
        """创建份数编辑框控件，绑定文本变更信号。"""
        edit = QLineEdit(str(max(1, int(copies))))
        edit.setValidator(QIntValidator(1, 9999))
        edit.setFixedWidth(60)
        edit.setAlignment(Qt.AlignCenter)
        edit.textChanged.connect(lambda text, current_row=row: self.on_row_copies_changed(current_row, text))
        return edit

    def row_path(self, row: int) -> Path | None:
        """获取指定行的 3MF 文件路径。"""
        item = self.table.item(row, FILE_COLUMN)
        if item is None:
            return None
        path_value = item.data(PATH_ROLE)
        if path_value:
            return Path(str(path_value))
        return Path(item.text())

    def load_thumbnail_pixmap(self, path: Path) -> QPixmap | None:
        """从 3MF 文件中加载预览缩略图。"""
        with zipfile.ZipFile(path, "r") as archive:
            gcode_members = list_gcode_members(archive)
            active_gcode_member = resolve_output_gcode_member(archive, gcode_members[0]) if gcode_members else None
            member_name = first_preview_image_member(archive.namelist(), active_gcode_member)
            if member_name is None:
                return None
            data = archive.read(member_name)
        pixmap = QPixmap()
        if not pixmap.loadFromData(data):
            return None
        return pixmap

    def update_thumbnail_preview(self) -> None:
        """更新缩略图预览：从当前选中行的 3MF 文件中提取预览图并显示。"""
        if not hasattr(self, "thumbnail_label"):
            return
        row = self.selected_row()
        path = self.row_path(row) if row is not None else None
        self.thumbnail_label.clear()
        if path is None:
            self.thumbnail_label.setText(t("gui.preview.select_hint"))
            self.thumbnail_name_label.setText("")
            return
        self.thumbnail_name_label.setText(path.name)
        try:
            pixmap = self.load_thumbnail_pixmap(path)
        except Exception as exc:
            self.thumbnail_label.setText(t("gui.preview.unavailable"))
            self.thumbnail_label.setToolTip(str(exc))
            return
        if pixmap is None:
            self.thumbnail_label.setText(t("gui.preview.no_image"))
            self.thumbnail_label.setToolTip("")
            return
        scaled = pixmap.scaled(self.thumbnail_label.size(), Qt.KeepAspectRatio, Qt.SmoothTransformation)
        self.thumbnail_label.setPixmap(scaled)
        self.thumbnail_label.setToolTip(str(path))

    def add_paths(self, paths: list[Path]) -> None:
        """批量添加 3MF 文件路径到输入表格。

        支持文件和文件夹（递归添加顶层 .3mf 文件）。
        根据"跳过重复"设置过滤已存在的文件。
        """
        expanded: list[Path] = []
        for path in paths:
            if path.is_dir():
                expanded.extend(sorted(item for item in path.iterdir() if item.is_file() and item.suffix.lower() == ".3mf"))
            elif path.is_file() and path.suffix.lower() == ".3mf":
                expanded.append(path)
        if not expanded:
            return
        existing: set[str] = set()
        for row in range(self.table.rowCount()):
            current_path = self.row_path(row)
            if current_path is not None:
                existing.add(str(current_path))
        added = 0
        skipped = 0
        for path in expanded:
            normalized = str(path.resolve())
            if self.skip_duplicates_check.isChecked() and normalized in existing:
                skipped += 1
                continue
            if self.add_file_row(normalized, int(self.default_copies_edit.text() or 1)):
                existing.add(normalized)
                added += 1
            else:
                skipped += 1
        if added or skipped:
            self.log.append(t("gui.log.added_files", added=added, skipped=skipped))
        self.update_total_summary()
        self.update_output_preview()

    def add_file_row(self, file_name: str, copies: int) -> bool:
        """向表格添加单行：读取 3MF 摘要，创建表格行并填充数据。"""
        path = Path(file_name)
        try:
            summary = read_3mf_summary(path)
        except Exception as exc:
            self.log.append(t("gui.log.skipped_file", path=str(path), reason=str(exc)))
            return False
        row = self.table.rowCount()
        self._updating_table = True
        try:
            self.table.insertRow(row)
            path_item = QTableWidgetItem(path.name)
            path_item.setFlags(path_item.flags() & ~Qt.ItemIsEditable)
            path_item.setData(PATH_ROLE, str(path))
            path_item.setData(SUMMARY_ROLE, self.summary_to_dict(summary))
            path_item.setToolTip(f"{path}\n\n{self.base_summary_tooltip(summary)}")
            path_item.setTextAlignment(Qt.AlignLeft | Qt.AlignVCenter)
            time_item = QTableWidgetItem("")
            filament_item = QTableWidgetItem("")
            for item in (time_item, filament_item):
                item.setFlags(item.flags() & ~Qt.ItemIsEditable)
                item.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter)
            self.table.setCellWidget(row, ORDER_COLUMN, self.create_order_widget(row))
            self.table.setItem(row, FILE_COLUMN, path_item)
            self.table.setCellWidget(row, COPIES_COLUMN, self.create_copies_edit(row, copies))
            self.table.setItem(row, TIME_COLUMN, time_item)
            self.table.setItem(row, FILAMENT_COLUMN, filament_item)
            self.table.setRowHeight(row, 30)
            self.update_row_stats(row)
        finally:
            self._updating_table = False
        self.update_order_controls()
        if self.table.rowCount() == 1:
            self.table.selectRow(0)
        return True

    def summary_to_dict(self, summary: ThreeMfSummary) -> dict[str, Any]:
        """将 ThreeMfSummary 对象转换为可序列化的字典（用于 QTableWidgetItem 数据角色）。"""
        return {
            "source_3mf": str(summary.source_3mf),
            "plate_count": summary.plate_count,
            "prediction_seconds": summary.prediction_seconds,
            "weight_grams": summary.weight_grams,
            "filament_used_m": summary.filament_used_m,
            "filament_used_g": summary.filament_used_g,
        }

    def base_summary_tooltip(self, summary: ThreeMfSummary) -> str:
        """生成 3MF 文件的基本摘要信息（用于工具提示）。"""
        return "\n".join((
            t("gui.file_group.table.tooltip_base_plates", count=summary.plate_count),
            t("gui.file_group.table.tooltip_base_time", time=format_duration(summary.prediction_seconds)),
            t("gui.file_group.table.tooltip_base_filament", filament=format_filament(summary.weight_grams, summary.filament_used_m))
        ))

    def get_row_copies(self, row: int) -> int:
        """获取指定行的份数值。"""
        widget = self.table.cellWidget(row, COPIES_COLUMN)
        if isinstance(widget, QLineEdit):
            return max(1, int(widget.text() or 1))
        item = self.table.item(row, COPIES_COLUMN)
        if item is None:
            return 1
        try:
            return max(1, int(item.text().strip()))
        except Exception:
            return 1

    def set_row_copies(self, row: int, copies: int) -> None:
        """设置指定行的份数值（通过编辑框或文本）。"""
        value = max(1, int(copies))
        widget = self.table.cellWidget(row, COPIES_COLUMN)
        if isinstance(widget, QLineEdit):
            widget.setText(str(value))
            return
        item = self.table.item(row, COPIES_COLUMN)
        if item is not None:
            item.setText(str(value))

    def on_row_copies_changed(self, row: int, text: str) -> None:
        """份数变更时：刷新该行统计、总合计和输出预览。"""
        if self._updating_table:
            return
        self._updating_table = True
        try:
            self.update_row_stats(row)
        finally:
            self._updating_table = False
        self.update_total_summary()
        self.update_output_preview()

    def update_row_stats(self, row: int) -> None:
        """根据当前份数重新计算并更新该行的时间列和耗材列。"""
        path_item = self.table.item(row, FILE_COLUMN)
        if path_item is None:
            return
        data = path_item.data(SUMMARY_ROLE)
        if isinstance(data, ThreeMfSummary):
            summary = data
        elif isinstance(data, dict):
            summary = three_mf_summary_from_mapping(data)
        else:
            return
        copies = self.get_row_copies(row)
        total_prediction = None if summary.prediction_seconds is None else summary.prediction_seconds * copies
        total_weight = None if summary.weight_grams is None else summary.weight_grams * copies
        total_used_m = None if summary.filament_used_m is None else summary.filament_used_m * copies
        self.table.item(row, TIME_COLUMN).setText(format_duration(total_prediction))
        self.table.item(row, FILAMENT_COLUMN).setText(format_filament(total_weight, total_used_m))

    def on_table_item_changed(self, item: QTableWidgetItem) -> None:
        """表格单元格内容变更事件：份数列手动编辑时同步更新统计。"""
        if self._updating_table:
            return
        if item.column() == COPIES_COLUMN:
            self._updating_table = True
            try:
                copies = self.get_row_copies(item.row())
                item.setText(str(copies))
                self.update_row_stats(item.row())
            finally:
                self._updating_table = False
        self.update_total_summary()
        self.update_output_preview()

    def selected_rows(self) -> list[int]:
        """返回当前选中行的索引列表（去重、升序）。"""
        return sorted({index.row() for index in self.table.selectedIndexes()})

    def selected_row(self) -> int | None:
        """返回当前选中的第一行索引，无选中时返回 None。"""
        rows = self.selected_rows()
        return rows[0] if rows else None

    def remove_selected(self) -> None:
        """删除选中的表格行，并智能选择下一行。"""
        rows = sorted(self.selected_rows(), reverse=True)
        next_row = min(rows) if rows else None
        for row in rows:
            self.table.removeRow(row)
        self.update_order_controls()
        if next_row is not None and self.table.rowCount() > 0:
            self.table.selectRow(min(next_row, self.table.rowCount() - 1))
        self.update_total_summary()
        self.update_output_preview()
        self.update_thumbnail_preview()

    def remove_all(self) -> None:
        """清空所有输入行。"""
        self.table.setRowCount(0)
        self.update_order_controls()
        self.update_total_summary()
        self.update_output_preview()
        self.update_thumbnail_preview()

    def move_selected(self, delta: int) -> None:
        """移动选中行（delta 为正则下移，为负则上移）。"""
        row = self.selected_row()
        if row is None:
            return
        self.move_row(row, delta)

    def move_row(self, row: int, delta: int) -> None:
        """将指定行移动 delta 个位置，保持份数和其他数据不变。"""
        new_row = row + delta
        if new_row < 0 or new_row >= self.table.rowCount():
            return
        copies = self.get_row_copies(row)
        row_items: list[QTableWidgetItem | None] = []
        for col in range(self.table.columnCount()):
            item = self.table.item(row, col)
            row_items.append(item.clone() if item is not None else None)
        self._updating_table = True
        try:
            self.table.removeRow(row)
            self.table.insertRow(new_row)
            for col, item in enumerate(row_items):
                if item is not None:
                    self.table.setItem(new_row, col, item)
            self.table.setCellWidget(new_row, COPIES_COLUMN, self.create_copies_edit(new_row, copies))
            self.table.setRowHeight(new_row, 38)
        finally:
            self._updating_table = False
        self.update_order_controls()
        self.table.selectRow(new_row)
        self.update_total_summary()
        self.update_output_preview()
        self.update_thumbnail_preview()

    def apply_default_copies_to_selected(self) -> None:
        """将默认份数应用到所有选中行。"""
        rows = self.selected_rows()
        if not rows:
            return
        self._updating_table = True
        try:
            for row in rows:
                self.set_row_copies(row, int(self.default_copies_edit.text() or 1))
                self.update_row_stats(row)
        finally:
            self._updating_table = False
        self.update_total_summary()
        self.update_output_preview()

    # =========================================================================
    # 输出配置
    # =========================================================================

    def choose_output_dir(self) -> None:
        """通过文件夹选择对话框设置输出目录。"""
        start_dir = self.output_dir_edit.text().strip()
        first_path = self.row_path(0) if self.table.rowCount() > 0 else None
        if not start_dir and first_path is not None:
            start_dir = str(first_path.parent)
        directory = QFileDialog.getExistingDirectory(self, t("gui.dialog.choose_output_dir"), start_dir or "")
        if directory:
            self.output_dir_edit.setText(directory)

    def collect_jobs(self) -> list[PlateJob]:
        """从表格中收集所有 PlateJob（文件路径 + 份数）。"""
        jobs: list[PlateJob] = []
        for row in range(self.table.rowCount()):
            path = self.row_path(row)
            if path is None:
                continue
            jobs.append(PlateJob(path, self.get_row_copies(row)))
        return jobs

    def summary_for_path(self, path: Path) -> ThreeMfSummary:
        """根据文件路径获取 ThreeMfSummary（优先从缓存表格数据中读取）。"""
        normalized = str(path)
        for row in range(self.table.rowCount()):
            row_path = self.row_path(row)
            item = self.table.item(row, FILE_COLUMN)
            if row_path is not None and str(row_path) == normalized and item is not None:
                data = item.data(SUMMARY_ROLE)
                if isinstance(data, ThreeMfSummary):
                    return data
                if isinstance(data, dict):
                    return three_mf_summary_from_mapping(data)
        return read_3mf_summary(path)

    def output_naming_options(self) -> OutputNamingOptions:
        """从 UI 控件收集输出命名选项。"""
        return OutputNamingOptions(
            output_directory=self.output_dir_edit.text().strip(),
            filename_rule=self.output_name_edit.text().strip() or DEFAULT_OUTPUT_PATTERN,
        )

    def current_total_summary(self) -> OutputSummary:
        """计算当前所有作业的合计摘要（板数、时间、耗材）。"""
        return summarize_jobs_for_output(self.collect_jobs(), self.summary_for_path)

    def update_total_summary(self) -> None:
        """刷新底部合计统计标签。"""
        summary = self.current_total_summary()
        self.total_summary_label.setText(
            t("gui.file_group.total_summary",
              plates=summary.plate_count,
              time=format_duration(summary.prediction_seconds),
              filament=format_filament(summary.weight_grams, summary.filament_used_m))
        )

    def update_output_preview(self) -> None:
        """刷新输出文件路径预览标签。

        合并模式下显示单个输出路径；
        独立批处理模式下显示"第一个输出路径 | N 个输出文件"。
        """
        jobs = self.collect_jobs()
        if not jobs:
            self.output_preview_label.setText("-")
            return
        try:
            if self.individual_batch_check.isChecked():
                first_path = resolve_output_path([jobs[0]], self.output_naming_options(), self.summary_for_path)
                self.output_preview_label.setText(
                    t("gui.output.batch_preview", first=str(first_path), count=len(jobs))
                )
            else:
                output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path)
                self.output_preview_label.setText(str(output_path))
        except Exception as exc:
            self.output_preview_label.setText(str(exc))

    # =========================================================================
    # 构建流程
    # =========================================================================

    def build_options_for_output(self, output_path: Path) -> BuildOptions:
        """根据当前 UI 选项构建 BuildOptions 对象。"""
        swap_gcode_path = self.swap_gcode_combo.currentData()
        if not swap_gcode_path:
            raise ValueError(t("gui.dialog.no_swap_gcode"))
        cool_bed_temp = int(self.cool_bed_edit.text() or 45) if self.bed_cooldown_check.isChecked() else None
        eject_wait_seconds = int(self.wait_edit.text() or 30) if self.eject_wait_check.isChecked() else 0
        return BuildOptions(
            swap_gcode=Path(swap_gcode_path),
            output_3mf=output_path,
            cool_bed_temp=cool_bed_temp,
            wait_after_eject_seconds=eject_wait_seconds,
            show_plate_number=self.show_plate_number_check.isChecked(),
            swap_after_final=self.swap_final_check.isChecked(),
            metadata_mode=self.metadata_combo.currentData(),
            apply_gcode_patches=self.patch_check.isChecked(),
            zip_compress_level=int(self.zip_level_combo.currentData() or DEFAULT_ZIP_COMPRESS_LEVEL),
        )

    def log_build_result(self, result: Any) -> None:
        """将单次构建结果信息写入日志。"""
        self.log.append(t("gui.log.output", path=str(result.output_3mf)))
        self.log.append(t("gui.log.plates", count=result.plate_count))
        self.log.append(t("gui.log.time", time=format_duration(result.total_prediction_seconds)))
        self.log.append(t("gui.log.filament", filament=format_filament(result.total_weight_grams)))
        self.log.append(t("gui.log.md5", md5=result.gcode_md5))

    def show_success_toast(self, message: str) -> None:
        """显示构建成功的浮动提示条。"""
        self.success_toast.show_message(message)

    def build_output(self) -> None:
        """主构建入口：根据批处理模式分发到合并构建或独立构建。"""
        jobs = self.collect_jobs()
        if not jobs:
            QMessageBox.warning(self, t("gui.dialog.warning.title"), t("gui.dialog.no_files"))
            return
        try:
            self.save_current_settings()
            if self.individual_batch_check.isChecked():
                self.build_individual_outputs(jobs)
            else:
                self.build_combined_output(jobs)
        except Exception as exc:
            QMessageBox.critical(self, t("gui.dialog.warning.title"), str(exc))
            self.log.append(t("gui.log.build_error", path=type(exc).__name__, error=str(exc)))
            return

    def build_combined_output(self, jobs: list[PlateJob]) -> None:
        """合并构建模式：将所有输入文件打包到单个输出 3MF 中。"""
        output_path = resolve_output_path(jobs, self.output_naming_options(), self.summary_for_path)
        options = self.build_options_for_output(output_path)
        result = build_packed_3mf(jobs, options)
        self.log_build_result(result)
        self.show_success_toast(t("gui.toast.combined_success"))
        if self.clear_after_build_check.isChecked():
            self.remove_all()

    def build_individual_outputs(self, jobs: list[PlateJob]) -> None:
        """独立批处理模式：每行输入构建一个独立的输出 3MF，支持并行处理。"""
        used_paths: set[Path] = set()
        success_count = 0
        errors: list[str] = []
        tasks: list[IndividualBuildTask] = []
        for job in jobs:
            try:
                output_path = make_unique_for_run(
                    resolve_output_path([job], self.output_naming_options(), self.summary_for_path),
                    used_paths,
                )
                options = self.build_options_for_output(output_path)
            except Exception as exc:
                errors.append(f"{job.source_3mf.name}: {exc}")
                self.log.append(t("gui.log.build_error", path=str(job.source_3mf), error=str(exc)))
                continue
            tasks.append(IndividualBuildTask(job, options))

        if tasks:
            worker_count = individual_batch_worker_count(len(tasks))
            self.log.append(t("gui.log.building_individual", count=len(tasks), workers=worker_count))
            batch_result = run_individual_batch_builds(tasks, max_workers=worker_count)
            for result in batch_result.results:
                self.log_build_result(result)
            success_count = len(batch_result.results)
            for failure in batch_result.failures:
                errors.append(f"{failure.job.source_3mf.name}: {failure.error}")
                self.log.append(t("gui.log.build_error", path=str(failure.job.source_3mf), error=str(failure.error)))

        if errors:
            QMessageBox.warning(
                self,
                t("gui.dialog.warning.title"),
                t("gui.dialog.batch_partial_success", success=success_count, failed=len(errors))
                + "\n".join(errors[:8])
                + ("\n..." if len(errors) > 8 else ""),
            )
            return
        self.show_success_toast(t("gui.toast.batch_success", count=success_count))
        if self.clear_after_build_check.isChecked():
            self.remove_all()

    # =========================================================================
    # 窗口级拖放事件
    # =========================================================================

    def dragEnterEvent(self, event: QDragEnterEvent) -> None:
        """主窗口拖入事件：接受包含文件 URL 的拖放。"""
        if event.mimeData().hasUrls():
            event.acceptProposedAction()
            return
        super().dragEnterEvent(event)

    def dropEvent(self, event: QDropEvent) -> None:
        """主窗口释放事件：将拖放的文件路径添加到输入列表。"""
        urls: list[QUrl] = event.mimeData().urls()
        paths = [Path(url.toLocalFile()) for url in urls if url.toLocalFile()]
        self.add_paths(paths)
        event.acceptProposedAction()

    def closeEvent(self, event: Any) -> None:
        """窗口关闭前保存当前设置。"""
        self.save_current_settings()
        super().closeEvent(event)

    def on_language_changed(self) -> None:
        """语言切换时重新加载翻译并刷新 UI 文本。"""
        new_locale = self.language_combo.currentData()
        if new_locale:
            i18n_switch(new_locale)
        self._refresh_ui_texts()
        self.save_current_settings()

    def _refresh_ui_texts(self) -> None:
        """刷新所有可通过 t() 获取的 UI 文本。"""
        # 文件输入区
        self.file_group.setTitle(t("gui.file_group.title"))
        self.table.setHorizontalHeaderLabels([
            t("gui.file_group.table.header.order"),
            t("gui.file_group.table.header.file"),
            t("gui.file_group.table.header.copies"),
            t("gui.file_group.table.header.time"),
            t("gui.file_group.table.header.filament"),
        ])
        self.table.setToolTip(t("gui.file_group.table.tooltip"))

        # 缩略图区
        self.preview_group.setTitle(t("gui.preview.title"))
        if self.thumbnail_label.text() and not self.thumbnail_label.pixmap():
            self.thumbnail_label.setText(t("gui.preview.select_hint"))

        # 按钮
        self.add_button.setText(t("gui.button.add_3mf"))
        self.remove_button.setText(t("gui.button.remove"))
        self.remove_all_button.setText(t("gui.button.remove_all"))
        self.apply_default_copies_button.setText(t("gui.button.apply_copies"))
        self.build_button.setText(t("gui.button.build"))

        # 打包选项区
        self.options_group.setTitle(t("gui.options.group.title"))
        self.refresh_button.setText(t("gui.options.refresh"))
        self.open_folder_button.setText(t("gui.options.open_folder"))
        # 重新计算按钮宽度以适应翻译后的文本长度
        self.refresh_button.setFixedWidth(self.refresh_button.sizeHint().width())
        self.open_folder_button.setFixedWidth(self.open_folder_button.sizeHint().width())
        self.bed_cooldown_check.setText(t("gui.options.bed_cooldown"))
        self.eject_wait_check.setText(t("gui.options.eject_wait"))
        self.show_plate_number_check.setText(t("gui.options.plate_number"))
        self.swap_final_check.setText(t("gui.options.final_swap"))
        self.patch_check.setText(t("gui.options.gcode_patches"))
        self.patch_check.setToolTip(t("gui.options.gcode_patches.tooltip"))
        self.open_patch_button.setText(t("gui.options.open_config"))
        self.individual_batch_check.setText(t("gui.options.batch_mode"))
        self.individual_batch_check.setToolTip(t("gui.options.batch_mode.tooltip"))
        self.clear_after_build_check.setText(t("gui.options.clear_after_build"))
        self.skip_duplicates_check.setText(t("gui.options.skip_duplicates"))
        self.bed_cooldown_suffix_label.setText(t("gui.options.bed_cooldown.suffix"))
        self.wait_suffix_label.setText(t("gui.options.wait_suffix"))

        # 元数据下拉框
        self.metadata_combo.setItemText(0, t("gui.options.metadata.source"))
        self.metadata_combo.setItemText(1, t("gui.options.metadata.sum"))
        # ZIP 压缩下拉框
        for level in range(1, 10):
            self.zip_level_combo.setItemText(level - 1, t("gui.options.zip_compression.level_format", level=level))
        self.zip_level_combo.setToolTip(t("gui.options.zip_compression.tooltip"))

        # 网格标签遍历
        self._refresh_grid_labels()

        # 输出区
        self.output_group.setTitle(t("gui.output.group.title"))
        self.output_dir_edit.setPlaceholderText(t("gui.output.dir.placeholder"))
        self.browse_output_dir_button.setText(t("gui.output.dir.browse"))
        self.browse_output_dir_button.setFixedWidth(self.browse_output_dir_button.sizeHint().width())
        self.output_name_edit.setPlaceholderText(t("gui.output.filename_rule.placeholder"))
        self.output_rule_help_button.setToolTip(t("gui.output.filename_rule.help.tooltip"))
        self.output_dir_label.setText(t("gui.output.dir.label"))
        self.output_filename_rule_label.setText(t("gui.output.filename_rule"))
        self.output_preview_label_title.setText(t("gui.output.preview"))

        # 语言标签
        self.language_label.setText(t("gui.options.language") + ":")

        # 动态文本刷新
        self.update_total_summary()
        self.update_output_preview()
        self.update_thumbnail_preview()

    def _refresh_grid_labels(self) -> None:
        """遍历打包选项网格，刷新所有 QLabel 文本。"""
        grid = self.options_group.layout()
        key_map = {
            (0, 0): "gui.options.swap_gcode.label",
            (1, 0): "gui.options.default_copies",
            (1, 2): "gui.options.bed_cooldown.label",
            (2, 0): "gui.options.eject_wait.label",
            (2, 2): "gui.options.plate_number.label",
            (3, 0): "gui.options.final_swap.label",
            (3, 2): "gui.options.gcode_patches.label",
            (4, 0): "gui.options.metadata.label",
            (4, 2): "gui.options.zip_compression.label",
            (5, 0): "gui.options.batch_mode.label",
            (5, 2): "gui.options.input_handling",
            (6, 0): "gui.options.clear_after_build.label",
        }
        for i in range(grid.count()):
            item = grid.itemAt(i)
            if item.widget() and isinstance(item.widget(), QLabel):
                label = item.widget()
                row, col, _, _ = grid.getItemPosition(i)
                key = key_map.get((row, col))
                if key:
                    label.setText(t(key))


def main() -> int:
    """应用程序入口：初始化 QApplication，创建并显示主窗口，进入事件循环。"""
    freeze_support()
    app = QApplication(sys.argv)
    app.setFont(QFont("Segoe UI", 10))
    icon_path = program_root() / "x.png"
    if icon_path.exists():
        icon = QIcon(str(icon_path))
        app.setWindowIcon(icon)
    window = MainWindow()
    if icon_path.exists():
        window.setWindowIcon(QIcon(str(icon_path)))
    window.show()
    return app.exec()


if __name__ == "__main__":
    raise SystemExit(main())
