#!/usr/bin/env python3
from __future__ import annotations

import argparse
import csv
from dataclasses import dataclass, field
from pathlib import Path
import re
import sys


EPISODE_RE = re.compile(r"^\s*(?:#{1,6}\s*)?第\s*([0-9一二三四五六七八九十百两]+)\s*集\b")
SCENE_RE = re.compile(
    r"^\s*(?:#{1,6}\s*)?(?:场(?:次)?\s*[:：]?\s*"
    r"([0-9一二三四五六七八九十百两]+)?(?:\s*[:：].*)?|"
    r"([0-9]{1,3})\s*[-－]\s*([0-9]{1,2})\b)"
)
# Format labels that must not be counted as dialogue. Kept in sync with the
# scene engineering sheet in 云端规则资料 so
# scripts written with the self-check fields pass length gates cleanly.
SECTION_LABELS = [
    "场次", "场景", "画面", "动作", "音效/BGM", "音效", "BGM", "镜头", "转场",
    "人物/对白/旁白", "人物", "对白", "旁白", "结尾钩子", "声纹核对", "关键对白功能",
    "单场有效字数", "场景功能", "场景目标", "人物权力关系", "信息差", "冲突升级三步",
    "画面中心", "动作链", "反应链", "屏幕规则/系统/道具", "视觉转折", "故事状态改变",
    "本场信息释放", "逐句对白检查", "无效场自检", "可参考的 scene_case 机制",
    "scene_case 置信度", "云端机制原创替换", "必须原创替换",
]
SECTION_RE = re.compile(
    r"^\s*(" + "|".join(re.escape(label) for label in sorted(SECTION_LABELS, key=len, reverse=True)) + r")\s*[:：]"
)
SPEAKER_RE = re.compile(r"^\s*([^：:\s]{1,12}(?:\s*(?:OS|VO))?|旁白|系统|字幕|广播)\s*[:：]\s*(.+?)\s*$")
# Bare 人物： is a cast/roster line in the 华语短剧格式, not dialogue; only the
# combined 人物/对白/旁白 label and the 对白/旁白 labels carry counted dialogue.
LABEL_PREFIX_RE = re.compile(r"^\s*(?:人物/对白/旁白|对白|旁白)\s*[:：]\s*(.*)$")
DELETION_RE = re.compile(r"^\s*本场删除候选\s*[:：]\s*(.+?)\s*$")
VOICE_DEVIATION_RE = re.compile(r"<!--\s*声纹偏离\s*:\s*(.*?)\s*-->")
INFO_NOTE_RE = re.compile(r"<!--\s*信息\s*:\s*(.*?)\s*-->")

INFO_DUMP_TERMS = [
    "因为",
    "所以",
    "原来",
    "其实",
    "当年",
    "真相",
    "身份",
    "规则",
    "系统",
    "任务",
    "秘密",
    "也就是说",
    "换句话说",
]

AI_RISK_TERMS = [
    "仿佛",
    "宛如",
    "此刻",
    "命运",
    "空气凝固",
    "所有人都震惊",
    "他知道",
    "她知道",
    "心中一震",
    "不由得",
    "也就是说",
    "换句话说",
]
HARD_AI_TERMS = [
    "命运的齿轮",
    "仿佛在诉说",
    "岁月静好",
    "空气仿佛凝固",
    "所有人都震惊了",
    "不禁倒吸一口凉气",
    "心中五味杂陈",
]
DIRECT_EMOTION_TERMS = [
    "我很愤怒",
    "我很生气",
    "我好害怕",
    "我很害怕",
    "我好难过",
    "我很难过",
    "我很痛苦",
    "我很绝望",
    "我很委屈",
    "我嫉妒",
    "我恨",
    "我爱你",
]
WRITTEN_REGISTER_TERMS = [
    "生路",
    "退路",
    "给你一条路",
    "给他一条路",
    "给她一条路",
    "付出代价",
    "逃不掉的",
    "终究",
    "注定",
    "胆敢",
    "竟敢",
    "此地",
    "今日",
]
NON_DIRECT_SIGNALS = ["？", "?", "……", "——", "闭嘴", "滚", "住手", "让开", "别动", "呵", "哈"]
COMPLETE_ENDINGS = ("。", "！", "!", "；", ";")


@dataclass
class DialogueLine:
    speaker: str
    text: str
    line_no: int
    raw: str = ""


@dataclass
class Scene:
    label: str
    start_line: int
    dialogue: list[DialogueLine] = field(default_factory=list)
    deletion_candidates: list[str] = field(default_factory=list)


@dataclass
class Episode:
    label: str
    start_line: int
    scenes: list[Scene] = field(default_factory=list)
    dialogue: list[DialogueLine] = field(default_factory=list)


def compact_count(text: str) -> int:
    return len(re.sub(r"\s+", "", text))


def normalize_label(raw: str | None, fallback: int) -> str:
    raw = (raw or "").strip()
    return raw or str(fallback)


def parse_script(text: str) -> list[Episode]:
    episodes: list[Episode] = []
    current_episode: Episode | None = None
    current_scene: Scene | None = None
    in_dialogue_section = False

    for line_no, line in enumerate(text.splitlines(), 1):
        stripped = line.strip()
        if not stripped:
            continue

        # △/▲ lines are action/visual/sound description in the 华语短剧格式;
        # never dialogue, even if they happen to contain a colon.
        if stripped[0] in "△▲":
            continue

        ep_match = EPISODE_RE.match(stripped)
        if ep_match:
            current_episode = Episode(label=ep_match.group(1), start_line=line_no)
            episodes.append(current_episode)
            current_scene = None
            in_dialogue_section = False
            continue

        if current_episode is None:
            current_episode = Episode(label="未标集", start_line=1)
            episodes.append(current_episode)

        deletion_match = DELETION_RE.match(stripped)
        if deletion_match:
            if current_scene is None:
                current_scene = Scene(label=f"未标场-{len(current_episode.scenes) + 1}", start_line=line_no)
                current_episode.scenes.append(current_scene)
            current_scene.deletion_candidates.append(deletion_match.group(1).strip())
            continue

        scene_match = SCENE_RE.match(stripped)
        if scene_match:
            if scene_match.group(3):
                scene_label = f"{scene_match.group(2)}-{scene_match.group(3)}"
            else:
                scene_label = normalize_label(scene_match.group(1), len(current_episode.scenes) + 1)
            current_scene = Scene(label=scene_label, start_line=line_no)
            current_episode.scenes.append(current_scene)
            in_dialogue_section = False
            continue

        section_match = SECTION_RE.match(stripped)
        if section_match:
            section_name = section_match.group(1)
            in_dialogue_section = section_name in {"人物/对白/旁白", "对白", "旁白"}
            label_match = LABEL_PREFIX_RE.match(stripped)
            if label_match and label_match.group(1).strip():
                speaker = section_name if section_name in {"旁白"} else "未标注"
                add_dialogue_line(current_episode, current_scene, speaker, label_match.group(1), line_no, stripped)
            continue

        # 华语短剧格式的对白直接跟在 △ 行之后，没有 `对白：` 标签，所以
        # `角色 ：台词` 一律计数（section 标签行已在上面 continue，不会到这里）。
        speaker_match = SPEAKER_RE.match(stripped)
        if speaker_match:
            add_dialogue_line(current_episode, current_scene, speaker_match.group(1).strip(), speaker_match.group(2).strip(), line_no, stripped)
            continue

        if in_dialogue_section and not SECTION_RE.match(stripped):
            add_dialogue_line(current_episode, current_scene, "未标注", stripped, line_no, stripped)

    return episodes


def add_dialogue_line(episode: Episode, scene: Scene | None, speaker: str, text: str, line_no: int, raw: str = "") -> None:
    if not text.strip():
        return
    clean_text = strip_notes(text.strip())
    item = DialogueLine(speaker=speaker, text=clean_text, line_no=line_no, raw=raw or text.strip())
    episode.dialogue.append(item)
    if scene is None:
        scene = Scene(label=f"未标场-{len(episode.scenes) + 1}", start_line=line_no)
        episode.scenes.append(scene)
    scene.dialogue.append(item)


def line_text_count(lines: list[DialogueLine]) -> int:
    return sum(compact_count(line.text) for line in lines)


def strip_notes(text: str) -> str:
    return re.sub(r"<!--.*?-->", "", text).strip()


def parse_note_fields(raw: str) -> dict[str, str]:
    fields: dict[str, str] = {}
    for part in re.split(r"[;；]", raw):
        if "=" in part:
            key, value = part.split("=", 1)
            fields[key.strip()] = value.strip()
    return fields


def scene_position_value(episode_label: str, scene_label: str) -> tuple[int, int]:
    episode_no = chinese_or_arabic_to_int(episode_label)
    scene_match = re.search(r"(\d+)$", scene_label)
    scene_no = int(scene_match.group(1)) if scene_match else 0
    return episode_no, scene_no


def parse_position(value: str) -> tuple[int, int] | None:
    value = value.strip()
    if not value:
        return None
    match = re.search(r"([0-9一二三四五六七八九十百两]+)\s*[-－集场]\s*([0-9一二三四五六七八九十百两]+)?", value)
    if not match:
        return None
    ep = chinese_or_arabic_to_int(match.group(1))
    sc = chinese_or_arabic_to_int(match.group(2) or "0")
    return ep, sc


def chinese_or_arabic_to_int(raw: str) -> int:
    raw = (raw or "").strip()
    if raw.isdigit():
        return int(raw)
    digits = {"零": 0, "一": 1, "二": 2, "两": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
    if raw == "十":
        return 10
    if "百" in raw:
        left, _, right = raw.partition("百")
        return (digits.get(left, 1) * 100) + chinese_or_arabic_to_int(right or "零")
    if "十" in raw:
        left, _, right = raw.partition("十")
        return (digits.get(left, 1) * 10) + digits.get(right, 0)
    return digits.get(raw, 0)


def load_term_rows(path: str) -> list[dict[str, str]]:
    if not path:
        return []
    term_path = Path(path).expanduser().resolve()
    rows: list[dict[str, str]] = []
    with term_path.open("r", encoding="utf-8-sig", newline="") as handle:
        reader = csv.DictReader(handle)
        for row in reader:
            rows.append({(key or "").strip(): (value or "").strip() for key, value in row.items()})
    return rows


def info_dump_warnings(episodes: list[Episode], min_chars: int) -> list[str]:
    warnings: list[str] = []
    for episode in episodes:
        for scene in episode.scenes:
            for line in scene.dialogue:
                length = compact_count(line.text)
                if length < min_chars:
                    continue
                term_hits = [term for term in INFO_DUMP_TERMS if term in line.text]
                if len(term_hits) >= 2:
                    warnings.append(
                        f"line {line.line_no}: 第{episode.label}集/场{scene.label} {line.speaker} "
                        f"疑似信息倾倒（{length}字，命中：{'、'.join(term_hits[:5])}）"
                    )
    return warnings


def ai_tone_warnings(
    episodes: list[Episode],
    max_dialogue_line_chars: int,
    max_narration_ratio: float,
    max_complete_sentence_streak: int,
    min_uniform_scene_lines: int,
    uniform_scene_cv: float,
) -> list[str]:
    warnings: list[str] = []
    all_lines = [line for episode in episodes for line in episode.dialogue]
    total_chars = line_text_count(all_lines)
    narration_chars = sum(
        compact_count(line.text)
        for line in all_lines
        if line.speaker in {"旁白", "系统", "字幕", "广播"}
    )
    narration_ratio = narration_chars / total_chars if total_chars else 0
    if total_chars and narration_ratio > max_narration_ratio:
        warnings.append(
            f"旁白/系统/字幕/广播占比 {narration_ratio:.1%}，高于 {max_narration_ratio:.0%}，可能偏说明书腔。"
        )

    for episode in episodes:
        for scene in episode.scenes:
            lengths = [compact_count(line.text) for line in scene.dialogue]
            if len(lengths) >= min_uniform_scene_lines:
                mean = sum(lengths) / len(lengths)
                variance = sum((length - mean) ** 2 for length in lengths) / len(lengths)
                cv = (variance ** 0.5) / mean if mean else 0
                if cv < uniform_scene_cv:
                    warnings.append(
                        f"第{episode.label}集/场{scene.label} 台词句长过于平均（cv={cv:.2f}），可能有 AI 整齐腔。"
                    )
                short_ratio = sum(1 for length in lengths if length <= 6) / len(lengths)
                if short_ratio >= 0.65 and mean <= 9:
                    warnings.append(
                        f"第{episode.label}集/场{scene.label} 短句密度 {short_ratio:.0%} 且均长 {mean:.1f} 字，可能被拆成电报腔。"
                    )
            short_streak = 0
            for line in scene.dialogue:
                if compact_count(line.text) <= 6:
                    short_streak += 1
                    if short_streak == 6:
                        warnings.append(
                            f"第{episode.label}集/场{scene.label} 连续 6 句超短对白，可能像提词器腔；普通谈判/关系场需要补语气、试探或反应。"
                        )
                else:
                    short_streak = 0
            has_non_direct_signal = any(
                any(signal in line.text for signal in NON_DIRECT_SIGNALS) or compact_count(line.text) <= 4
                for line in scene.dialogue
            )
            if len(scene.dialogue) >= 3 and not has_non_direct_signal:
                warnings.append(
                    f"第{episode.label}集/场{scene.label} 缺少反问、打断、停顿、命令或短句信号，建议加入潜台词/非直答。"
                )

            complete_streak = 0
            for line in scene.dialogue:
                length = compact_count(line.text)
                if length > max_dialogue_line_chars and line.speaker not in {"系统", "字幕"}:
                    warnings.append(
                        f"line {line.line_no}: {line.speaker} 单句 {length} 字，高于 {max_dialogue_line_chars}，建议拆句或改成动作承接。"
                    )
                risk_hits = [term for term in AI_RISK_TERMS if term in line.text]
                if risk_hits:
                    warnings.append(
                        f"line {line.line_no}: 疑似 AI 腔高危词（{'、'.join(risk_hits[:4])}）。"
                    )
                emotion_hits = [term for term in DIRECT_EMOTION_TERMS if term in line.text]
                if emotion_hits:
                    warnings.append(
                        f"line {line.line_no}: 情绪直给（{'、'.join(emotion_hits[:3])}），建议改为动作、反讽、沉默或转移话题。"
                    )
                register_hits = [term for term in WRITTEN_REGISTER_TERMS if term in line.text]
                if register_hits:
                    warnings.append(
                        f"line {line.line_no}: 书面/舞台腔词组（{'、'.join(register_hits[:3])}），建议换成具体口语动作，例如“放她走”“让他出去”“我不开枪”。"
                    )
                if line.text.endswith(COMPLETE_ENDINGS) and length >= 8 and "？" not in line.text and "?" not in line.text:
                    complete_streak += 1
                    if complete_streak == max_complete_sentence_streak:
                        warnings.append(
                            f"第{episode.label}集/场{scene.label} 连续 {max_complete_sentence_streak} 句完整陈述，可能偏 AI 总结腔。"
                        )
                else:
                    complete_streak = 0
    return warnings


def strict_ai_tone_errors(episodes: list[Episode], hard_narration_ratio: float) -> list[str]:
    errors: list[str] = []
    all_lines = [line for episode in episodes for line in episode.dialogue]
    total_chars = line_text_count(all_lines)
    narration_chars = sum(
        compact_count(line.text)
        for line in all_lines
        if line.speaker in {"旁白", "系统", "字幕", "广播"}
    )
    narration_ratio = narration_chars / total_chars if total_chars else 0
    if total_chars and narration_ratio > hard_narration_ratio:
        errors.append(
            f"旁白/系统/字幕/广播占比 {narration_ratio:.1%}，高于 strict 硬上限 {hard_narration_ratio:.0%}。"
        )
    for episode in episodes:
        for scene in episode.scenes:
            for line in scene.dialogue:
                hard_hits = [term for term in HARD_AI_TERMS if term in line.text]
                if hard_hits:
                    errors.append(
                        f"line {line.line_no}: strict AI 腔硬名单命中（{'、'.join(hard_hits[:3])}）。"
                    )
    return errors


def information_boundary_messages(episodes: list[Episode]) -> tuple[list[str], list[str]]:
    errors: list[str] = []
    warnings: list[str] = []
    for episode in episodes:
        for scene in episode.scenes:
            current_pos = scene_position_value(episode.label, scene.label)
            for line in scene.dialogue:
                for raw_note in INFO_NOTE_RE.findall(line.raw):
                    fields = parse_note_fields(raw_note)
                    earliest = parse_position(fields.get("最早", ""))
                    keyword = fields.get("关键词", "")
                    actor = fields.get("角色", line.speaker)
                    action = fields.get("动作", "释放")
                    if earliest and current_pos < earliest:
                        errors.append(
                            f"line {line.line_no}: {actor}{action}“{keyword}”早于最早位置 {fields.get('最早')}。"
                        )
                    elif not earliest:
                        warnings.append(f"line {line.line_no}: 信息注释缺少 `最早=`，无法做硬门校验。")
    return errors, warnings


def voice_deviation_warnings(episodes: list[Episode]) -> list[str]:
    warnings: list[str] = []
    for episode in episodes:
        for scene in episode.scenes:
            for line in scene.dialogue:
                for raw_note in VOICE_DEVIATION_RE.findall(line.raw):
                    fields = parse_note_fields(raw_note)
                    if not fields.get("依据"):
                        warnings.append(f"line {line.line_no}: 声纹偏离注释缺少 `依据=`。")
    return warnings


def deletion_candidate_warnings(episodes: list[Episode], max_empty_streak: int) -> list[str]:
    warnings: list[str] = []
    empty_streak = 0
    for episode in episodes:
        for scene in episode.scenes:
            has_candidate = any(item and item not in {"无", "无候选", "没有候选"} for item in scene.deletion_candidates)
            if not has_candidate:
                empty_streak += 1
                warnings.append(f"第{episode.label}集/场{scene.label} 未填写本场删除候选。")
                if empty_streak >= max_empty_streak:
                    warnings.append(f"连续 {empty_streak} 场没有删除候选，可能未做删除测试。")
            else:
                empty_streak = 0
    return warnings


def main_character_silence_warnings(episodes: list[Episode], names: list[str], max_silent_scenes: int) -> list[str]:
    warnings: list[str] = []
    if not names:
        return warnings
    silent_streaks = {name: 0 for name in names}
    totals = {name: 0 for name in names}
    for episode in episodes:
        for scene in episode.scenes:
            scene_speakers = {line.speaker for line in scene.dialogue}
            for name in names:
                if name in scene_speakers:
                    silent_streaks[name] = 0
                    totals[name] += 1
                else:
                    silent_streaks[name] += 1
                    if silent_streaks[name] == max_silent_scenes:
                        warnings.append(f"主要角色“{name}”连续 {max_silent_scenes} 场没有台词。")
    for name, total in totals.items():
        if total == 0:
            warnings.append(f"主要角色“{name}”全篇没有被识别到台词。")
    return warnings


def term_warnings(episodes: list[Episode], rows: list[dict[str, str]]) -> list[str]:
    warnings: list[str] = []
    if not rows:
        return warnings
    all_lines = [line for episode in episodes for line in episode.dialogue]
    for row in rows:
        speaker = row.get("角色") or row.get("speaker") or ""
        required = [term.strip() for term in re.split(r"[|,，、]", row.get("口头禅", "") or row.get("required", "")) if term.strip()]
        forbidden = [term.strip() for term in re.split(r"[|,，、]", row.get("禁用词", "") or row.get("forbidden", "")) if term.strip()]
        speaker_lines = [line for line in all_lines if not speaker or line.speaker == speaker]
        speaker_text = "\n".join(line.text for line in speaker_lines)
        for term in required:
            if term and term not in speaker_text:
                warnings.append(f"角色“{speaker or '全体'}”未出现口头禅/必备词：“{term}”。")
        for line in speaker_lines:
            for term in forbidden:
                if term and term in line.text:
                    warnings.append(f"line {line.line_no}: 角色“{line.speaker}”出现禁用词/反声纹词：“{term}”。")
    return warnings


def dialogue_inventory(episodes: list[Episode]) -> list[str]:
    rows: list[str] = []
    for episode in episodes:
        speakers: dict[str, int] = {}
        for line in episode.dialogue:
            speakers[line.speaker] = speakers.get(line.speaker, 0) + compact_count(line.text)
        if speakers:
            summary = "；".join(f"{speaker}:{chars}字" for speaker, chars in sorted(speakers.items()))
            rows.append(f"第{episode.label}集角色台词量：{summary}")
    return rows


def run(path: Path, args: argparse.Namespace) -> int:
    text = path.read_text(encoding="utf-8")
    episodes = parse_script(text)
    errors: list[str] = []
    warnings: list[str] = []

    if not episodes:
        errors.append("未识别到任何集。请使用“第X集”作为集标题。")

    for episode in episodes:
        ep_chars = line_text_count(episode.dialogue)
        scene_count = len(episode.scenes)
        if ep_chars < args.min_episode_chars:
            errors.append(f"第{episode.label}集有效对白+旁白 {ep_chars} 字，低于 {args.min_episode_chars}。")
        if ep_chars > args.max_episode_chars:
            errors.append(f"第{episode.label}集有效对白+旁白 {ep_chars} 字，高于 {args.max_episode_chars}。")
        if scene_count < 1:
            errors.append(f"第{episode.label}集未识别到场次。")
        if scene_count > args.max_scenes:
            errors.append(f"第{episode.label}集 {scene_count} 场，高于 {args.max_scenes} 场上限。")

        for scene in episode.scenes:
            scene_chars = line_text_count(scene.dialogue)
            if scene_chars < args.min_scene_chars:
                warnings.append(f"第{episode.label}集/场{scene.label} 有效对白+旁白 {scene_chars} 字，低于 {args.min_scene_chars}，可能未展开。")
            if scene_chars > args.max_scene_chars:
                warnings.append(f"第{episode.label}集/场{scene.label} 有效对白+旁白 {scene_chars} 字，高于 {args.max_scene_chars}，建议拆场或删冗余。")

    boundary_errors, boundary_warnings = information_boundary_messages(episodes)
    errors.extend(boundary_errors)
    warnings.extend(boundary_warnings)
    if args.strict:
        errors.extend(strict_ai_tone_errors(episodes, args.hard_narration_ratio))
    warnings.extend(voice_deviation_warnings(episodes))
    if args.require_deletion_candidates:
        warnings.extend(deletion_candidate_warnings(episodes, args.max_empty_deletion_streak))
    warnings.extend(info_dump_warnings(episodes, args.info_dump_chars))
    warnings.extend(
        ai_tone_warnings(
            episodes,
            args.max_dialogue_line_chars,
            args.max_narration_ratio,
            args.max_complete_sentence_streak,
            args.min_uniform_scene_lines,
            args.uniform_scene_cv,
        )
    )
    warnings.extend(main_character_silence_warnings(episodes, args.main_characters, args.max_silent_scenes))
    warnings.extend(term_warnings(episodes, load_term_rows(args.voice_terms_csv)))

    print(f"QA target: {path}")
    print(f"episodes={len(episodes)}")
    print("")

    if errors:
        print("ERRORS")
        for error in errors:
            print(f"- {error}")
        print("")

    if warnings:
        print("WARNINGS")
        for warning in warnings:
            print(f"- {warning}")
        print("")

    print("DIALOGUE INVENTORY")
    inventory = dialogue_inventory(episodes)
    if inventory:
        for row in inventory:
            print(f"- {row}")
    else:
        print("- 未识别到对白或旁白。请检查“人物/对白/旁白：”或“角色：台词”格式。")

    if errors:
        return 1
    return 0


def main() -> None:
    parser = argparse.ArgumentParser(description="QA generated comic-drama scripts for hard length/scene gates and dialogue review signals.")
    parser.add_argument("script_path", help="Path to a generated script markdown/text file.")
    parser.add_argument("--min-episode-chars", type=int, default=250)
    parser.add_argument("--max-episode-chars", type=int, default=1000)
    parser.add_argument("--max-scenes", type=int, default=3)
    parser.add_argument("--min-scene-chars", type=int, default=80)
    parser.add_argument("--max-scene-chars", type=int, default=400)
    parser.add_argument("--info-dump-chars", type=int, default=90)
    parser.add_argument("--max-dialogue-line-chars", type=int, default=32)
    parser.add_argument("--max-narration-ratio", type=float, default=0.15)
    parser.add_argument("--max-complete-sentence-streak", type=int, default=4)
    parser.add_argument("--min-uniform-scene-lines", type=int, default=5)
    parser.add_argument("--uniform-scene-cv", type=float, default=0.22)
    parser.add_argument("--strict", action="store_true", help="Promote low-false-positive AI-tone failures to errors.")
    parser.add_argument("--hard-narration-ratio", type=float, default=0.45)
    parser.add_argument("--require-deletion-candidates", action="store_true", help="Warn when scenes do not include 本场删除候选.")
    parser.add_argument("--max-empty-deletion-streak", type=int, default=3)
    parser.add_argument("--main-character", dest="main_characters", action="append", default=[], help="Main character name to watch for dialogue silence; can be repeated.")
    parser.add_argument("--max-silent-scenes", type=int, default=3)
    parser.add_argument("--voice-terms-csv", default="", help="Optional CSV with columns 角色,口头禅,禁用词 for string checks.")
    args = parser.parse_args()

    path = Path(args.script_path).expanduser().resolve()
    if not path.exists():
        print(f"ERROR: script not found: {path}", file=sys.stderr)
        sys.exit(2)
    sys.exit(run(path, args))


if __name__ == "__main__":
    main()
