"""视频去 Logo GUI 入口。"""

from __future__ import annotations

import concurrent.futures
import os
import queue
import subprocess
import sys
import threading
import time
import tkinter as tk
from tkinter import filedialog, messagebox

import customtkinter as ctk

from processor import process_video


VIDEO_EXTS = [("视频文件", "*.mp4 *.avi *.mov *.mkv *.flv *.wmv"), ("所有文件", "*.*")]
IMAGE_EXTS = [("图片文件", "*.png *.jpg *.jpeg *.bmp *.webp"), ("所有文件", "*.*")]

_PADDLE_VERSION = "3.3.1"
_CUDA_CHOICES = ["cu118", "cu126", "cu129"]

_JOB_PROGRESS_MIN_INTERVAL = 0.2  # 避免高 FPS 视频把 Listbox 重绘打爆

_PARALLEL_CHOICES = ["1 (顺序)", "2", "3"]


def _fmt_time(seconds: float) -> str:
    seconds = max(0, int(seconds))
    h, rem = divmod(seconds, 3600)
    m, s = divmod(rem, 60)
    return f"{h}:{m:02d}:{s:02d}" if h else f"{m}:{s:02d}"


_STATUS_QUEUED = "queued"
_STATUS_RUNNING = "running"
_STATUS_DONE = "done"
_STATUS_CANCELLED = "cancelled"
_STATUS_ERROR = "error"
_STATUS_RECHECK_FAILED = "recheck_failed"


class App(ctk.CTk):
    def __init__(self) -> None:
        super().__init__()
        self.title("jlike delogo — 批量")
        self.geometry("860x820")
        self.minsize(800, 720)
        ctk.set_appearance_mode("System")
        ctk.set_default_color_theme("blue")

        self.video_paths: list[str] = []
        self.output_dir = tk.StringVar()
        self.parallel_choice = tk.StringVar(value=_PARALLEL_CHOICES[0])

        # 处理参数（全局共享）
        self.detect_method = tk.StringVar(value="仅文字 OCR（推荐）")
        self.threshold = tk.DoubleVar(value=0.3)
        self.template_score = tk.DoubleVar(value=0.75)
        self.ocr_pad = tk.DoubleVar(value=1.0)
        self.ocr_score = tk.DoubleVar(value=0.5)
        self.block = tk.IntVar(value=15)
        self.extend_prev = tk.IntVar(value=0)
        self.extend_next = tk.IntVar(value=5)
        self.keep_audio = tk.BooleanVar(value=True)
        self.recheck = tk.BooleanVar(value=False)
        self.ocr_debug_log = tk.BooleanVar(value=False)
        self.image_paths: list[str] = []
        self._method_map = {
            "仅文字 OCR（推荐）": "ocr",
            "自动（文字+图像）": "auto",
            "模板匹配": "template",
            "特征匹配 ORB": "orb",
            "模板匹配 + ORB": "both",
        }

        self._cancel = threading.Event()
        self._executor: concurrent.futures.ThreadPoolExecutor | None = None
        self._futures: list[concurrent.futures.Future] = []
        self._jobs: list[dict] = []
        self._msg_queue: queue.Queue = queue.Queue()
        self._batch_start: float | None = None
        self._pending_futures: int = 0
        self._pending_lock = threading.Lock()

        self._build_ui()
        self.after(100, self._poll_queue)

    # ---------- UI ----------
    def _build_ui(self) -> None:
        pad = {"padx": 10, "pady": 3}

        # ---- 先把底部控件（状态栏、进度条、动作按钮）固定到底部 ----
        # 用 side="bottom"，保证即使中间内容过高，底部永远可见
        self.status = ctk.CTkLabel(self, text="就绪。", anchor="w")
        self.status.pack(side="bottom", fill="x", padx=10, pady=(0, 6))

        self.progress = ctk.CTkProgressBar(self)
        self.progress.pack(side="bottom", fill="x", padx=10, pady=(2, 2))
        self.progress.set(0)

        act = ctk.CTkFrame(self)
        act.pack(side="bottom", fill="x", padx=10, pady=3)
        self.start_btn = ctk.CTkButton(
            act, text="开始批量处理", command=self._start_batch,
            width=160, height=36,
            font=ctk.CTkFont(size=14, weight="bold"),
            fg_color="#2fa572", hover_color="#248a5e",
        )
        self.start_btn.pack(side="left", padx=(10, 6), pady=6)
        self.cancel_btn = ctk.CTkButton(
            act, text="取消", command=self._cancel_batch, state="disabled",
            width=80, height=36,
            fg_color="#d96b6b", hover_color="#b85656",
        )
        self.cancel_btn.pack(side="left", padx=6, pady=6)
        ctk.CTkButton(
            act, text="Paddle 管理", width=100, height=30,
            fg_color="transparent", border_width=1, text_color=("gray20", "gray80"),
            command=self._open_paddle_manager,
        ).pack(side="right", padx=(6, 10), pady=6)
        ctk.CTkButton(
            act, text="GPU 检测", width=90, height=30,
            fg_color="transparent", border_width=1, text_color=("gray20", "gray80"),
            command=self._check_gpu,
        ).pack(side="right", padx=6, pady=6)

        # ---- 中部/上部内容（从上往下） ----
        # 视频队列
        qf = ctk.CTkFrame(self)
        qf.pack(side="top", fill="both", expand=True, **pad)
        ctk.CTkLabel(qf, text="视频队列（可多选添加）：").pack(
            anchor="w", padx=8, pady=(6, 0)
        )
        q_row = ctk.CTkFrame(qf, fg_color="transparent")
        q_row.pack(fill="both", expand=True, padx=8, pady=2)
        self.jobs_listbox = tk.Listbox(
            q_row, selectmode=tk.EXTENDED, height=5, font=("Consolas", 10)
        )
        self.jobs_listbox.pack(side="left", fill="both", expand=True)
        q_sb = tk.Scrollbar(q_row, orient="vertical", command=self.jobs_listbox.yview)
        q_sb.pack(side="right", fill="y")
        self.jobs_listbox.config(yscrollcommand=q_sb.set)

        q_btn = ctk.CTkFrame(qf, fg_color="transparent")
        q_btn.pack(fill="x", padx=8, pady=(0, 6))
        ctk.CTkButton(q_btn, text="添加视频", width=90, command=self._add_videos).pack(
            side="left", padx=3
        )
        ctk.CTkButton(
            q_btn, text="移除选中", width=90, command=self._remove_selected_videos
        ).pack(side="left", padx=3)
        ctk.CTkButton(
            q_btn, text="清空", width=70, command=self._clear_videos
        ).pack(side="left", padx=3)
        ctk.CTkButton(
            q_btn, text="上移", width=55, command=lambda: self._move_selected_videos(-1)
        ).pack(side="left", padx=3)
        ctk.CTkButton(
            q_btn, text="下移", width=55, command=lambda: self._move_selected_videos(1)
        ).pack(side="left", padx=3)

        # 屏蔽图片（全局）
        lf = ctk.CTkFrame(self)
        lf.pack(side="top", fill="x", **pad)
        ctk.CTkLabel(lf, text="屏蔽图片（全局共享，可多选）：").pack(
            anchor="w", padx=8, pady=(6, 0)
        )
        list_row = ctk.CTkFrame(lf, fg_color="transparent")
        list_row.pack(fill="both", expand=True, padx=8, pady=2)
        self.listbox = tk.Listbox(list_row, selectmode=tk.EXTENDED, height=3)
        self.listbox.pack(side="left", fill="both", expand=True)
        sb = tk.Scrollbar(list_row, orient="vertical", command=self.listbox.yview)
        sb.pack(side="right", fill="y")
        self.listbox.config(yscrollcommand=sb.set)
        btn_row = ctk.CTkFrame(lf, fg_color="transparent")
        btn_row.pack(fill="x", padx=8, pady=(0, 6))
        ctk.CTkButton(btn_row, text="添加图片", width=90, command=self._add_images).pack(
            side="left", padx=3
        )
        ctk.CTkButton(
            btn_row, text="移除选中", width=90, command=self._remove_selected
        ).pack(side="left", padx=3)
        ctk.CTkButton(btn_row, text="清空", width=70, command=self._clear_images).pack(
            side="left", padx=3
        )

        # 屏蔽文字（OCR / 自动模式，全局）
        tf = ctk.CTkFrame(self)
        tf.pack(side="top", fill="x", **pad)
        ctk.CTkLabel(
            tf, text="屏蔽文字（每行一个，可选；仅 OCR / 自动模式生效）：",
        ).pack(anchor="w", padx=8, pady=(6, 0))
        text_row = ctk.CTkFrame(tf, fg_color="transparent")
        text_row.pack(fill="x", padx=8, pady=(2, 6))
        self.ocr_text_box = tk.Text(text_row, height=2, wrap="none")
        self.ocr_text_box.pack(side="left", fill="x", expand=True)
        tsb = tk.Scrollbar(text_row, orient="vertical", command=self.ocr_text_box.yview)
        tsb.pack(side="right", fill="y")
        self.ocr_text_box.config(yscrollcommand=tsb.set)

        # 输出目录
        row3 = ctk.CTkFrame(self)
        row3.pack(side="top", fill="x", **pad)
        ctk.CTkLabel(row3, text="输出目录：", width=70).pack(side="left", padx=(8, 0), pady=6)
        ctk.CTkEntry(row3, textvariable=self.output_dir).pack(
            side="left", fill="x", expand=True, padx=6, pady=6
        )
        ctk.CTkButton(row3, text="选择目录", width=90, command=self._pick_output_dir).pack(
            side="left", padx=(0, 8), pady=6
        )

        # 参数（紧凑布局：两列并排，11 行 → 6 行）
        param = ctk.CTkFrame(self)
        param.pack(side="top", fill="x", **pad)

        grid_pad = {"padx": 5, "pady": 3}

        # Row 0: 检测方式（跨 6 列）
        ctk.CTkLabel(param, text="检测方式：", width=70).grid(
            row=0, column=0, sticky="w", **grid_pad
        )
        ctk.CTkOptionMenu(
            param, values=list(self._method_map.keys()),
            variable=self.detect_method, command=self._on_method_change,
        ).grid(row=0, column=1, columnspan=5, sticky="ew", **grid_pad)

        # Row 1: 模板阈值 | 内点比例
        ctk.CTkLabel(param, text="模板阈值：", width=70).grid(
            row=1, column=0, sticky="w", **grid_pad
        )
        self.tm_label = ctk.CTkLabel(param, text="0.75", width=40)
        self.tm_label.grid(row=1, column=2, **grid_pad)
        self.tm_slider = ctk.CTkSlider(
            param, from_=0.5, to=0.95, number_of_steps=45,
            variable=self.template_score,
            command=lambda v: self.tm_label.configure(text=f"{float(v):.2f}"),
        )
        self.tm_slider.grid(row=1, column=1, sticky="ew", **grid_pad)

        ctk.CTkLabel(param, text="内点比例：", width=70).grid(
            row=1, column=3, sticky="w", **grid_pad
        )
        self.th_label = ctk.CTkLabel(param, text="0.30", width=40)
        self.th_label.grid(row=1, column=5, **grid_pad)
        self.th_slider = ctk.CTkSlider(
            param, from_=0.15, to=0.7, number_of_steps=55,
            variable=self.threshold,
            command=lambda v: self.th_label.configure(text=f"{float(v):.2f}"),
        )
        self.th_slider.grid(row=1, column=4, sticky="ew", **grid_pad)

        # Row 2: 文字外扩 | OCR 阈值
        ctk.CTkLabel(param, text="文字外扩：", width=70).grid(
            row=2, column=0, sticky="w", **grid_pad
        )
        self.pad_label = ctk.CTkLabel(param, text="1.00", width=40)
        self.pad_label.grid(row=2, column=2, **grid_pad)
        self.pad_slider = ctk.CTkSlider(
            param, from_=0.0, to=1.5, number_of_steps=150,
            variable=self.ocr_pad,
            command=lambda v: self.pad_label.configure(text=f"{float(v):.2f}"),
        )
        self.pad_slider.grid(row=2, column=1, sticky="ew", **grid_pad)

        ctk.CTkLabel(param, text="OCR 阈值：", width=70).grid(
            row=2, column=3, sticky="w", **grid_pad
        )
        self.ocr_score_label = ctk.CTkLabel(param, text="0.50", width=40)
        self.ocr_score_label.grid(row=2, column=5, **grid_pad)
        self.ocr_score_slider = ctk.CTkSlider(
            param, from_=0.3, to=0.9, number_of_steps=12,
            variable=self.ocr_score,
            command=lambda v: self.ocr_score_label.configure(text=f"{float(v):.2f}"),
        )
        self.ocr_score_slider.grid(row=2, column=4, sticky="ew", **grid_pad)

        # Row 3: 马赛克块 | 前帧
        ctk.CTkLabel(param, text="马赛克块：", width=70).grid(
            row=3, column=0, sticky="w", **grid_pad
        )
        self.blk_label = ctk.CTkLabel(param, text="15", width=40)
        self.blk_label.grid(row=3, column=2, **grid_pad)
        ctk.CTkSlider(
            param, from_=5, to=40, number_of_steps=35,
            variable=self.block,
            command=lambda v: self.blk_label.configure(text=f"{int(float(v))}"),
        ).grid(row=3, column=1, sticky="ew", **grid_pad)

        ctk.CTkLabel(param, text="前帧：", width=70).grid(
            row=3, column=3, sticky="w", **grid_pad
        )
        self.ext_prev_label = ctk.CTkLabel(param, text="0", width=40)
        self.ext_prev_label.grid(row=3, column=5, **grid_pad)
        ctk.CTkSlider(
            param, from_=0, to=10, number_of_steps=10,
            variable=self.extend_prev,
            command=lambda v: self.ext_prev_label.configure(text=f"{int(float(v))}"),
        ).grid(row=3, column=4, sticky="ew", **grid_pad)

        # Row 4: 后帧 | 并发数
        ctk.CTkLabel(param, text="后帧：", width=70).grid(
            row=4, column=0, sticky="w", **grid_pad
        )
        self.ext_next_label = ctk.CTkLabel(param, text="5", width=40)
        self.ext_next_label.grid(row=4, column=2, **grid_pad)
        self.ext_next_slider = ctk.CTkSlider(
            param, from_=0, to=10, number_of_steps=10,
            variable=self.extend_next,
            command=lambda v: self.ext_next_label.configure(text=f"{int(float(v))}"),
        )
        self.ext_next_slider.grid(row=4, column=1, sticky="ew", **grid_pad)

        ctk.CTkLabel(param, text="并发数：", width=70).grid(
            row=4, column=3, sticky="w", **grid_pad
        )
        ctk.CTkOptionMenu(
            param, values=_PARALLEL_CHOICES, variable=self.parallel_choice, width=110,
        ).grid(row=4, column=4, columnspan=2, sticky="w", **grid_pad)

        # Row 5: 保留音频 | 复核（两个复选框并排）
        ctk.CTkCheckBox(
            param, text="保留原始音频（需 ffmpeg）", variable=self.keep_audio
        ).grid(row=5, column=0, columnspan=3, sticky="w", padx=5, pady=(4, 2))

        self.recheck_cb = ctk.CTkCheckBox(
            param, text="复核文字残留（失败追加 _复核失败）",
            variable=self.recheck,
        )
        self.recheck_cb.grid(row=5, column=3, columnspan=3, sticky="w", padx=5, pady=(4, 2))

        # Row 6: OCR 每帧日志
        self.ocr_debug_cb = ctk.CTkCheckBox(
            param, text="记录每帧 OCR 识别日志（输出同目录 .ocr_log.jsonl）",
            variable=self.ocr_debug_log,
        )
        self.ocr_debug_cb.grid(row=6, column=0, columnspan=6, sticky="w", padx=5, pady=(2, 2))

        # Row 7: 并行提示小字
        ctk.CTkLabel(
            param,
            text="提示：并行 2–3 路适合模板/ORB；OCR 模式因内部串行锁加速有限",
            anchor="w", text_color=("gray40", "gray60"),
            font=ctk.CTkFont(size=11),
        ).grid(row=7, column=0, columnspan=6, sticky="w", padx=5, pady=(0, 4))

        param.grid_columnconfigure(1, weight=1)
        param.grid_columnconfigure(4, weight=1)

        self._on_method_change(self.detect_method.get())

    def _on_method_change(self, choice: str) -> None:
        method = self._method_map.get(choice, "auto")
        is_ocr_like = method in ("auto", "ocr")
        ocr_state = "normal" if is_ocr_like else "disabled"
        self.tm_slider.configure(state="normal" if method in ("auto", "template", "both") else "disabled")
        self.th_slider.configure(state="normal" if method in ("orb", "both") else "disabled")
        self.pad_slider.configure(state=ocr_state)
        if hasattr(self, "ocr_text_box"):
            self.ocr_text_box.configure(state=ocr_state)
        if hasattr(self, "recheck_cb"):
            self.recheck_cb.configure(state=ocr_state)
            if not is_ocr_like:
                self.recheck.set(False)
        if hasattr(self, "ocr_debug_cb"):
            self.ocr_debug_cb.configure(state=ocr_state)
            if not is_ocr_like:
                self.ocr_debug_log.set(False)
        if hasattr(self, "ext_next_slider"):
            if method == "ocr":
                self.extend_next.set(0)
                self.ext_next_label.configure(text="0")
                self.ext_next_slider.configure(state="disabled")
            else:
                self.ext_next_slider.configure(state="normal")
        if hasattr(self, "ocr_score_slider"):
            self.ocr_score_slider.configure(state=ocr_state)

    def _collect_ocr_texts(self) -> list[str]:
        raw = self.ocr_text_box.get("1.0", "end")
        return [line.strip() for line in raw.splitlines() if line.strip()]

    # ---------- 视频队列管理 ----------
    def _add_videos(self) -> None:
        if self._is_running():
            return
        paths = filedialog.askopenfilenames(title="选择视频（可多选）", filetypes=VIDEO_EXTS)
        for p in paths:
            if p not in self.video_paths:
                self.video_paths.append(p)
        # 若尚未指定输出目录，默认使用第一个视频的目录
        if self.video_paths and not self.output_dir.get().strip():
            self.output_dir.set(os.path.dirname(self.video_paths[0]))
        self._rebuild_jobs_listbox()

    def _remove_selected_videos(self) -> None:
        if self._is_running():
            return
        sel = sorted(self.jobs_listbox.curselection(), reverse=True)
        for i in sel:
            if 0 <= i < len(self.video_paths):
                del self.video_paths[i]
        self._rebuild_jobs_listbox()

    def _clear_videos(self) -> None:
        if self._is_running():
            return
        self.video_paths.clear()
        self._rebuild_jobs_listbox()

    def _move_selected_videos(self, delta: int) -> None:
        if self._is_running() or delta == 0:
            return
        sel = list(self.jobs_listbox.curselection())
        if not sel:
            return
        n = len(self.video_paths)
        if delta < 0:
            order = sorted(sel)
        else:
            order = sorted(sel, reverse=True)
        for i in order:
            j = i + delta
            if 0 <= j < n:
                self.video_paths[i], self.video_paths[j] = (
                    self.video_paths[j],
                    self.video_paths[i],
                )
        self._rebuild_jobs_listbox()
        # 恢复选中位置
        self.jobs_listbox.selection_clear(0, tk.END)
        for i in sel:
            j = max(0, min(n - 1, i + delta))
            self.jobs_listbox.selection_set(j)

    def _rebuild_jobs_listbox(self) -> None:
        """根据 video_paths（或正在运行的 _jobs）重绘视频列表。"""
        self.jobs_listbox.delete(0, tk.END)
        if self._is_running() and self._jobs:
            for job in self._jobs:
                self.jobs_listbox.insert(tk.END, self._render_job(job))
        else:
            for p in self.video_paths:
                self.jobs_listbox.insert(tk.END, f"⋯ 排队 | {os.path.basename(p)}")

    @staticmethod
    def _render_job(job: dict) -> str:
        name = os.path.basename(job["path"])
        status = job.get("status", _STATUS_QUEUED)
        if status == _STATUS_QUEUED:
            return f"⋯ 排队 | {name}"
        if status == _STATUS_RUNNING:
            cur = job.get("progress", 0)
            total = job.get("total", 0)
            if total > 0:
                pct = int(cur * 100 / total) if total else 0
                eta = job.get("eta_text", "")
                return f"▶ {pct:3d}% | {name}  ({cur}/{total}{eta})"
            return f"▶  …  | {name}"
        if status == _STATUS_DONE:
            return f"✓ 完成 | {name}"
        if status == _STATUS_CANCELLED:
            return f"⊘ 取消 | {name}"
        if status == _STATUS_RECHECK_FAILED:
            hits = job.get("recheck_hits", 0)
            return f"⚠ 复核失败({hits}处) | {name}"
        if status == _STATUS_ERROR:
            err = job.get("error", "")
            return f"✗ 失败 | {name}  — {err}"
        return f"? {status} | {name}"

    def _update_job_row(self, idx: int) -> None:
        if not (0 <= idx < len(self._jobs)):
            return
        job = self._jobs[idx]
        text = self._render_job(job)
        if job.get("_rendered") == text:
            return
        job["_rendered"] = text
        try:
            self.jobs_listbox.delete(idx)
            self.jobs_listbox.insert(idx, text)
        except tk.TclError:
            pass

    # ---------- 屏蔽图片管理 ----------
    def _add_images(self) -> None:
        paths = filedialog.askopenfilenames(title="选择要屏蔽的图片", filetypes=IMAGE_EXTS)
        for p in paths:
            if p not in self.image_paths:
                self.image_paths.append(p)
                self.listbox.insert(tk.END, p)

    def _remove_selected(self) -> None:
        for i in sorted(self.listbox.curselection(), reverse=True):
            self.listbox.delete(i)
            del self.image_paths[i]

    def _clear_images(self) -> None:
        self.listbox.delete(0, tk.END)
        self.image_paths.clear()

    def _pick_output_dir(self) -> None:
        d = filedialog.askdirectory(title="选择输出目录")
        if d:
            self.output_dir.set(d)

    # ---------- 运行 ----------
    def _is_running(self) -> bool:
        return self._executor is not None

    def _parallelism(self) -> int:
        raw = self.parallel_choice.get().strip()
        digits = "".join(c for c in raw if c.isdigit())
        try:
            n = int(digits) if digits else 1
        except ValueError:
            n = 1
        return max(1, min(3, n))

    def _derive_output_path(
        self, video: str, output_dir: str, already: set[str]
    ) -> str:
        base, ext = os.path.splitext(os.path.basename(video))
        candidate = os.path.join(output_dir, f"{base}{ext}")
        video_abs = os.path.abspath(video)

        def conflict(path: str) -> bool:
            if os.path.abspath(path) == video_abs:
                return True
            if os.path.abspath(path) in already:
                return True
            if os.path.exists(path):
                return True
            return False

        if not conflict(candidate):
            return candidate
        candidate = os.path.join(output_dir, f"{base}_clean{ext}")
        if not conflict(candidate):
            return candidate
        i = 1
        while True:
            candidate = os.path.join(output_dir, f"{base}_clean_{i}{ext}")
            if not conflict(candidate):
                return candidate
            i += 1

    def _start_batch(self) -> None:
        if self._is_running():
            return
        if not self.video_paths:
            messagebox.showwarning("提示", "请至少添加一个视频")
            return
        missing = [p for p in self.video_paths if not os.path.isfile(p)]
        if missing:
            messagebox.showwarning(
                "提示", "以下视频文件不存在：\n" + "\n".join(missing[:5])
                + ("\n…" if len(missing) > 5 else "")
            )
            return

        out_dir = self.output_dir.get().strip()
        if not out_dir:
            messagebox.showwarning("提示", "请指定输出目录")
            return
        if not os.path.isdir(out_dir):
            try:
                os.makedirs(out_dir, exist_ok=True)
            except OSError as e:
                messagebox.showwarning("提示", f"无法创建输出目录：{e}")
                return

        method = self._method_map.get(self.detect_method.get(), "auto")
        texts = self._collect_ocr_texts()
        if method in ("ocr", "auto"):
            if not self.image_paths and not texts:
                messagebox.showwarning(
                    "提示", "请至少添加一张屏蔽图片或输入一条关键词"
                )
                return
        else:
            if not self.image_paths:
                messagebox.showwarning("提示", "请至少添加一张屏蔽图片")
                return

        # 生成每个视频的输出路径，避免与源文件 / 已规划输出冲突
        planned_outputs: set[str] = set()
        jobs: list[dict] = []
        for path in self.video_paths:
            out = self._derive_output_path(path, out_dir, planned_outputs)
            planned_outputs.add(os.path.abspath(out))
            jobs.append(
                {
                    "path": path,
                    "out": out,
                    "status": _STATUS_QUEUED,
                    "progress": 0,
                    "total": 0,
                    "error": "",
                }
            )

        params = {
            "images": list(self.image_paths),
            "texts": texts,
            "process_kwargs": {
                "detect_method": method,
                "threshold": float(self.threshold.get()),
                "template_score": float(self.template_score.get()),
                "ocr_pad_ratio": float(self.ocr_pad.get()),
                "ocr_min_score": float(self.ocr_score.get()),
                "mosaic_block": int(self.block.get()),
                "extend_prev": int(self.extend_prev.get()),
                "extend_next": int(self.extend_next.get()),
                "keep_audio": bool(self.keep_audio.get()),
            },
            "recheck": bool(self.recheck.get()) and method in ("auto", "ocr"),
            "ocr_min_score": float(self.ocr_score.get()),
            "ocr_debug_log": bool(self.ocr_debug_log.get()) and method in ("auto", "ocr"),
        }

        self._jobs = jobs
        self._rebuild_jobs_listbox()
        self._cancel.clear()
        self._batch_start = time.monotonic()
        self.start_btn.configure(state="disabled")
        self.cancel_btn.configure(state="normal")
        self.progress.set(0)
        self.status.configure(text=f"批量处理中 — 0/{len(jobs)} 完成")

        workers = min(self._parallelism(), len(jobs))
        self._executor = concurrent.futures.ThreadPoolExecutor(
            max_workers=workers, thread_name_prefix="delogo"
        )
        self._pending_futures = len(jobs)
        self._futures = [
            self._executor.submit(self._run_one, idx, params)
            for idx in range(len(jobs))
        ]
        for f in self._futures:
            f.add_done_callback(self._on_future_done)

    def _on_future_done(self, _future: concurrent.futures.Future) -> None:
        with self._pending_lock:
            self._pending_futures -= 1
            done = self._pending_futures <= 0
        if done:
            self._msg_queue.put(("batch_done",))

    def _cancel_batch(self) -> None:
        if not self._is_running():
            return
        self._cancel.set()
        if self._executor is not None:
            # 取消尚未开始的 future；正在跑的靠 cancel_flag 在帧循环里退出
            try:
                self._executor.shutdown(wait=False, cancel_futures=True)
            except TypeError:
                # Python < 3.9 没有 cancel_futures
                self._executor.shutdown(wait=False)
        self.status.configure(text="正在取消批次…")

    def _run_one(self, idx: int, params: dict) -> None:
        if self._cancel.is_set():
            self._msg_queue.put(("job_cancelled", idx))
            return
        job = self._jobs[idx]
        self._msg_queue.put(("job_start", idx))

        last_push = 0.0

        def progress_cb(cur: int, total: int) -> None:
            nonlocal last_push
            now = time.monotonic()
            is_final = total > 0 and cur >= total
            if not is_final and (now - last_push) < _JOB_PROGRESS_MIN_INTERVAL:
                return
            last_push = now
            self._msg_queue.put(("job_progress", idx, cur, total))

        ocr_log_path: str | None = None
        ocr_frames_dir: str | None = None
        if params.get("ocr_debug_log"):
            base, _ = os.path.splitext(job["out"])
            ocr_log_path = base + ".ocr_log.jsonl"
            ocr_frames_dir = base + ".ocr_frames"

        try:
            result = process_video(
                video_path=job["path"],
                template_paths=params["images"],
                output_path=job["out"],
                ocr_texts=params["texts"],
                ocr_debug_log_path=ocr_log_path,
                ocr_debug_frames_dir=ocr_frames_dir,
                progress_cb=progress_cb,
                cancel_flag=self._cancel.is_set,
                **params["process_kwargs"],
            )
        except Exception as e:  # noqa: BLE001 - surface all failures
            self._msg_queue.put(("job_error", idx, str(e)))
            return

        if result == "cancelled" or self._cancel.is_set():
            self._msg_queue.put(("job_cancelled", idx))
            return

        if params.get("recheck") and params["texts"]:
            try:
                from processor import recheck_video
                hits = recheck_video(
                    job["out"],
                    params["texts"],
                    sample_fps=2.0,
                    min_score=params["ocr_min_score"],
                    cancel_flag=self._cancel.is_set,
                )
            except Exception as e:  # noqa: BLE001
                self._msg_queue.put(("job_error", idx, f"复核阶段异常：{e}"))
                return
            if self._cancel.is_set():
                self._msg_queue.put(("job_cancelled", idx))
                return
            if hits:
                base, ext = os.path.splitext(job["out"])
                failed_path = f"{base}_复核失败{ext}"
                try:
                    os.replace(job["out"], failed_path)
                except OSError as e:
                    self._msg_queue.put(("job_error", idx, f"重命名失败：{e}"))
                    return
                self._msg_queue.put(("job_recheck_failed", idx, failed_path, len(hits)))
                return

        self._msg_queue.put(("job_done", idx, job["out"]))

    def _set_job_status(self, idx: int, status: str, **extra) -> None:
        if not (0 <= idx < len(self._jobs)):
            return
        job = self._jobs[idx]
        job["status"] = status
        for k, v in extra.items():
            job[k] = v
        self._update_job_row(idx)
        self._refresh_summary()

    def _poll_queue(self) -> None:
        try:
            while True:
                msg = self._msg_queue.get_nowait()
                kind = msg[0]

                if kind == "job_start":
                    self._set_job_status(msg[1], _STATUS_RUNNING)
                elif kind == "job_progress":
                    _, idx, cur, total = msg
                    if 0 <= idx < len(self._jobs):
                        job = self._jobs[idx]
                        job["progress"] = cur
                        job["total"] = total
                        elapsed = (
                            time.monotonic() - self._batch_start
                            if self._batch_start else 0.0
                        )
                        if cur > 0 and total > 0:
                            eta = elapsed * (total - cur) / max(1, cur)
                            job["eta_text"] = f" — ETA {_fmt_time(eta)}"
                        else:
                            job["eta_text"] = ""
                        self._update_job_row(idx)
                        self._refresh_summary()
                elif kind == "job_done":
                    self._set_job_status(msg[1], _STATUS_DONE)
                elif kind == "job_cancelled":
                    self._set_job_status(msg[1], _STATUS_CANCELLED)
                elif kind == "job_recheck_failed":
                    _, idx, _failed_path, hits = msg
                    self._set_job_status(idx, _STATUS_RECHECK_FAILED, recheck_hits=hits)
                elif kind == "job_error":
                    self._set_job_status(msg[1], _STATUS_ERROR, error=msg[2])
                elif kind == "batch_done":
                    self._finish_batch()
                elif kind == "pm_log":
                    self._pm_append(msg[1])
                elif kind == "pm_done":
                    self._pm_set_busy(False)
                    self._pm_refresh_status()
                    if msg[1]:
                        messagebox.showinfo(
                            "完成",
                            "操作完成。\n\n"
                            "⚠️ paddle 已经被当前进程加载，需要**重启本软件**后新版本才会生效。",
                        )
                    else:
                        messagebox.showerror("失败", "操作未成功，请查看日志。")
        except queue.Empty:
            pass
        self.after(100, self._poll_queue)

    def _tally_jobs(self) -> dict[str, int]:
        counts = {
            _STATUS_QUEUED: 0,
            _STATUS_RUNNING: 0,
            _STATUS_DONE: 0,
            _STATUS_CANCELLED: 0,
            _STATUS_ERROR: 0,
            _STATUS_RECHECK_FAILED: 0,
        }
        for j in self._jobs:
            s = j["status"]
            if s in counts:
                counts[s] += 1
        return counts

    def _refresh_summary(self) -> None:
        if not self._jobs:
            return
        c = self._tally_jobs()
        total = len(self._jobs)
        done = c[_STATUS_DONE] + c[_STATUS_RECHECK_FAILED]
        failed = c[_STATUS_ERROR]
        cancelled = c[_STATUS_CANCELLED]
        running = c[_STATUS_RUNNING]
        finished = done + failed + cancelled

        self.progress.set(finished / total if total else 0)

        elapsed = (
            time.monotonic() - self._batch_start if self._batch_start else 0.0
        )
        eta_text = ""
        if done > 0 and done < total and self._batch_start:
            avg = elapsed / max(1, done)
            eta = avg * (total - finished)
            eta_text = f" | 预计剩余 {_fmt_time(eta)}"
        parts = [f"批量 — 完成 {done}/{total}"]
        if running:
            parts.append(f"进行中 {running}")
        if failed:
            parts.append(f"失败 {failed}")
        if cancelled:
            parts.append(f"取消 {cancelled}")
        parts.append(f"累计已用 {_fmt_time(elapsed)}{eta_text}")
        self.status.configure(text=" | ".join(parts))

    def _finish_batch(self) -> None:
        executor = self._executor
        self._executor = None
        self._futures = []
        if executor is not None:
            try:
                executor.shutdown(wait=False)
            except Exception:
                pass
        self.start_btn.configure(state="normal")
        self.cancel_btn.configure(state="disabled")

        total = len(self._jobs)
        done = sum(1 for j in self._jobs if j["status"] == _STATUS_DONE)
        recheck_failed = sum(
            1 for j in self._jobs if j["status"] == _STATUS_RECHECK_FAILED
        )
        failed = sum(1 for j in self._jobs if j["status"] == _STATUS_ERROR)
        cancelled = sum(1 for j in self._jobs if j["status"] == _STATUS_CANCELLED)
        elapsed = (
            time.monotonic() - self._batch_start if self._batch_start else 0.0
        )
        self._batch_start = None
        self._refresh_summary()

        summary = [
            f"总数：{total}",
            f"成功：{done}",
        ]
        if recheck_failed:
            summary.append(f"复核失败：{recheck_failed}")
        if failed:
            summary.append(f"失败：{failed}")
        if cancelled:
            summary.append(f"已取消：{cancelled}")
        summary.append(f"耗时：{_fmt_time(elapsed)}")
        self.status.configure(text=" | ".join(summary))

        if failed or recheck_failed:
            messagebox.showwarning("批量完成", "\n".join(summary))
        elif cancelled and not done:
            messagebox.showinfo("已取消", "\n".join(summary))
        else:
            messagebox.showinfo("批量完成", "\n".join(summary))

    # ---------- GPU 检测 ----------
    def _check_gpu(self) -> None:
        lines: list[str] = []
        ok = False
        try:
            import paddle
            lines.append(f"paddle 版本：{paddle.__version__}")
            compiled = bool(paddle.device.is_compiled_with_cuda())
            lines.append(f"编译含 CUDA：{'是' if compiled else '否（当前为 CPU 构建）'}")
            if compiled:
                try:
                    count = paddle.device.cuda.device_count()
                    lines.append(f"CUDA 设备数：{count}")
                    for i in range(count):
                        try:
                            name = paddle.device.cuda.get_device_name(i)
                        except Exception as e:
                            name = f"(读取失败：{e})"
                        lines.append(f"  [{i}] {name}")
                    if count > 0:
                        try:
                            x = paddle.to_tensor([1.0, 2.0], place=paddle.CUDAPlace(0))
                            _ = (x + x).numpy()
                            lines.append("CUDA 张量测试：通过")
                            ok = True
                        except Exception as e:
                            lines.append(f"CUDA 张量测试：失败（{e}）")
                except Exception as e:
                    lines.append(f"枚举设备失败：{e}")
        except ImportError:
            lines.append("未安装 paddlepaddle。")
        except Exception as e:
            lines.append(f"paddle 加载异常：{e}")

        env = os.environ.get("DELOGO_OCR_DEVICE", "(未设置)")
        lines.append(f"DELOGO_OCR_DEVICE = {env}")
        try:
            from processor import _resolve_ocr_device
            lines.append(f"实际将使用：{_resolve_ocr_device()}")
        except Exception as e:
            lines.append(f"解析 device 失败：{e}")

        text = "\n".join(lines)
        if ok:
            messagebox.showinfo("GPU 检测", "GPU 可用\n\n" + text)
        else:
            messagebox.showwarning("GPU 检测", "GPU 不可用（将回退 CPU）\n\n" + text)

    # ---------- Paddle 管理 ----------
    def _open_paddle_manager(self) -> None:
        if getattr(self, "_pm_win", None) is not None:
            try:
                self._pm_win.deiconify()
                self._pm_win.lift()
                self._pm_win.focus_force()
                return
            except tk.TclError:
                self._pm_win = None

        win = ctk.CTkToplevel(self)
        win.title("Paddle 管理")
        win.geometry("760x560")
        win.transient(self)

        info = ctk.CTkFrame(win)
        info.pack(fill="x", padx=12, pady=(12, 4))
        ctk.CTkLabel(
            info, text=f"Python：{sys.executable}", anchor="w", justify="left",
        ).pack(fill="x", padx=6, pady=(4, 0))
        self._pm_status = ctk.CTkLabel(info, text="加载中…", anchor="w", justify="left")
        self._pm_status.pack(fill="x", padx=6, pady=(2, 4))

        cfg = ctk.CTkFrame(win)
        cfg.pack(fill="x", padx=12, pady=4)
        ctk.CTkLabel(cfg, text="CUDA 版本：", width=80).pack(side="left", padx=6)
        self._pm_cuda = tk.StringVar(value="cu118")
        ctk.CTkOptionMenu(
            cfg, values=_CUDA_CHOICES, variable=self._pm_cuda, width=100,
        ).pack(side="left", padx=6)
        ctk.CTkButton(
            cfg, text="自动检测", width=80, command=self._pm_auto_cuda,
        ).pack(side="left", padx=6)
        self._pm_cuda_detected = ctk.CTkLabel(cfg, text="", anchor="w")
        self._pm_cuda_detected.pack(side="left", padx=6)

        btns = ctk.CTkFrame(win)
        btns.pack(fill="x", padx=12, pady=4)
        self._pm_btn_gpu = ctk.CTkButton(
            btns, text="装 GPU 版（先卸 CPU）", command=self._pm_switch_gpu,
        )
        self._pm_btn_gpu.pack(side="left", padx=4, pady=4)
        self._pm_btn_cpu = ctk.CTkButton(
            btns, text="装 CPU 版（先卸 GPU）", command=self._pm_switch_cpu,
        )
        self._pm_btn_cpu.pack(side="left", padx=4, pady=4)
        self._pm_btn_unin = ctk.CTkButton(
            btns, text="仅卸载全部", command=self._pm_uninstall_all,
        )
        self._pm_btn_unin.pack(side="left", padx=4, pady=4)
        self._pm_btn_refresh = ctk.CTkButton(
            btns, text="刷新状态", width=90, command=self._pm_refresh_status,
        )
        self._pm_btn_refresh.pack(side="right", padx=4, pady=4)

        log_frame = ctk.CTkFrame(win)
        log_frame.pack(fill="both", expand=True, padx=12, pady=(4, 12))
        self._pm_log = tk.Text(
            log_frame, wrap="word", bg="#1e1e1e", fg="#dcdcdc",
            insertbackground="#dcdcdc", font=("Consolas", 10),
        )
        self._pm_log.pack(side="left", fill="both", expand=True)
        sb = tk.Scrollbar(log_frame, orient="vertical", command=self._pm_log.yview)
        sb.pack(side="right", fill="y")
        self._pm_log.config(yscrollcommand=sb.set, state="disabled")

        self._pm_win = win
        self._pm_busy = False
        win.protocol("WM_DELETE_WINDOW", self._pm_on_close)
        self._pm_refresh_status()
        self._pm_auto_cuda()

    def _pm_on_close(self) -> None:
        if self._pm_busy:
            if not messagebox.askyesno(
                "确认", "有 pip 操作仍在进行，关闭窗口不会停止它。\n仍要关闭？"
            ):
                return
        try:
            if self._pm_win is not None:
                self._pm_win.destroy()
        finally:
            self._pm_win = None

    def _pm_append(self, text: str) -> None:
        if getattr(self, "_pm_log", None) is None:
            return
        try:
            self._pm_log.configure(state="normal")
            self._pm_log.insert("end", text)
            self._pm_log.see("end")
            self._pm_log.configure(state="disabled")
        except tk.TclError:
            pass

    def _pm_set_busy(self, busy: bool) -> None:
        self._pm_busy = busy
        state = "disabled" if busy else "normal"
        for attr in ("_pm_btn_gpu", "_pm_btn_cpu", "_pm_btn_unin", "_pm_btn_refresh"):
            b = getattr(self, attr, None)
            if b is not None:
                try:
                    b.configure(state=state)
                except tk.TclError:
                    pass

    def _pm_refresh_status(self) -> None:
        cpu = self._pm_pip_show("paddlepaddle")
        gpu = self._pm_pip_show("paddlepaddle-gpu")
        lines = [
            f"paddlepaddle (CPU)：{cpu or '未安装'}",
            f"paddlepaddle-gpu：{gpu or '未安装'}",
        ]
        if cpu and gpu:
            lines.append("⚠ 两个版本同时存在，建议先「仅卸载全部」再安装需要的那一个。")
        if getattr(self, "_pm_status", None) is not None:
            try:
                self._pm_status.configure(text="\n".join(lines))
            except tk.TclError:
                pass

    @staticmethod
    def _pm_detect_cuda() -> tuple[str | None, str]:
        """用 nvidia-smi 读驱动支持的最高 CUDA 版本，映射到 paddle wheel。"""
        import re
        driver: str | None = None
        try:
            flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
            r = subprocess.run(
                ["nvidia-smi"], capture_output=True, text=True, timeout=10,
                creationflags=flags,
            )
            if r.returncode == 0:
                m = re.search(r"CUDA Version:\s*(\d+)\.(\d+)", r.stdout)
                if m:
                    driver = f"{m.group(1)}.{m.group(2)}"
        except (FileNotFoundError, subprocess.TimeoutExpired):
            pass
        except Exception:
            pass
        rec = "cu118"
        if driver:
            try:
                major, minor = (int(x) for x in driver.split(".")[:2])
                ver = (major, minor)
                if ver >= (12, 9):
                    rec = "cu129"
                elif ver >= (12, 6):
                    rec = "cu126"
                else:
                    rec = "cu118"
            except Exception:
                pass
        return driver, rec

    def _pm_auto_cuda(self) -> None:
        driver, rec = self._pm_detect_cuda()
        if rec in _CUDA_CHOICES:
            self._pm_cuda.set(rec)
        if getattr(self, "_pm_cuda_detected", None) is None:
            return
        if driver:
            text = f"驱动支持 CUDA {driver} → 推荐 {rec}"
        else:
            text = "未检测到 NVIDIA 驱动（nvidia-smi 不可用），默认 cu118"
        try:
            self._pm_cuda_detected.configure(text=text)
        except tk.TclError:
            pass

    @staticmethod
    def _pm_pip_show(pkg: str) -> str | None:
        try:
            flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
            r = subprocess.run(
                [sys.executable, "-m", "pip", "show", pkg],
                capture_output=True, text=True, timeout=20,
                creationflags=flags,
            )
            if r.returncode != 0:
                return None
            for line in r.stdout.splitlines():
                if line.lower().startswith("version:"):
                    return line.split(":", 1)[1].strip()
            return "(已安装)"
        except Exception:
            return None

    def _pm_run_commands(self, commands: list[list[str]], done_msg: str) -> None:
        if self._pm_busy:
            return
        self._pm_set_busy(True)
        self._pm_append(f"\n===== {time.strftime('%H:%M:%S')} 开始 =====\n")

        def worker() -> None:
            ok = True
            for cmd in commands:
                self._msg_queue.put(("pm_log", f"\n$ {' '.join(cmd)}\n"))
                try:
                    flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
                    proc = subprocess.Popen(
                        cmd,
                        stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                        text=True, encoding="utf-8", errors="replace",
                        bufsize=1,
                        creationflags=flags,
                    )
                    assert proc.stdout is not None
                    for line in proc.stdout:
                        self._msg_queue.put(("pm_log", line))
                    rc = proc.wait()
                    if rc != 0:
                        self._msg_queue.put(("pm_log", f"\n[退出码 {rc}，终止后续操作]\n"))
                        ok = False
                        break
                except Exception as e:
                    self._msg_queue.put(("pm_log", f"\n[异常：{e}]\n"))
                    ok = False
                    break
            if ok:
                self._msg_queue.put(("pm_log", f"\n{done_msg}\n"))
            self._msg_queue.put(("pm_done", ok))

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

    def _pm_switch_gpu(self) -> None:
        if not messagebox.askyesno(
            "确认", "将执行：\n"
            f"  1) pip uninstall -y paddlepaddle paddlepaddle-gpu\n"
            f"  2) pip install paddlepaddle-gpu=={_PADDLE_VERSION} "
            f"(-i {self._pm_cuda.get()} 索引)\n\n"
            "继续？",
        ):
            return
        cuda = self._pm_cuda.get()
        py = sys.executable
        commands = [
            [py, "-m", "pip", "uninstall", "-y", "paddlepaddle"],
            [py, "-m", "pip", "uninstall", "-y", "paddlepaddle-gpu"],
            [py, "-m", "pip", "install", f"paddlepaddle-gpu=={_PADDLE_VERSION}",
             "-i", f"https://www.paddlepaddle.org.cn/packages/stable/{cuda}/"],
        ]
        self._pm_run_commands(commands, "✓ GPU 版安装完成。")

    def _pm_switch_cpu(self) -> None:
        if not messagebox.askyesno(
            "确认", "将执行：\n"
            f"  1) pip uninstall -y paddlepaddle paddlepaddle-gpu\n"
            f"  2) pip install paddlepaddle=={_PADDLE_VERSION}\n\n"
            "继续？",
        ):
            return
        py = sys.executable
        commands = [
            [py, "-m", "pip", "uninstall", "-y", "paddlepaddle"],
            [py, "-m", "pip", "uninstall", "-y", "paddlepaddle-gpu"],
            [py, "-m", "pip", "install", f"paddlepaddle=={_PADDLE_VERSION}"],
        ]
        self._pm_run_commands(commands, "✓ CPU 版安装完成。")

    def _pm_uninstall_all(self) -> None:
        if not messagebox.askyesno(
            "确认", "将卸载 paddlepaddle 和 paddlepaddle-gpu。继续？",
        ):
            return
        py = sys.executable
        commands = [
            [py, "-m", "pip", "uninstall", "-y", "paddlepaddle"],
            [py, "-m", "pip", "uninstall", "-y", "paddlepaddle-gpu"],
        ]
        self._pm_run_commands(commands, "✓ 卸载完成。")


if __name__ == "__main__":
    App().mainloop()
