"""视频特征点匹配 + 局部马赛克处理核心。"""

from __future__ import annotations

import json
import os
import re
import shutil
import subprocess
import tempfile
import threading
from collections import deque
from typing import Any, Callable, Iterable

import cv2
import numpy as np


def _ffmpeg_exe() -> str | None:
    try:
        import imageio_ffmpeg
        return imageio_ffmpeg.get_ffmpeg_exe()
    except ImportError:
        return shutil.which("ffmpeg")


def _mux_audio(original_video: str, processed_video: str, output_path: str) -> None:
    """把 original_video 的音轨复制到 processed_video，结果写入 output_path。"""
    exe = _ffmpeg_exe()
    if exe is None:
        raise RuntimeError("未找到 ffmpeg，无法保留原始音频")
    cmd = [
        exe, "-y",
        "-i", processed_video,
        "-i", original_video,
        "-c:v", "copy",
        "-c:a", "aac",
        "-map", "0:v:0",
        "-map", "1:a:0?",
        "-shortest",
        output_path,
    ]
    result = subprocess.run(
        cmd, capture_output=True, text=True, encoding="utf-8", errors="replace"
    )
    if result.returncode != 0:
        raise RuntimeError(f"ffmpeg 失败: {result.stderr[-500:]}")


def apply_mosaic(frame: np.ndarray, x: int, y: int, w: int, h: int, block: int) -> None:
    """对 frame 的 [y:y+h, x:x+w] 区域做像素化，原地修改。"""
    H, W = frame.shape[:2]
    x0, y0 = max(0, x), max(0, y)
    x1, y1 = min(W, x + w), min(H, y + h)
    if x1 - x0 <= 0 or y1 - y0 <= 0:
        return
    roi = frame[y0:y1, x0:x1]
    rh, rw = roi.shape[:2]
    small_w = max(1, rw // block)
    small_h = max(1, rh // block)
    small = cv2.resize(roi, (small_w, small_h), interpolation=cv2.INTER_LINEAR)
    frame[y0:y1, x0:x1] = cv2.resize(small, (rw, rh), interpolation=cv2.INTER_NEAREST)


def _iou(a: tuple[int, int, int, int], b: tuple[int, int, int, int]) -> float:
    ax0, ay0, aw, ah = a
    bx0, by0, bw, bh = b
    ax1, ay1 = ax0 + aw, ay0 + ah
    bx1, by1 = bx0 + bw, by0 + bh
    ix0, iy0 = max(ax0, bx0), max(ay0, by0)
    ix1, iy1 = min(ax1, bx1), min(ay1, by1)
    iw, ih = max(0, ix1 - ix0), max(0, iy1 - iy0)
    inter = iw * ih
    if inter == 0:
        return 0.0
    union = aw * ah + bw * bh - inter
    return inter / union if union > 0 else 0.0


def merge_boxes(
    boxes: list[tuple[int, int, int, int]], iou_thresh: float = 0.3
) -> list[tuple[int, int, int, int]]:
    """合并 IoU 超阈值的 bbox，用外接矩形代替。"""
    if len(boxes) < 2:
        return list(boxes)
    remaining = list(boxes)
    merged: list[tuple[int, int, int, int]] = []
    while remaining:
        base = remaining.pop(0)
        changed = True
        while changed:
            changed = False
            kept: list[tuple[int, int, int, int]] = []
            for other in remaining:
                if _iou(base, other) >= iou_thresh:
                    x0 = min(base[0], other[0])
                    y0 = min(base[1], other[1])
                    x1 = max(base[0] + base[2], other[0] + other[2])
                    y1 = max(base[1] + base[3], other[1] + other[3])
                    base = (x0, y0, x1 - x0, y1 - y0)
                    changed = True
                else:
                    kept.append(other)
            remaining = kept
        merged.append(base)
    return merged


# ---------- 跨帧稳定 ----------

_STICKY_HISTORY = 15
_STICKY_MATCH_IOU = 0.3
_STICKY_WARMUP = 1          # 首次命中后即启用 envelope 兜底（短视频里 logo 常只命中 1 帧）
_STICKY_MAX_MISS = 150      # 最多连续 150 帧无命中仍保留 track（5s@30fps）
_STICKY_FALLBACK_MISS = 60  # 连续漏检 ≤ 该值时仍发射历史 envelope 兜底（2s@30fps）


def _envelope_box(
    hist: deque[tuple[int, int, int, int]],
) -> tuple[int, int, int, int]:
    """最近 N 帧检测的外接矩形——比中位 box 更不容易让动画 logo 露出一角。"""
    x0 = min(b[0] for b in hist)
    y0 = min(b[1] for b in hist)
    x1 = max(b[0] + b[2] for b in hist)
    y1 = max(b[1] + b[3] for b in hist)
    return (x0, y0, x1 - x0, y1 - y0)


class _Track:
    __slots__ = ("history", "misses", "envelope")

    def __init__(self, box: tuple[int, int, int, int]) -> None:
        self.history: deque[tuple[int, int, int, int]] = deque(
            [box], maxlen=_STICKY_HISTORY
        )
        self.misses = 0
        self.envelope = box

    def update(self, box: tuple[int, int, int, int]) -> None:
        self.history.append(box)
        self.envelope = _envelope_box(self.history)
        self.misses = 0


class _StickyTracker:
    """用近期检测外接矩形(envelope)兜底动画/缩水/抖动帧。

    每条 track 用最近 N 帧检测的 max 外接矩形替代单帧 box,确保动画中字号最大那一
    刻的覆盖在后续帧也保留;OCR 漏检时同样发射 envelope 兜底。
    """

    def __init__(self) -> None:
        self._tracks: list[_Track] = []

    def stabilize(
        self, new_boxes: list[tuple[int, int, int, int]]
    ) -> list[tuple[int, int, int, int]]:
        tracks = self._tracks
        matched = [False] * len(tracks)
        new_tracks: list[_Track] = []
        out: list[tuple[int, int, int, int]] = []

        for b in new_boxes:
            best_i = -1
            best_iou = 0.0
            for i, t in enumerate(tracks):
                if matched[i]:
                    continue
                iou = _iou(b, t.envelope)
                if iou > best_iou:
                    best_iou = iou
                    best_i = i
            if best_i >= 0 and best_iou >= _STICKY_MATCH_IOU:
                matched[best_i] = True
                t = tracks[best_i]
                t.update(b)
                out.append(t.envelope)
            else:
                nt = _Track(b)
                new_tracks.append(nt)
                out.append(nt.envelope)

        survivors: list[_Track] = []
        for i, t in enumerate(tracks):
            if matched[i]:
                survivors.append(t)
                continue
            t.misses += 1
            if t.misses <= _STICKY_MAX_MISS:
                survivors.append(t)
                if (
                    len(t.history) >= _STICKY_WARMUP
                    and t.misses <= _STICKY_FALLBACK_MISS
                ):
                    out.append(t.envelope)
        survivors.extend(new_tracks)
        self._tracks = survivors

        return out

    def stabilize_with_fragments(
        self,
        primary_boxes: list[tuple[int, int, int, int]],
        fragment_boxes: list[tuple[int, int, int, int]],
    ) -> list[tuple[int, int, int, int]]:
        """主检测走 track 稳定化；片段按 OCR 当前帧实际 box 直接输出（错打优先于漏打）。"""
        out = self.stabilize(primary_boxes)
        if fragment_boxes:
            # 顺带让 IoU 对上的已暖启 track 保活，避免主检测偶发漏识累积 miss 被删
            warmed = [t for t in self._tracks if len(t.history) >= _STICKY_WARMUP]
            for t in warmed:
                for fb in fragment_boxes:
                    if _iou(fb, t.envelope) >= _STICKY_MATCH_IOU:
                        t.misses = 0
                        break
            out.extend(fragment_boxes)
        return out


# ---------- 特征匹配相关 ----------

_MIN_MATCHES = 8          # 至少需要的 good matches 数（也用于 RANSAC 内点下限）
_LOWE_RATIO = 0.75        # Lowe 比值测试阈值
_RANSAC_REPROJ = 5.0      # RANSAC 重投影误差（像素）
_TEMPLATE_FEATURES = 1000
_FRAME_FEATURES = 2500


def _create_orb(n_features: int) -> cv2.ORB:
    return cv2.ORB_create(nfeatures=n_features, scaleFactor=1.2, nlevels=8)


def _load_templates(
    template_paths: Iterable[str],
) -> list[tuple[tuple, np.ndarray, tuple[int, int]]]:
    """返回 [(keypoints, descriptors, (h, w)), ...]。"""
    orb = _create_orb(_TEMPLATE_FEATURES)
    out: list[tuple[tuple, np.ndarray, tuple[int, int]]] = []
    for p in template_paths:
        img = cv2.imdecode(np.fromfile(p, dtype=np.uint8), cv2.IMREAD_COLOR)
        if img is None:
            raise ValueError(f"无法读取图片: {p}")
        gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        kp, des = orb.detectAndCompute(gray, None)
        n = 0 if kp is None else len(kp)
        if des is None or n < _MIN_MATCHES:
            raise ValueError(
                f"模板特征点过少（{n}），该图片纹理太弱无法用于特征匹配: {p}"
            )
        out.append((kp, des, gray.shape[:2]))
    return out


def _quad_is_sane(quad: np.ndarray, frame_shape: tuple) -> bool:
    """拒绝退化的同形性结果（自相交、面积异常、长宽极度失衡）。"""
    fh, fw = frame_shape[:2]
    quad_int = quad.astype(np.int32)
    if not cv2.isContourConvex(quad_int):
        return False
    area = abs(cv2.contourArea(quad_int))
    if area < 100 or area > fh * fw * 0.9:
        return False
    edges = [float(np.linalg.norm(quad[(i + 1) % 4] - quad[i])) for i in range(4)]
    if min(edges) < 5:
        return False
    if max(edges) / max(min(edges), 1.0) > 15.0:
        return False
    return True


def _detect_logo_box(
    frame_kp: tuple,
    frame_des: np.ndarray | None,
    template: tuple[tuple, np.ndarray, tuple[int, int]],
    matcher: cv2.BFMatcher,
    inlier_ratio_thresh: float,
    frame_shape: tuple,
) -> tuple[int, int, int, int] | None:
    """对单个模板做 ORB 匹配 + 单应性，返回外接矩形 (x, y, w, h) 或 None。"""
    if frame_des is None or len(frame_kp) < _MIN_MATCHES:
        return None
    tkp, tdes, (th, tw) = template

    pairs = matcher.knnMatch(tdes, frame_des, k=2)
    good = []
    for pair in pairs:
        if len(pair) != 2:
            continue
        m, n = pair
        if m.distance < _LOWE_RATIO * n.distance:
            good.append(m)
    if len(good) < _MIN_MATCHES:
        return None

    src = np.float32([tkp[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
    dst = np.float32([frame_kp[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
    H, mask = cv2.findHomography(src, dst, cv2.RANSAC, _RANSAC_REPROJ)
    if H is None or mask is None:
        return None
    inliers = int(mask.sum())
    if inliers < _MIN_MATCHES or inliers / len(good) < inlier_ratio_thresh:
        return None

    corners = np.float32([[0, 0], [tw, 0], [tw, th], [0, th]]).reshape(-1, 1, 2)
    warped = cv2.perspectiveTransform(corners, H).reshape(-1, 2)
    if not _quad_is_sane(warped, frame_shape):
        return None

    x, y, w, h = cv2.boundingRect(warped.astype(np.int32))
    if w <= 0 or h <= 0:
        return None
    return (x, y, w, h)


# ---------- 模板匹配相关 ----------

_TEMPLATE_SCALES: tuple[float, ...] = (
    0.25, 0.3, 0.35, 0.4, 0.5, 0.6, 0.75, 0.9, 1.0, 1.25, 1.5,
)
_TEMPLATE_ANGLES: tuple[float, ...] = (
    -25.0, -20.0, -15.0, -10.0, -5.0, 0.0, 5.0, 10.0, 15.0, 20.0, 25.0,
)
_TEMPLATE_NMS_IOU = 0.3
_DEFAULT_TEMPLATE_SCORE = 0.65


def _rotate_template(template: np.ndarray, angle_deg: float) -> np.ndarray:
    """绕中心旋转模板，自动扩大画布避免裁切；填充取模板边缘均值，减少 NCC 干扰。"""
    if abs(angle_deg) < 1e-3:
        return template
    h, w = template.shape[:2]
    cx, cy = w / 2.0, h / 2.0
    M = cv2.getRotationMatrix2D((cx, cy), angle_deg, 1.0)
    cos, sin = abs(M[0, 0]), abs(M[0, 1])
    new_w = int(round(h * sin + w * cos))
    new_h = int(round(h * cos + w * sin))
    M[0, 2] += new_w / 2.0 - cx
    M[1, 2] += new_h / 2.0 - cy
    border = int(round(float(template.mean())))
    return cv2.warpAffine(
        template, M, (new_w, new_h),
        flags=cv2.INTER_LINEAR,
        borderMode=cv2.BORDER_CONSTANT,
        borderValue=border,
    )


def _load_color_templates(template_paths: Iterable[str]) -> list[np.ndarray]:
    """读取模板为灰度图（动态贴纸用灰度更鲁棒）。"""
    out: list[np.ndarray] = []
    for p in template_paths:
        img = cv2.imdecode(np.fromfile(p, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
        if img is None:
            raise ValueError(f"无法读取图片: {p}")
        if img.ndim == 2:
            gray = img
        elif img.shape[2] == 4:
            gray = cv2.cvtColor(img, cv2.COLOR_BGRA2GRAY)
        else:
            gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        out.append(gray)
    return out


def _detect_logo_template(
    frame_gray: np.ndarray,
    template: np.ndarray,
    scales: tuple[float, ...],
    score_thresh: float,
    angles: tuple[float, ...] = _TEMPLATE_ANGLES,
) -> list[tuple[int, int, int, int]]:
    """多尺度 + 多角度灰度模板匹配，返回 NMS 后的 (x, y, w, h) 列表。"""
    th0, tw0 = template.shape[:2]
    fh, fw = frame_gray.shape[:2]
    raw: list[tuple[int, int, int, int, float]] = []
    for s in scales:
        nw, nh = int(round(tw0 * s)), int(round(th0 * s))
        if nw < 12 or nh < 12 or nw > fw or nh > fh:
            continue
        if abs(s - 1.0) < 1e-6:
            base = template
        else:
            interp = cv2.INTER_AREA if s < 1.0 else cv2.INTER_LINEAR
            base = cv2.resize(template, (nw, nh), interpolation=interp)
        for ang in angles:
            tpl = _rotate_template(base, ang)
            rh, rw = tpl.shape[:2]
            if rw > fw or rh > fh:
                continue
            res = cv2.matchTemplate(frame_gray, tpl, cv2.TM_CCOEFF_NORMED)
            ys, xs = np.where(res >= score_thresh)
            if ys.size == 0:
                continue
            scores = res[ys, xs]
            for x, y, sc in zip(xs.tolist(), ys.tolist(), scores.tolist()):
                raw.append((int(x), int(y), rw, rh, float(sc)))
    if not raw:
        return []
    raw.sort(key=lambda b: -b[4])
    kept: list[tuple[int, int, int, int, float]] = []
    for b in raw:
        if all(_iou(b[:4], k[:4]) < _TEMPLATE_NMS_IOU for k in kept):
            kept.append(b)
    return [b[:4] for b in kept]


# ---------- OCR 检测相关 ----------

_OCR_MIN_SCORE = 0.5      # OCR 文本置信度下限（UI 默认值保持一致）
_OCR_MIN_TEXT_LEN = 2     # 有效文本最短字符数
_OCR_DEFAULT_PAD = 1.0    # 文本 box 外扩比例（覆盖周边装饰/胶囊徽章背景）
_OCR_ABS_PAD_PX = 70      # 外扩绝对像素下限，防止小字 pad×ratio 仅几像素
_OCR_BATCH_SIZE = 8       # 每批送入 PaddleOCR 的帧数

_ocr_engine: Any = None
_OCR_LOCK = threading.Lock()  # 保护 PaddleOCR 单例的懒加载与推理调用（非线程安全）


def _resolve_ocr_device() -> str:
    """依据 DELOGO_OCR_DEVICE 环境变量与 paddle CUDA 可用性决定设备。"""
    pref = os.environ.get("DELOGO_OCR_DEVICE", "auto").strip().lower()
    if pref == "cpu":
        return "cpu"
    if pref == "gpu":
        return "gpu"
    try:
        import paddle  # type: ignore
        if paddle.device.is_compiled_with_cuda():
            return "gpu"
    except Exception:
        pass
    return "cpu"


def _get_ocr() -> Any:
    """懒加载 PaddleOCR 单例；首次调用会下载模型 + 初始化，约 10–40s。"""
    global _ocr_engine
    if _ocr_engine is not None:
        return _ocr_engine
    with _OCR_LOCK:
        if _ocr_engine is None:
            os.environ.setdefault("PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK", "True")
            device = _resolve_ocr_device()
            try:
                import paddle  # type: ignore
                paddle.set_device(device)
            except Exception:
                pass
            from paddleocr import PaddleOCR  # type: ignore
            common = dict(
                use_textline_orientation=False,
                lang="ch",
                text_detection_model_name="PP-OCRv5_mobile_det",
                text_recognition_model_name="PP-OCRv5_mobile_rec",
                enable_mkldnn=False,
                text_det_limit_side_len=2048,
                text_det_limit_type="max",
            )
            try:
                _ocr_engine = PaddleOCR(device=device, **common)
            except TypeError:
                try:
                    _ocr_engine = PaddleOCR(**common)
                except TypeError:
                    common.pop("text_det_limit_side_len", None)
                    common.pop("text_det_limit_type", None)
                    _ocr_engine = PaddleOCR(**common)
            print(f"[OCR] device={device}", flush=True)
        return _ocr_engine


def _normalize_text(s: str) -> str:
    return re.sub(r"[\s\W_]+", "", s, flags=re.UNICODE).lower()


def _bigram_fragments(norm: str) -> list[str]:
    """对归一化关键词生成 2 字滑窗片段；长度 <3 不产生。"""
    if len(norm) < 3:
        return []
    return [norm[i:i + 2] for i in range(len(norm) - 1)]


def _parse_ocr_result(result: Any) -> list[tuple[str, float, np.ndarray]]:
    """兼容 PaddleOCR 3.x 的 dict 格式与旧版 list 格式，返回 [(text, score, poly)]。"""
    items: list[tuple[str, float, np.ndarray]] = []
    if not result:
        return items
    r = result[0] if isinstance(result, list) and result else result
    if isinstance(r, dict):
        texts = r.get("rec_texts") or []
        scores = r.get("rec_scores") or []
        polys = r.get("rec_polys") or r.get("dt_polys") or []
        for t, s, p in zip(texts, scores, polys):
            poly = np.asarray(p, dtype=np.float32).reshape(-1, 2)
            items.append((str(t), float(s), poly))
    elif isinstance(r, list):
        for line in r:
            if not line:
                continue
            box, info = line[0], line[1]
            text, score = info[0], info[1]
            items.append((str(text), float(score), np.asarray(box, dtype=np.float32)))
    return items


def _load_template_texts(
    template_paths: Iterable[str], min_score: float = _OCR_MIN_SCORE
) -> list[list[str]]:
    """对每个模板跑 OCR，返回并行列表：有文字的模板 → 规范化文本列表；无文字 → []。"""
    paths = list(template_paths)
    if not paths:
        return []
    ocr = _get_ocr()
    out: list[list[str]] = []
    for p in paths:
        img = cv2.imdecode(np.fromfile(p, dtype=np.uint8), cv2.IMREAD_COLOR)
        if img is None:
            raise ValueError(f"无法读取图片: {p}")
        with _OCR_LOCK:
            items = _parse_ocr_result(ocr.predict(img))
        texts: list[str] = []
        for text, score, _ in items:
            norm = _normalize_text(text)
            if score >= min_score and len(norm) >= _OCR_MIN_TEXT_LEN:
                texts.append(norm)
        out.append(texts)
    return out


def _text_matches_any(detected: str, targets: list[str]) -> bool:
    d = _normalize_text(detected)
    if len(d) < _OCR_MIN_TEXT_LEN:
        return False
    for t in targets:
        if not t:
            continue
        if t in d or d in t:
            return True
    return False


def _poly_to_padded_bbox(
    poly: np.ndarray, pad_ratio: float, frame_shape: tuple[int, ...]
) -> tuple[int, int, int, int]:
    fh, fw = frame_shape[:2]
    xs = poly[:, 0]
    ys = poly[:, 1]
    x0 = float(xs.min())
    x1 = float(xs.max())
    y0 = float(ys.min())
    y1 = float(ys.max())
    w = x1 - x0
    h = y1 - y0
    if w <= 0 or h <= 0:
        return (0, 0, 0, 0)
    px = max(w * pad_ratio, _OCR_ABS_PAD_PX)
    py = max(h * pad_ratio, _OCR_ABS_PAD_PX)
    nx0 = max(0, int(round(x0 - px)))
    ny0 = max(0, int(round(y0 - py)))
    nx1 = min(fw, int(round(x1 + px)))
    ny1 = min(fh, int(round(y1 + py)))
    if nx1 <= nx0 or ny1 <= ny0:
        return (0, 0, 0, 0)
    return (nx0, ny0, nx1 - nx0, ny1 - ny0)


def _find_fragment_in_detected(
    d_norm: str, fragment_norms: Iterable[str]
) -> tuple[str, int] | None:
    """在 d_norm 中找最早出现的 fragment。返回 (fragment, d_start) 或 None。"""
    best_frag: str | None = None
    best_pos = len(d_norm) + 1
    best_len = 0
    for f in fragment_norms:
        if not f or len(f) > len(d_norm):
            continue
        pos = d_norm.find(f)
        if pos < 0:
            continue
        if pos < best_pos or (pos == best_pos and len(f) > best_len):
            best_pos = pos
            best_len = len(f)
            best_frag = f
    if best_frag is None:
        return None
    return best_frag, best_pos


def _extend_chars_for_target(
    fragment: str,
    d_norm: str,
    d_start: int,
    target_norms: Iterable[str],
) -> tuple[int, int]:
    """根据 fragment 在 detected / 目标关键词中的相对位置, 估算左右应补的字数。

    选覆盖字数最多的目标为准（最稳妥，偏向多盖不少盖）。
    """
    d_left = d_start
    d_right = len(d_norm) - d_start - len(fragment)
    best_left = 0
    best_right = 0
    best_total = -1
    for t in target_norms:
        if not t:
            continue
        pos = t.find(fragment)
        if pos < 0:
            continue
        t_left = pos
        t_right = len(t) - pos - len(fragment)
        left = max(0, t_left - d_left)
        right = max(0, t_right - d_right)
        total = left + right
        if total > best_total:
            best_total = total
            best_left = left
            best_right = right
    return best_left, best_right


def _poly_to_extended_bbox(
    poly: np.ndarray,
    pad_ratio: float,
    frame_shape: tuple[int, ...],
    detected_text_len: int,
    left_extend_chars: float,
    right_extend_chars: float,
) -> tuple[int, int, int, int]:
    """在 _poly_to_padded_bbox 基础上再按 char 宽度横向外扩 left/right 个字。"""
    fh, fw = frame_shape[:2]
    xs = poly[:, 0]
    ys = poly[:, 1]
    x0 = float(xs.min())
    x1 = float(xs.max())
    y0 = float(ys.min())
    y1 = float(ys.max())
    w = x1 - x0
    h = y1 - y0
    if w <= 0 or h <= 0:
        return (0, 0, 0, 0)
    char_w = w / max(1, detected_text_len)
    px = max(w * pad_ratio, _OCR_ABS_PAD_PX)
    py = max(h * pad_ratio, _OCR_ABS_PAD_PX)
    nx0 = max(0, int(round(x0 - px - left_extend_chars * char_w)))
    ny0 = max(0, int(round(y0 - py)))
    nx1 = min(fw, int(round(x1 + px + right_extend_chars * char_w)))
    ny1 = min(fh, int(round(y1 + py)))
    if nx1 <= nx0 or ny1 <= ny0:
        return (0, 0, 0, 0)
    return (nx0, ny0, nx1 - nx0, ny1 - ny0)


def _items_to_boxes(
    items: list[tuple[str, float, np.ndarray]],
    target_texts: list[str],
    frame_shape: tuple[int, ...],
    min_score: float,
    pad_ratio: float,
    fragment_texts: list[str] | tuple[str, ...] = (),
) -> tuple[list[tuple[int, int, int, int]], list[tuple[int, int, int, int]]]:
    """返回 (primary_boxes, fragment_boxes)。fragment_texts 为空时第二项恒为空列表。"""
    primary_out: list[tuple[int, int, int, int]] = []
    fragment_out: list[tuple[int, int, int, int]] = []
    for text, score, poly in items:
        if score < min_score:
            continue
        is_primary = _text_matches_any(text, target_texts)
        is_fragment = (
            not is_primary
            and bool(fragment_texts)
            and _text_matches_any(text, fragment_texts)
        )
        if not is_primary and not is_fragment:
            continue
        if is_primary:
            box = _poly_to_padded_bbox(poly, pad_ratio, frame_shape)
        else:
            d_norm = _normalize_text(text)
            found = _find_fragment_in_detected(d_norm, fragment_texts)
            if found is None:
                box = _poly_to_padded_bbox(poly, pad_ratio, frame_shape)
            else:
                frag, d_start = found
                left_chars, right_chars = _extend_chars_for_target(
                    frag, d_norm, d_start, target_texts,
                )
                box = _poly_to_extended_bbox(
                    poly, pad_ratio, frame_shape, len(text),
                    left_chars, right_chars,
                )
        if box[2] <= 0 or box[3] <= 0:
            continue
        if is_primary:
            primary_out.append(box)
        else:
            fragment_out.append(box)
    return primary_out, fragment_out


def _detect_logo_ocr_batch(
    frames: list[np.ndarray],
    target_texts: list[str],
    min_score: float = _OCR_MIN_SCORE,
    pad_ratio: float = _OCR_DEFAULT_PAD,
    fragment_texts: list[str] | tuple[str, ...] = (),
    raw_items_out: list[list[tuple[str, float, np.ndarray]]] | None = None,
) -> list[tuple[list[tuple[int, int, int, int]], list[tuple[int, int, int, int]]]]:
    """一次调用 OCR 处理多帧，返回与输入等长的 (primary, fragment) 元组列表。

    raw_items_out 非空时，会把每帧解析后的原始 OCR items (过滤前) 依次 append 进去。
    """
    if not frames:
        return []
    if not target_texts and not fragment_texts:
        if raw_items_out is not None:
            for _ in frames:
                raw_items_out.append([])
        return [([], []) for _ in frames]
    ocr = _get_ocr()
    with _OCR_LOCK:
        raw = ocr.predict(frames)
    if not isinstance(raw, list):
        raw = [raw]
    results: list[tuple[list[tuple[int, int, int, int]], list[tuple[int, int, int, int]]]] = []
    for frame, per in zip(frames, raw):
        items = _parse_ocr_result(per)
        if raw_items_out is not None:
            raw_items_out.append(items)
        results.append(
            _items_to_boxes(
                items, target_texts, frame.shape, min_score, pad_ratio,
                fragment_texts=fragment_texts,
            )
        )
    return results


def _save_debug_frame(frames_dir: str, frame_idx: int, frame: np.ndarray) -> str | None:
    """把原始帧以 JPEG 写入 frames_dir，返回相对文件名；失败返回 None。"""
    fname = f"f{frame_idx:06d}.jpg"
    fpath = os.path.join(frames_dir, fname)
    try:
        ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 85])
        if not ok:
            return None
        with open(fpath, "wb") as fh:
            fh.write(buf.tobytes())
    except Exception:
        return None
    return fname


def _write_ocr_debug_record(
    log_file: Any,
    frame_idx: int,
    fps: float,
    items: list[tuple[str, float, np.ndarray]],
    target_texts: list[str],
    fragment_texts: list[str] | tuple[str, ...],
    min_score: float,
    primary_boxes: list[tuple[int, int, int, int]],
    fragment_boxes: list[tuple[int, int, int, int]],
    stabilized_boxes: list[tuple[int, int, int, int]],
    frame_image: str | None = None,
) -> None:
    """把单帧 OCR 原始 items + 派生 box 以 JSONL 一行写入 log_file。"""
    texts_info: list[dict] = []
    for t, s, poly in items:
        is_primary = _text_matches_any(t, target_texts)
        is_fragment = (
            not is_primary
            and bool(fragment_texts)
            and _text_matches_any(t, fragment_texts)
        )
        pts = poly.reshape(-1, 2).tolist() if hasattr(poly, "reshape") else list(poly)
        texts_info.append({
            "text": str(t),
            "score": round(float(s), 4),
            "poly": [[round(float(x), 2), round(float(y), 2)] for x, y in pts],
            "matched_primary": bool(is_primary),
            "matched_fragment": bool(is_fragment),
            "below_min_score": float(s) < min_score,
        })
    record: dict = {
        "frame": int(frame_idx),
        "time_s": round(frame_idx / max(1e-6, fps), 3),
        "texts": texts_info,
        "primary_boxes": [list(map(int, b)) for b in primary_boxes],
        "fragment_boxes": [list(map(int, b)) for b in fragment_boxes],
        "stabilized_boxes": [list(map(int, b)) for b in stabilized_boxes],
    }
    if frame_image:
        record["frame_image"] = frame_image
    log_file.write(json.dumps(record, ensure_ascii=False) + "\n")


def _detect_logo_ocr(
    frame: np.ndarray,
    target_texts: list[str],
    min_score: float = _OCR_MIN_SCORE,
    pad_ratio: float = _OCR_DEFAULT_PAD,
    fragment_texts: list[str] | tuple[str, ...] = (),
    raw_items_out: list[list[tuple[str, float, np.ndarray]]] | None = None,
) -> tuple[list[tuple[int, int, int, int]], list[tuple[int, int, int, int]]]:
    """对一帧做 OCR，返回 (primary_boxes, fragment_boxes)。"""
    if not target_texts and not fragment_texts:
        if raw_items_out is not None:
            raw_items_out.append([])
        return [], []
    ocr = _get_ocr()
    with _OCR_LOCK:
        items = _parse_ocr_result(ocr.predict(frame))
    if raw_items_out is not None:
        raw_items_out.append(items)
    return _items_to_boxes(
        items, target_texts, frame.shape, min_score, pad_ratio,
        fragment_texts=fragment_texts,
    )


def recheck_video(
    video_path: str,
    ocr_texts: list[str],
    sample_fps: float = 2.0,
    min_score: float = _OCR_MIN_SCORE,
    progress_cb: Callable[[int, int], None] | None = None,
    cancel_flag: Callable[[], bool] | None = None,
) -> list[tuple[int, float, str]]:
    """对已生成视频按 sample_fps 均匀采样 OCR，查验是否仍能识别出 ocr_texts 中的关键词。

    返回命中列表 [(frame_idx, timestamp_sec, detected_text), ...]。空列表表示通过。
    pad_ratio 不参与（这里只判断是否命中，不产出 box）。
    """
    targets: list[str] = []
    seen: set[str] = set()
    for raw in ocr_texts or []:
        norm = _normalize_text(raw)
        if len(norm) < _OCR_MIN_TEXT_LEN or norm in seen:
            continue
        seen.add(norm)
        targets.append(norm)
    if not targets:
        return []

    cap = cv2.VideoCapture(video_path)
    if not cap.isOpened():
        raise ValueError(f"无法打开视频: {video_path}")
    try:
        fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
        total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
        step = max(1, int(round(fps / max(0.1, sample_fps))))
        sample_total = max(1, (total + step - 1) // step) if total > 0 else 0

        ocr = _get_ocr()
        hits: list[tuple[int, float, str]] = []
        batch_frames: list[np.ndarray] = []
        batch_idx: list[int] = []
        sampled = 0

        def flush() -> None:
            nonlocal sampled
            if not batch_frames:
                return
            with _OCR_LOCK:
                raw = ocr.predict(batch_frames)
            if not isinstance(raw, list):
                raw = [raw]
            for frame_idx, per in zip(batch_idx, raw):
                items = _parse_ocr_result(per)
                for text, score, _poly in items:
                    if score < min_score:
                        continue
                    if _text_matches_any(text, targets):
                        hits.append((frame_idx, frame_idx / fps if fps else 0.0, str(text)))
                        break
            sampled += len(batch_frames)
            batch_frames.clear()
            batch_idx.clear()
            if progress_cb is not None:
                progress_cb(sampled, sample_total)

        idx = 0
        while True:
            if cancel_flag is not None and cancel_flag():
                break
            ok, frame = cap.read()
            if not ok:
                break
            if idx % step == 0:
                batch_frames.append(frame)
                batch_idx.append(idx)
                if len(batch_frames) >= _OCR_BATCH_SIZE:
                    flush()
            idx += 1
        flush()
        return hits
    finally:
        cap.release()


def process_video(
    video_path: str,
    template_paths: list[str],
    output_path: str,
    threshold: float = 0.3,
    mosaic_block: int = 15,
    extend_prev: int = 0,
    extend_next: int = 0,
    keep_audio: bool = True,
    detect_method: str = "ocr",
    template_score: float = _DEFAULT_TEMPLATE_SCORE,
    ocr_pad_ratio: float = _OCR_DEFAULT_PAD,
    ocr_texts: list[str] | None = None,
    ocr_min_score: float = _OCR_MIN_SCORE,
    ocr_debug_log_path: str | None = None,
    ocr_debug_frames_dir: str | None = None,
    progress_cb: Callable[[int, int], None] | None = None,
    cancel_flag: Callable[[], bool] | None = None,
) -> str:
    """处理视频：在每帧定位所有模板并打马赛克，写入 output_path。

    detect_method:
      - "auto": 对每个模板先做 OCR，识别到文字的走 OCR 检测，其余走图像模板匹配（推荐）。
      - "template": 全部走灰度模板匹配。
      - "orb": 全部走 ORB 特征匹配。
      - "both": 同时用 ORB + 模板匹配。
      - "ocr": 全部走 OCR 检测；当未提供 ocr_texts 时，所有模板都必须能识别出文字。
    threshold: ORB 模式下 RANSAC 内点占 good matches 的最小比例（典型 0.2–0.5）。
    template_score: 模板匹配模式下 NCC 分值阈值（典型 0.6–0.9，越大越严）。
    ocr_pad_ratio: OCR 文本框外扩比例（覆盖周边装饰），典型 0.1–0.5。
    ocr_texts: OCR/auto 模式下直接指定要匹配的文字关键词（与从模板图识别的文字合并、去重）。
        提供后 OCR/auto 模式允许 template_paths 为空；仅 OCR/auto 模式生效。
    extend_prev: 检测到 logo 时，同样的马赛克区域回溯应用到前 N 帧。
    extend_next: 检测到 logo 时，同样的马赛克区域继续应用到后 N 帧。
    keep_audio: 处理后用 ffmpeg 把原视频音轨复制到输出。

    返回: "done" | "cancelled"
    """
    if extend_prev < 0 or extend_next < 0:
        raise ValueError("extend_prev/extend_next 不能为负")
    if detect_method not in ("auto", "template", "orb", "both", "ocr"):
        raise ValueError(f"未知 detect_method: {detect_method}")
    if not (0.0 < ocr_min_score < 1.0):
        raise ValueError(f"ocr_min_score 须在 (0, 1) 之间: {ocr_min_score}")

    manual_ocr_targets: list[str] = []
    if ocr_texts:
        seen_manual: set[str] = set()
        for raw in ocr_texts:
            norm = _normalize_text(raw)
            if len(norm) < _OCR_MIN_TEXT_LEN:
                continue
            if norm in seen_manual:
                continue
            seen_manual.add(norm)
            manual_ocr_targets.append(norm)

    if detect_method in ("ocr", "auto"):
        if not template_paths and not manual_ocr_targets:
            raise ValueError("OCR/自动 模式下至少需要一张屏蔽图片或一条关键词")
    else:
        if not template_paths:
            raise ValueError("至少需要一张屏蔽图片")

    if keep_audio:
        if _ffmpeg_exe() is None:
            raise RuntimeError("未找到 ffmpeg，无法保留原始音频；请安装 imageio-ffmpeg 或系统 ffmpeg")
        tmp_fd, video_target = tempfile.mkstemp(suffix=".mp4", prefix="logo_mosaic_")
        os.close(tmp_fd)
    else:
        video_target = output_path

    cap = cv2.VideoCapture(video_path)
    if not cap.isOpened():
        if keep_audio and os.path.exists(video_target):
            os.remove(video_target)
        raise ValueError(f"无法打开视频: {video_path}")

    cancelled = False
    ocr_log_file: Any = None
    try:
        width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
        height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
        fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
        total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))

        frames_dir_active: str | None = None
        if ocr_debug_log_path and detect_method in ("ocr", "auto"):
            try:
                os.makedirs(os.path.dirname(os.path.abspath(ocr_debug_log_path)), exist_ok=True)
            except OSError:
                pass
            ocr_log_file = open(ocr_debug_log_path, "w", encoding="utf-8")
            if ocr_debug_frames_dir:
                try:
                    os.makedirs(ocr_debug_frames_dir, exist_ok=True)
                    frames_dir_active = ocr_debug_frames_dir
                except OSError:
                    frames_dir_active = None
            ocr_log_file.write(json.dumps({
                "_meta": True,
                "video_path": video_path,
                "output_path": output_path,
                "fps": round(float(fps), 4),
                "width": int(width),
                "height": int(height),
                "total_frames": int(total),
                "detect_method": detect_method,
                "ocr_min_score": float(ocr_min_score),
                "ocr_pad_ratio": float(ocr_pad_ratio),
                "frames_dir": frames_dir_active,
            }, ensure_ascii=False) + "\n")

        use_orb = detect_method in ("orb", "both")
        use_tm = detect_method in ("template", "both")
        use_ocr = False

        ocr_targets: list[str] = []
        tm_template_paths: list[str] = list(template_paths)
        orb_template_paths: list[str] = list(template_paths)

        if detect_method == "auto":
            tm_template_paths = []
            if template_paths:
                texts_per_template = _load_template_texts(template_paths)
                for p, texts in zip(template_paths, texts_per_template):
                    if texts:
                        ocr_targets.extend(texts)
                    else:
                        tm_template_paths.append(p)
            ocr_targets.extend(manual_ocr_targets)
            # 去重（相同文字不重复判定）
            seen: set[str] = set()
            ocr_targets = [t for t in ocr_targets if not (t in seen or seen.add(t))]
            use_ocr = bool(ocr_targets)
            use_tm = bool(tm_template_paths)
        elif detect_method == "ocr":
            if template_paths:
                texts_per_template = _load_template_texts(template_paths)
                for p, texts in zip(template_paths, texts_per_template):
                    if not texts:
                        if manual_ocr_targets:
                            print(f"[OCR] 模板 {p} 未识别到有效文字，已跳过", flush=True)
                            continue
                        raise ValueError(
                            f"OCR 模式要求所有模板都能识别出文字，但 {p} 未识别到有效文字"
                        )
                    ocr_targets.extend(texts)
            ocr_targets.extend(manual_ocr_targets)
            seen = set()
            ocr_targets = [t for t in ocr_targets if not (t in seen or seen.add(t))]
            if not ocr_targets:
                raise ValueError("OCR 模式需要至少一条有效关键词或能识别出文字的图片")
            use_ocr = True

        ocr_fragments: list[str] = []
        if use_ocr:
            primary_set = set(ocr_targets)
            seen_frag: set[str] = set()
            for t in ocr_targets:
                for f in _bigram_fragments(t):
                    if f in seen_frag or f in primary_set:
                        continue
                    seen_frag.add(f)
                    ocr_fragments.append(f)

        orb_templates: list[tuple[tuple, np.ndarray, tuple[int, int]]] = []
        color_templates: list[np.ndarray] = []
        frame_orb: cv2.ORB | None = None
        matcher: cv2.BFMatcher | None = None
        if use_orb:
            orb_templates = _load_templates(orb_template_paths)
            frame_orb = _create_orb(_FRAME_FEATURES)
            matcher = cv2.BFMatcher(cv2.NORM_HAMMING)
        if use_tm:
            color_templates = _load_color_templates(tm_template_paths)

        fourcc = cv2.VideoWriter_fourcc(*"mp4v")
        writer = cv2.VideoWriter(video_target, fourcc, fps, (width, height))
        if not writer.isOpened():
            raise ValueError(f"无法创建输出视频: {video_target}")

        buffer: deque[tuple[np.ndarray, list[tuple[int, int, int, int]]]] = deque()
        forward_active: list[list] = []  # [ttl, boxes]
        frame_shape = (height, width, 3)
        tracker = _StickyTracker()

        def flush_oldest() -> None:
            old_frame, old_boxes = buffer.popleft()
            for x, y, w, h in merge_boxes(old_boxes):
                apply_mosaic(old_frame, x, y, w, h, mosaic_block)
            writer.write(old_frame)

        def handle_frame(frame: np.ndarray, new_boxes: list[tuple[int, int, int, int]]) -> None:
            if new_boxes:
                for _, past_boxes in buffer:
                    past_boxes.extend(new_boxes)

            own = list(new_boxes)
            next_active: list[list] = []
            for entry in forward_active:
                ttl, boxes = entry
                own.extend(boxes)
                if ttl > 1:
                    next_active.append([ttl - 1, boxes])
            forward_active[:] = next_active

            if new_boxes and extend_next > 0:
                forward_active.append([extend_next, new_boxes])

            buffer.append((frame, own))
            while len(buffer) > extend_prev:
                flush_oldest()

        try:
            idx = 0
            report_every = max(1, total // 200) if total > 0 else 1
            ocr_only = use_ocr and not use_orb and not use_tm

            if ocr_only:
                done = False
                while not done:
                    if cancel_flag is not None and cancel_flag():
                        cancelled = True
                        return "cancelled"
                    batch: list[np.ndarray] = []
                    while len(batch) < _OCR_BATCH_SIZE:
                        ok, frame = cap.read()
                        if not ok:
                            done = True
                            break
                        batch.append(frame)
                    if not batch:
                        break
                    raw_items_list: list[list[tuple[str, float, np.ndarray]]] | None = (
                        [] if ocr_log_file is not None else None
                    )
                    box_lists = _detect_logo_ocr_batch(
                        batch, ocr_targets,
                        min_score=ocr_min_score,
                        pad_ratio=ocr_pad_ratio,
                        fragment_texts=ocr_fragments,
                        raw_items_out=raw_items_list,
                    )
                    for local_i, (frame, (primary, fragment)) in enumerate(zip(batch, box_lists)):
                        if cancel_flag is not None and cancel_flag():
                            cancelled = True
                            return "cancelled"
                        new_boxes = tracker.stabilize_with_fragments(primary, fragment)
                        if ocr_log_file is not None and raw_items_list is not None:
                            frame_items = raw_items_list[local_i]
                            saved_name: str | None = None
                            if frames_dir_active and (frame_items or new_boxes):
                                saved_name = _save_debug_frame(
                                    frames_dir_active, idx, frame
                                )
                            _write_ocr_debug_record(
                                ocr_log_file, idx, fps,
                                frame_items,
                                ocr_targets, ocr_fragments, ocr_min_score,
                                primary, fragment, new_boxes,
                                frame_image=saved_name,
                            )
                        handle_frame(frame, new_boxes)
                        idx += 1
                        if progress_cb is not None and (
                            idx == total or idx % report_every == 0
                        ):
                            progress_cb(idx, total)
            else:
                while True:
                    if cancel_flag is not None and cancel_flag():
                        cancelled = True
                        return "cancelled"
                    ok, frame = cap.read()
                    if not ok:
                        break

                    new_boxes: list[tuple[int, int, int, int]] = []
                    fragment_boxes: list[tuple[int, int, int, int]] = []
                    gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if (use_orb or use_tm) else None
                    if use_orb:
                        fkp, fdes = frame_orb.detectAndCompute(gray, None)
                        for tpl in orb_templates:
                            box = _detect_logo_box(
                                fkp, fdes, tpl, matcher, threshold, frame_shape
                            )
                            if box is not None:
                                new_boxes.append(box)
                    if use_tm:
                        for tpl_gray in color_templates:
                            new_boxes.extend(
                                _detect_logo_template(
                                    gray, tpl_gray, _TEMPLATE_SCALES, template_score
                                )
                            )
                    per_frame_items: list[list[tuple[str, float, np.ndarray]]] | None = (
                        [] if (use_ocr and ocr_log_file is not None) else None
                    )
                    if use_ocr:
                        primary, fragment = _detect_logo_ocr(
                            frame, ocr_targets,
                            min_score=ocr_min_score,
                            pad_ratio=ocr_pad_ratio,
                            fragment_texts=ocr_fragments,
                            raw_items_out=per_frame_items,
                        )
                        new_boxes.extend(primary)
                        fragment_boxes.extend(fragment)

                    if use_ocr:
                        new_boxes = tracker.stabilize_with_fragments(new_boxes, fragment_boxes)
                    else:
                        new_boxes = tracker.stabilize(new_boxes)
                    if use_ocr and ocr_log_file is not None and per_frame_items is not None:
                        frame_items = per_frame_items[0] if per_frame_items else []
                        saved_name = None
                        if frames_dir_active and (frame_items or new_boxes):
                            saved_name = _save_debug_frame(
                                frames_dir_active, idx, frame
                            )
                        _write_ocr_debug_record(
                            ocr_log_file, idx, fps,
                            frame_items,
                            ocr_targets, ocr_fragments, ocr_min_score,
                            list(primary), list(fragment), new_boxes,
                            frame_image=saved_name,
                        )
                    handle_frame(frame, new_boxes)
                    idx += 1
                    if progress_cb is not None and (idx == total or idx % report_every == 0):
                        progress_cb(idx, total)

            while buffer:
                flush_oldest()
        finally:
            writer.release()
    finally:
        cap.release()
        if ocr_log_file is not None:
            try:
                ocr_log_file.close()
            except Exception:
                pass
        if keep_audio and cancelled and os.path.exists(video_target):
            os.remove(video_target)

    if keep_audio:
        try:
            _mux_audio(video_path, video_target, output_path)
        finally:
            if os.path.exists(video_target):
                os.remove(video_target)

    return "done"
