#!/usr/bin/env python3
"""bdsub.py — 蓝光外挂字幕时间轴拟合 / 校正

用少量人工锚点做线性拟合: 碟片时刻 = a * 字幕时刻 + b
一次同时解掉整体偏移(b)与速率漂移(a), 并用残差判定是否结构性错位。

支持 .srt 与 .ass/.ssa (ASS 只替换 Event 行的起止时间, 样式/特效原样保留)

典型流程:
  1) ./bdsub.py find 中字.srt "你要找的那句台词"     # 查字幕里这句在什么时间
  2) ./bdsub.py fit  中字.srt --anchor 5:12=4:38 --anchor 1:51:00=1:46:20 --dry-run
  3) ./bdsub.py fit  中字.srt --anchor ... --out 中字_fixed.srt

锚点格式: 碟片时刻=字幕时刻  (写 MM:SS 也行, 会自动补零)
含义: "字幕里 S 时刻的那句话, 在碟上实际 D 时刻出现"

跨平台: 纯标准库, macOS / Linux / Windows 通用。Windows 用 py -3 bdsub.py ...
"""

import argparse
import os
import re
import sys
from pathlib import Path

# Windows 控制台编码可能不支持某些字符, 降级为替换而不是崩溃
try:
    sys.stdout.reconfigure(errors="replace")
except Exception:
    pass

TS_RE = re.compile(r"(\d{1,3}):(\d{2}):(\d{2})[,.](\d{1,3})")
ASS_TS_RE = re.compile(r"^\s*(\d+):(\d{2}):(\d{2})[.:](\d{1,3})\s*$")

# SRT 的时间行。用 match 而非 fullmatch, 行尾多余的 X1:/X2:/Y1:/Y2: 坐标直接忽略
_ONE_TS = r"\d{1,3}:\d{2}:\d{2}(?:[,.]\d{1,3})?|\d{1,2}:\d{2}"
SRT_TIME_LINE = re.compile(rf"^\s*({_ONE_TS})\s*-->\s*({_ONE_TS})")

# 常见帧率换算系数
KNOWN_RATIOS = [
    (1.0416667, "PAL 加速 24→25 (+4.167%)"),
    (0.96, "25→24 反向减速 (-4%)"),
    (1.0010010, "23.976→24 (+0.1%)"),
    (0.9990000, "24→23.976 (-0.1%)"),
    (1.0427083, "23.976→25 (+4.271%)"),
    (0.9590409, "25→23.976 (-4.096%)"),
]

DEFAULT_ASS_FMT = ["layer", "start", "end", "style", "name",
                   "marginl", "marginr", "marginv", "effect", "text"]


# ---------------------------------------------------------------- 编解码

# 常用汉字(简+繁), 用于编码判别打分: 正确解码命中的多, 错误解码命中率极低
_COMMON_CN = set(
    "的一是不了人我在有他這这中大來来上國国個个到說说們们為为子和你妳地出道也時时年得就"
    "那要下以生會会自著着去之過过家學学對对可她裡里後后小麼么麼心多天而能好都然沒没日於于"
    "起還还發发成事只作當当想看文無无開开手十用主行方又如前所本見见經经頭头面公同三已老從从"
    "動动兩两長长知民樣样現现分將将外但身些與与高意進进把法此實实回二理美點点月明其種种聲声全"
    "工己話话兒儿者向情部正名定女問问力機机給给等幾几很業业最間间新什打便位因重被走電电四第"
    "門门相次東东政海口使教西再平真聽听世氣气信北少關关並并內内加化由卻却代軍军產产入先山五太"
    "水萬万市眼體体別别處处總总才場场師师書书比住員员九笑性通目華华報报立馬马命張张活難难神數数"
    "件安表原車车白應应路期叫死常提感金何更反合放做系計计或司利受光王果親亲界及今京務务制解"
    "各任至清物台象記记邊边共風风戰战干接它許许八特覺觉望直服毛林題题建南度統统色字請请交愛爱"
    "讓让認认算論论百吃義义科怎元社術术結结六功指思非流每青管夫連连遠远資资隊队跟帶带花快條条"
    "院變变聯联言權权往展該该領领傳传近留紅红治決决周保達达辦办運运武半候七必城父強强步完革深"
    "區区即求品士轉转量空甚眾众技輕轻程告江語语英基派滿满式李息寫写呢識识極极令黃黄德收臉脸錢钱"
    "黨党倒未持取設设始版雙双歷历越史商千片容研像找友孩站廣广改議议形委早房音火際际則则首單单"
    "據据導导影失拿網网香似斯專专石若兵弟誰谁校讀读志飛飞觀观爭争究包組组造落視视濟济喜離离雖虽"
    "坐集編编寶宝談谈府拉黑且隨随格盡尽劍剑講讲布殺杀微怕母調调局根曾準准團团段終终樂乐切級级克"
    "精哪官示冷綠绿嗎吗假族歷興兴舉举義議護护讓陽阳陰阴劉刘陳陈楊杨黃黄趙赵吳吴"
)


def _score(txt):
    """常用字命中率: 正确解码应显著高于错误解码"""
    cjk = [ch for ch in txt if "\u3400" <= ch <= "\u9fff"]
    if not cjk:
        return 0.0
    return sum(1 for ch in cjk if ch in _COMMON_CN) / len(cjk)


def detect_encoding(path):
    """中文字幕编码探测: UTF-8 严格优先, 否则按常用字命中率在 GB18030 / BIG5 间择优"""
    raw = Path(path).read_bytes()
    if raw[:3] == b"\xef\xbb\xbf":
        return "utf-8-sig"
    try:
        raw.decode("utf-8")
        return "utf-8"
    except UnicodeDecodeError:
        pass

    best_score, best_enc = -1.0, "gb18030"
    for enc in ("gb18030", "big5"):
        try:
            txt = raw.decode(enc)
        except UnicodeDecodeError:
            continue
        sc = _score(txt)
        if sc > best_score:
            best_score, best_enc = sc, enc
    return best_enc


# ---------------------------------------------------------------- 时间

def parse_ts(s):
    """SRT / 命令行锚点: HH:MM:SS,mmm / MM:SS / 纯秒数"""
    s = s.strip()
    m = TS_RE.fullmatch(s)
    if m:
        frac = int(m.group(4).ljust(3, "0")[:3]) / 1000
        return int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3)) + frac
    parts = s.split(":")
    if len(parts) == 2:
        parts = ["0"] + parts
    if len(parts) != 3:
        raise ValueError(f"时间格式无法识别: {s!r} (需要 HH:MM:SS 或 MM:SS)")
    return float(parts[0]) * 3600 + float(parts[1]) * 60 + float(parts[2])


def fmt_ts(t):
    t = max(t, 0.0)
    ms = int(round(t * 1000))
    h, ms = divmod(ms, 3_600_000)
    m, ms = divmod(ms, 60_000)
    s, ms = divmod(ms, 1000)
    return f"{h:02d}:{m:02d}:{s:02d},{ms:03d}"


def parse_ass_time(s):
    """ASS: H:MM:SS.cc (百分秒)"""
    m = ASS_TS_RE.match(s)
    if not m:
        raise ValueError(f"ASS 时间无法解析: {s!r}")
    h, mi, se, fr = m.groups()
    frac = int(fr) / (100 if len(fr) == 2 else 1000)
    return int(h) * 3600 + int(mi) * 60 + int(se) + frac


def fmt_ass_time(t):
    t = max(t, 0.0)
    cs = int(round(t * 100))
    h, cs = divmod(cs, 360_000)
    m, cs = divmod(cs, 6_000)
    s, cs = divmod(cs, 100)
    return f"{h}:{m:02d}:{s:02d}.{cs:02d}"


def norm(s):
    """去标点/空白并小写, 用于模糊匹配台词"""
    return re.sub(r"[\W_]+", "", s.lower())


def clean_ass_text(t):
    """去掉 {\\pos(...)} 之类的覆盖标签, 还原 \\N 换行"""
    t = re.sub(r"\{[^}]*\}", "", t)
    return t.replace("\\N", " ").replace("\\n", " ").replace("\\h", " ").strip()


# ---------------------------------------------------------------- 解析

def _looks_ass(path, raw):
    if Path(path).suffix.lower() in (".ass", ".ssa"):
        return True
    return "[Events]" in raw and re.search(r"(?mi)^\s*dialogue\s*:", raw) is not None


def _parse_srt(raw):
    """以"时间行"为锚点切分, 而不是以空行切分。

    用空行切分有两个已知的静默丢条目问题:
      - 正文里含空行(常见于人工编辑过的文件) → 后半条被当成无时间行丢弃
      - 老式 SRT 时间行带 X1:/Y2: 坐标 → 整条丢弃
    """
    raw = raw.replace("\r\n", "\n").replace("\r", "\n")
    lines = raw.split("\n")

    hits = []
    for i, ln in enumerate(lines):
        m = SRT_TIME_LINE.match(ln)
        if m:
            hits.append((i, m))

    cues = []
    for k, (li, m) in enumerate(hits):
        nxt = hits[k + 1][0] if k + 1 < len(hits) else len(lines)
        body = lines[li + 1 : nxt]
        while body and not body[-1].strip():
            body.pop()
        # 紧挨下一条时间行的那一行若是纯数字, 它是下一条的编号, 不属于本条正文
        if k + 1 < len(hits) and body and body[-1].strip().isdigit():
            body.pop()
            while body and not body[-1].strip():
                body.pop()

        idx = str(len(cues) + 1)
        if li > 0 and lines[li - 1].strip().isdigit():
            idx = lines[li - 1].strip()
        try:
            start, end = parse_ts(m.group(1)), parse_ts(m.group(2))
        except ValueError:
            continue
        cues.append({"idx": idx, "start": start, "end": end,
                     "text": "\n".join(body).strip()})
    return cues


def _parse_ass(raw):
    """只提取 Event 行; 记住行号, 以便写回时原地替换"""
    nl = "\r\n" if "\r\n" in raw else "\n"
    lines = raw.split(nl)
    section, fmt = None, None
    cues = []
    for i, ln in enumerate(lines):
        s = ln.strip()
        if s.startswith("[") and s.endswith("]"):
            section, fmt = s.lower(), None
            continue
        if section != "[events]":
            continue
        low = s.lower()
        if low.startswith("format:"):
            fmt = [x.strip().lower() for x in s.split(":", 1)[1].split(",")]
            continue
        if not low.startswith("dialogue:"):
            continue  # Comment: 行跳过

        fields = fmt or DEFAULT_ASS_FMT
        try:
            si, ei = fields.index("start"), fields.index("end")
            ti = fields.index("text")
        except ValueError:
            si, ei, ti = 1, 2, len(fields) - 1

        body = ln.split(":", 1)[1].lstrip()
        prefix = ln[: len(ln) - len(body)]
        parts = body.split(",", max(len(fields) - 1, max(si, ei, ti)))
        if len(parts) <= max(si, ei, ti):
            continue
        try:
            start, end = parse_ass_time(parts[si]), parse_ass_time(parts[ei])
        except ValueError:
            continue
        cues.append({"idx": str(len(cues) + 1), "start": start, "end": end,
                     "text": clean_ass_text(parts[ti]),
                     "line": i, "prefix": prefix, "parts": parts,
                     "si": si, "ei": ei})
    return cues, {"kind": "ass", "nl": nl, "lines": lines}


def _read_text(path, enc):
    """newline='' 保留原始换行符; 默认的 universal newlines 会把 CRLF 变成 LF"""
    with open(path, "r", encoding=enc, errors="replace", newline="") as f:
        return f.read()


def load(path):
    """返回 (cues, meta)。cues 元素: idx/start/end/text"""
    enc = detect_encoding(path)
    raw = _read_text(path, enc)
    if _looks_ass(path, raw):
        cues, meta = _parse_ass(raw)
        meta["enc"] = enc
        return cues, meta
    return _parse_srt(raw), {"kind": "srt", "enc": enc}


# ---------------------------------------------------------------- 写回

def check_enc(enc):
    """校验用户给的编码名, 避免 LookupError 直接抛 traceback"""
    if not enc:
        return None
    try:
        "x".encode(enc)
    except LookupError:
        sys.exit(f"未知编码: {enc!r}。常用: utf-8 / gb18030 / big5 / utf-8-sig")
    return enc


def _same_file(a, b):
    """判断两个路径是否指向同一文件。用于阻止"输出覆盖源文件"这类误操作"""
    pa, pb = Path(a), Path(b)
    try:
        if pa.exists() and pb.exists():
            return os.path.samefile(pa, pb)
        return pa.resolve() == pb.resolve()
    except OSError:
        return str(pa.absolute()) == str(pb.absolute())


def _atomic_write(path, text, enc):
    """先写临时文件再原子替换。

    直接 open(path,'w') 会先把原文件截断为 0 字节, 一旦中途异常(磁盘满/编码错误),
    用户就只剩一个空文件。这条路径必须走原子替换。
    """
    p = Path(path)
    tmp = p.with_name(p.name + ".tmp")
    try:
        with open(tmp, "w", encoding=enc, errors="replace", newline="") as f:
            f.write(text)
        os.replace(str(tmp), str(p))
    except Exception:
        try:
            tmp.unlink()
        except OSError:
            pass
        raise


def _write_srt(cues, path, enc, times):
    out = []
    for i, (c, (ns, ne)) in enumerate(zip(cues, times), 1):
        out.append(f"{i}\n{fmt_ts(ns)} --> {fmt_ts(ne)}\n" + c["text"])
    _atomic_write(path, "\n\n".join(out) + "\n", enc)


def _write_ass(cues, meta, path, enc, times):
    """只替换 Event 行里的 Start / End 两个字段, 其余字节原样保留"""
    lines = list(meta["lines"])
    for c, (ns, ne) in zip(cues, times):
        parts = list(c["parts"])
        parts[c["si"]] = fmt_ass_time(ns)
        parts[c["ei"]] = fmt_ass_time(ne)
        lines[c["line"]] = c["prefix"] + ",".join(parts)
    _atomic_write(path, meta["nl"].join(lines), enc)


def save(path, cues, meta, enc, times):
    if meta["kind"] == "ass":
        _write_ass(cues, meta, path, enc, times)
    else:
        _write_srt(cues, path, enc, times)


# ---------------------------------------------------------------- 拟合

def fit_linear(xs, ys):
    n = len(xs)
    if n == 1:
        return 1.0, ys[0] - xs[0]
    mx, my = sum(xs) / n, sum(ys) / n
    den = sum((x - mx) ** 2 for x in xs)
    if den == 0:
        return 1.0, my - mx
    a = sum((x - mx) * (y - my) for x, y in zip(xs, ys)) / den
    return a, my - a * mx


def classify(a, b, rms, rms_limit=1.0):
    """残差超过阈值时必须是 C 类。之前只加一句警告、判定仍报 A/B 类, 会自相矛盾"""
    if rms > rms_limit:
        return "C", f"C 类 · 结构性错位 (残差 RMS {rms:.2f}s, 超出线性拟合能力)", [
            "锚点互相矛盾, 极可能是不同剪辑版本; 线性变换救不了, 建议换对应版本的字幕",
            "若坚持要写文件, 加 --force; 但结果不会可靠",
        ]
    if abs(a - 1.0) < 5e-4:
        kind = "A" if abs(b) > 0.2 else "-"
        label = f"A 类 · 整体平移 {b:+.2f}s" if kind == "A" else "已对齐"
    else:
        kind = "B"
        label = f"B 类 · 速率漂移 a={a:.6f} ({a - 1:+.2%})"
        for r, name in KNOWN_RATIOS:
            if abs(a - r) < 5e-4:
                label += f"  ≈ {name}"
                break
        else:
            label += "  (非标准系数, 按实测值用)"
    return kind, label, []


# ---------------------------------------------------------------- 子命令

def cmd_fit(args):
    check_enc(args.enc)  # 参数错误应在任何计算之前就报出来
    anchors = []
    for spec in args.anchor:
        if "=" not in spec:
            sys.exit(f"锚点格式错误: {spec!r}  (应为 碟片时刻=字幕时刻, 例如 5:12=4:38)")
        d, s = spec.split("=", 1)
        try:
            anchors.append((parse_ts(s), parse_ts(d)))
        except ValueError as e:
            sys.exit(f"锚点 {spec!r} 解析失败: {e}")
    if not anchors:
        sys.exit("至少需要一个 --anchor")

    xs = [x for x, _ in anchors]
    ys = [y for _, y in anchors]
    if args.ratio:
        a = args.ratio
        b = sum(y - a * x for x, y in zip(xs, ys)) / len(xs)
        print(f"(按 --ratio 固定 a={a:.7f}, 只用锚点解偏移 b)")
    else:
        a, b = fit_linear(xs, ys)
    res = [y - (a * x + b) for x, y in zip(xs, ys)]
    rms = (sum(r * r for r in res) / len(res)) ** 0.5

    kind, label, notes = classify(a, b, rms)

    print(f"锚点数        {len(anchors)}")
    print(f"缩放 a        {a:.6f}  ({a - 1:+.4%})")
    print(f"偏移 b        {b:+.3f} s")
    print("残差          " + "  ".join(f"{r:+.2f}s" for r in res) + f"   (RMS {rms:.3f}s)")
    print(f"判定          {label}")
    for msg in notes:
        print(f"警告          {msg}")

    if not (0.90 <= a <= 1.15):
        print(f"警告          系数 a={a:.4f} 明显离谱。最常见的错因是把锚点写反了 ——")
        print("              正确写法是 碟片时刻=字幕时刻，例如 --anchor 5:12=4:38")
    if len(anchors) == 1 and not args.ratio:
        print("提示          仅 1 个锚点, 只能解偏移、解不出漂移; 建议再补片尾一个锚点")
    if len(anchors) >= 2:
        span = max(xs) - min(xs)
        if span < 600:
            print(f"提示          锚点跨度只有 {span/60:.1f} 分钟, 算出的 a 不可靠; 请拉到 20 分钟以上")
    if args.dry_run:
        return

    if kind == "C" and not args.force:
        print("\n已拒绝写文件: 残差过大, 套用这个变换只会把整条字幕改坏。")
        print("  换一份对应版本的字幕, 或确认无误后加 --force 强行套用。")
        sys.exit(1)

    cues, meta = load(args.srt)
    if not cues:
        sys.exit(f"未能从 {args.srt} 解析出任何字幕条目")

    times = []
    for c in cues:
        ns = a * c["start"] + b
        ne = (c["end"] if args.keep_duration else a * c["end"]) + b
        if ne <= ns:
            ne = ns + 0.5
        times.append((max(ns, 0.0), max(ne, 0.0)))

    enc = check_enc(args.enc) or meta["enc"]
    out = args.out or str(Path(args.srt).with_name(Path(args.srt).stem + "_fixed" + Path(args.srt).suffix))
    out_ext = Path(out).suffix.lower()
    if meta["kind"] == "ass" and out_ext in (".srt",):
        sys.exit("输出扩展名是 .srt，但源文件是 ASS —— 要转格式请用: "
                 f"bdsub.py convert {args.srt} {out}")
    if meta["kind"] == "srt" and out_ext in (".ass", ".ssa"):
        sys.exit("输出扩展名是 ASS，但源文件是 SRT —— 本工具不做 SRT→ASS 转换")
    if _same_file(out, args.srt):
        sys.exit(f"输出路径与源文件相同 ({out})，拒绝覆盖。请换一个 --out。")
    save(out, cues, meta, enc, times)

    print(f"\n已写出 {len(cues)} 条 → {out}   (格式 {meta['kind'].upper()}, 编码 {enc})")
    print(f"首条 {fmt_ts(cues[0]['start'])} → {fmt_ts(times[0][0])}")
    print(f"末条 {fmt_ts(cues[-1]['start'])} → {fmt_ts(times[-1][0])}")
    if a * cues[0]["start"] + b < 0:
        print("警告          校正后首条为负、已裁到 0; 说明偏移方向可能反了, 请检查锚点")


def cmd_find(args):
    cues, meta = load(args.srt)
    if not cues:
        sys.exit(f"未能解析出字幕条目: {args.srt}")
    key = norm(" ".join(args.keyword))
    if not key:
        sys.exit("关键词为空")
    hits = [c for c in cues if key in norm(c["text"])]
    if not hits:
        print(f"未匹配到 {args.keyword!r}。可换更短的片段重试; 或按时间范围查看:")
        print(f"  bdsub.py list {Path(args.srt).name} --from 00:05:00 --to 00:06:30")
        return
    print(f"匹配 {len(hits)} 条 (共 {len(cues)} 条, {meta['kind'].upper()}, 编码 {meta['enc']}):")
    for c in hits[: args.limit]:
        print(f"  [{c['idx']:>4}] {fmt_ts(c['start'])} --> {fmt_ts(c['end'])}")
        for line in c["text"].split("\n"):
            if line.strip():
                print(f"         {line.strip()}")
    if len(hits) > args.limit:
        print(f"  ... 还有 {len(hits) - args.limit} 条, 用 --limit 提高上限")


def cmd_list(args):
    cues, meta = load(args.srt)
    if not cues:
        sys.exit(f"未能解析出字幕条目: {args.srt}")
    sel = cues
    if args.head is not None:
        sel = cues[: max(args.head, 0)]
    elif args.tail is not None:
        sel = cues[len(cues) - max(args.tail, 0):] if args.tail else []
    elif args.from_ts or args.to_ts:
        lo = parse_ts(args.from_ts) if args.from_ts else -1
        hi = parse_ts(args.to_ts) if args.to_ts else 1e9
        sel = [c for c in cues if lo <= c["start"] <= hi]
    print(f"共 {len(cues)} 条, {meta['kind'].upper()}, 编码 {meta['enc']}, "
          f"首 {fmt_ts(cues[0]['start'])}, 末 {fmt_ts(cues[-1]['start'])}")
    for c in sel:
        txt = " / ".join(x.strip() for x in c["text"].split("\n") if x.strip())
        print(f"  [{c['idx']:>4}] {fmt_ts(c['start'])} --> {fmt_ts(c['end'])}   {txt}")


def cmd_check(args):
    a, _ = load(args.a)
    b, _ = load(args.b)
    if not a or not b:
        sys.exit("有文件解析为空")
    print(f"{Path(args.a).name}: {len(a)} 条, 末句 {fmt_ts(a[-1]['start'])}")
    print(f"{Path(args.b).name}: {len(b)} 条, 末句 {fmt_ts(b[-1]['start'])}")
    if len(a) != len(b):
        print(f"条数不一致 (差 {len(b) - len(a)} 条) → 强烈提示不同剪辑版本, 不建议调轴")
        return
    diffs = [y["start"] - x["start"] for x, y in zip(a, b)]
    print(f"逐句时间差: 首 {diffs[0]:+.2f}s  中 {diffs[len(diffs)//2]:+.2f}s  末 {diffs[-1]:+.2f}s")
    if abs(diffs[0] - diffs[-1]) < 0.3:
        print("→ 差值恒定: 整体平移, 一个锚点即可")
    else:
        print("→ 差值单调变化: 速率漂移, 需要两个以上锚点做 fit")


def cmd_convert(args):
    """ASS/SSA → SRT: 去特效标签, 只留文字与时间。G5300 渲染 ASS 特效较弱时用这个降级"""
    cues, meta = load(args.src)
    if not cues:
        sys.exit(f"未能解析出字幕条目: {args.src}")
    if meta["kind"] == "srt":
        sys.exit("输入已经是 SRT，convert 没有意义。要重新归一化请显式指定输出名。")
    enc = check_enc(args.enc) or meta["enc"]
    dst = args.dst or str(Path(args.src).with_suffix(".srt"))
    if _same_file(dst, args.src):
        sys.exit(f"输出路径与源文件相同 ({dst})，拒绝覆盖。请换一个输出名。")
    times = [(c["start"], c["end"]) for c in cues]
    _write_srt(cues, dst, enc, times)
    print(f"已转换 {len(cues)} 条 {meta['kind'].upper()} → SRT   (编码 {enc})")
    print(f"→ {dst}")


def cmd_sweep(args):
    """亚秒级偏移: 一次生成一组候选, 拷到 U 盘逐一切换比对。0.x 秒用测量法测不出来, 只能用比较法挑"""
    if args.offset is not None:
        lo = hi = args.offset
    elif args.from_off is not None and args.to_off is not None:
        lo, hi = args.from_off, args.to_off
    else:
        sys.exit("给 --offset 0.3（单个值），或 --from -0.4 --to 0.4（一组）")
    if args.step <= 0:
        sys.exit("--step 必须大于 0")
    if hi < lo:
        lo, hi = hi, lo
    n = int(round((hi - lo) / args.step))
    if n > 60:
        sys.exit(f"候选太多 ({n + 1} 个), 把范围收窄或把 --step 调大")
    vals = [round(lo + i * args.step, 3) for i in range(n + 1)]
    if args.out and len(vals) > 1:
        sys.exit(f"--out 只对单个偏移有效, 这次会生成 {len(vals)} 个文件; 去掉 --out 或改用 --offset")
    if args.out and _same_file(args.out, args.srt):
        sys.exit(f"输出路径与源文件相同 ({args.out})，拒绝覆盖。")

    cues, meta = load(args.srt)
    if not cues:
        sys.exit(f"未能从 {args.srt} 解析出字幕条目")

    dec = 2 if args.step < 0.1 else 1
    src = Path(args.srt)
    outdir = Path(args.dir) if args.dir else src.parent
    outdir.mkdir(parents=True, exist_ok=True)

    print(f"源: {src.name}  {len(cues)} 条  {meta['kind'].upper()}")
    print(f"生成 {len(vals)} 个候选 → {outdir}\n")
    for v in vals:
        if len(vals) == 1 and args.out:
            out = Path(args.out)
        else:
            dec_str = "0.0" + "0" * (dec - 1) if abs(v) < 1e-9 else (
                ("p" if v > 0 else "m") + f"{abs(v):.{dec}f}")
            out = outdir / f"{src.stem}_{dec_str}s{src.suffix}"
        if _same_file(out, args.srt):
            print(f"  跳过 {out.name}（与源文件同名）")
            continue
        times = [(max(c["start"] + v, 0.0), max(c["end"] + v, 0.0)) for c in cues]
        save(str(out), cues, meta, meta["enc"], times)
        mark = "  ← 原时间轴 (基准)" if abs(v) < 1e-9 else ""
        print(f"  {v:+.{dec}f}s  {out.name}{mark}")

    if cues[0]["start"] + vals[0] < 0:
        print("\n注意: 负向候选会让首条变负、被裁到 0; 片头几秒的字幕会堆在一起, 属正常")


def main():
    p = argparse.ArgumentParser(description="蓝光外挂字幕时间轴拟合与校正 (SRT / ASS)")
    sub = p.add_subparsers(dest="cmd", required=True)

    f = sub.add_parser("fit", help="用锚点拟合 a/b 并输出校正后的字幕")
    f.add_argument("srt", help="字幕文件, 支持 .srt / .ass / .ssa")
    f.add_argument("--anchor", action="append", default=[], metavar="碟=字",
                   help="碟片时刻=字幕时刻, 可多次给出")
    f.add_argument("--out", help="输出路径, 默认 <原名>_fixed<原扩展名>")
    f.add_argument("--dry-run", action="store_true", help="只算系数, 不写文件")
    f.add_argument("--keep-duration", action="store_true", help="只变换起点, 保持原显示时长")
    f.add_argument("--ratio", type=float,
                   help="直接指定已知速率比(如 1.0416667 = PAL 加速), 此时一个锚点即可解出偏移")
    f.add_argument("--enc", help="输出编码, 默认与原文件一致")
    f.add_argument("--force", action="store_true", help="残差过大(判定 C 类)时仍强行写出")
    f.set_defaults(func=cmd_fit)

    fd = sub.add_parser("find", help="搜台词, 查它在字幕里的时间码")
    fd.add_argument("srt")
    fd.add_argument("keyword", nargs="+", help="台词片段, 可只给一小段")
    fd.add_argument("--limit", type=int, default=10)
    fd.set_defaults(func=cmd_find)

    ls = sub.add_parser("list", help="按行号或时间范围列字幕条目")
    ls.add_argument("srt")
    ls.add_argument("--head", type=int)
    ls.add_argument("--tail", type=int)
    ls.add_argument("--from", dest="from_ts")
    ls.add_argument("--to", dest="to_ts")
    ls.set_defaults(func=cmd_list)

    c = sub.add_parser("check", help="对比两份字幕, 判断偏移类型")
    c.add_argument("a")
    c.add_argument("b")
    c.set_defaults(func=cmd_check)

    cv = sub.add_parser("convert", help="ASS/SSA → SRT (去特效标签), 给渲染能力弱的播放器用")
    cv.add_argument("src")
    cv.add_argument("dst", nargs="?")
    cv.add_argument("--enc", help="输出编码, 默认与原文件一致")
    cv.set_defaults(func=cmd_convert)

    sw = sub.add_parser("sweep", help="亚秒级偏移: 一次生成一组候选, 播放时逐一切换挑最贴的")
    sw.add_argument("srt", help="字幕文件 (.srt/.ass/.ssa)")
    sw.add_argument("--offset", type=float, help="单个偏移量(秒), 如 0.3 或 -0.2")
    sw.add_argument("--from", dest="from_off", type=float, help="范围起点(秒), 如 -0.4")
    sw.add_argument("--to", dest="to_off", type=float, help="范围终点(秒), 如 0.4")
    sw.add_argument("--step", type=float, default=0.1, help="步长, 默认 0.1")
    sw.add_argument("--dir", help="输出目录, 默认与原文件同目录")
    sw.add_argument("--out", help="只有一个候选时指定输出名")
    sw.set_defaults(func=cmd_sweep)

    args = p.parse_args()
    args.func(args)


if __name__ == "__main__":
    main()
