# -*- coding: utf-8 -*-
"""
C4: 融合B稿(743行碎句) + A稿分段骨架 → 融合A稿.docx(公文格式)
=============================================================
职责: AI 只做分段对齐(决定每句归哪段),正文一字不改、纯规则拼接。

P3 新增: LLM 分段骨架抽取 (doco skeleton 命令)
  - extract_a_paragraphs: 纯 docx 段落样式提取
  - extract_skeleton_llm: LLM 判断分段结构,产出 JSON 骨架
  - validate_skeleton_coverage: 全覆盖硬校验(无缺口/无重叠/无越界)
  - parse_a_segments: 改读 skeleton JSON 替代正则(ep001 fallback 保留)
"""

import json
import re
import sys
import unicodedata
from pathlib import Path
from typing import List, Dict, Optional, Tuple, Any

from docx import Document
from docx.shared import Pt, Cm
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml.ns import qn
from docx.oxml import OxmlElement

from .llm import chat, LLMConfigError
from .fusion_review import parse_timed_lines


# --------------------------------------------------------------------------
# 常量
# --------------------------------------------------------------------------

SEG_HEADER_PATTERN = re.compile(r"^【.+?】$")

# 隔断识别: "隔断：【标题】"
SEG_BREAK_PATTERN = re.compile(r"^隔断：【(.+?)】")

# 句尾标点: 行尾已有这些字符则不补逗号
SENTENCE_END_PUNCT = frozenset("。！？；…!?;")

# 字体
TITLE_FONT_PRIMARY = "方正小标宋_GBK"
TITLE_FONT_FALLBACK = "宋体"
HEADER_FONT = "黑体"
BODY_FONT = "仿宋_GB2312"
BREAK_HEADER_FONT = "仿宋_GB2312"  # 隔断段头用仿宋加粗

EMPTY_SEG_PLACEHOLDER = "（本段无对应字幕，待编导核）"

# 中文冒号/英文冒号（用于切分 inline 段头）
INLINE_HEADER_SEP = re.compile(r"[：:]")


# ====================================================================
# 1a. 纯 docx 段落样式提取
# ====================================================================


def extract_a_paragraphs(docx_path: str) -> List[Dict[str, Any]]:
    """
    读 A 稿 docx，返回带样式信息的段落列表。
    每条: {"idx": int, "text": str, "bold": bool, "align": str}
      - bold: 该段任一 run 含加粗即为 True
      - align: "left" | "center" | "justify" | "right" | None
      - 跳过全空段落
    """
    p = Path(docx_path)
    if not p.exists():
        raise FileNotFoundError(f"A稿 docx 不存在: {docx_path}")

    doc = Document(str(p))
    paras_out = []

    ALIGN_MAP = {
        WD_ALIGN_PARAGRAPH.LEFT: "left",
        WD_ALIGN_PARAGRAPH.CENTER: "center",
        WD_ALIGN_PARAGRAPH.JUSTIFY: "justify",
        WD_ALIGN_PARAGRAPH.RIGHT: "right",
    }

    for idx, para in enumerate(doc.paragraphs):
        text = para.text.strip()
        if not text:
            continue

        # 检测加粗: 任一 run 的 bold=True
        bold = any(
            (run.bold is not None and run.bold) for run in para.runs
        )

        # 对齐
        raw_align = para.alignment
        align = ALIGN_MAP.get(raw_align, None)

        paras_out.append(
            {
                "idx": len(paras_out),  # 重新编号，连续无跳
                "text": text,
                "bold": bold,
                "align": align,
            }
        )

    return paras_out


# ====================================================================
# 1b. 段落列表序列化（喂 LLM）
# ====================================================================


def _serialize_paras_for_llm(paras: List[Dict[str, Any]]) -> str:
    """把段落列表格式化为 LLM 输入文本: [idx] bold=X align=Y | text"""
    lines = []
    for p in paras:
        flags = []
        if p.get("bold"):
            flags.append("bold")
        if p.get("align"):
            flags.append(f"align={p['align']}")
        flag_str = ",".join(flags) if flags else "-"
        lines.append(f"[{p['idx']}] {flag_str} | {p['text']}")
    return "\n".join(lines)


# ====================================================================
# 2. LLM 分段骨架提取
# ====================================================================

SKELETON_SYSTEM_PROMPT = """你是电视专题片文稿结构分析器。给你全文段落列表(每段带编号和样式标注),请判断每段的结构角色并输出 JSON 骨架。

【核心铁律】
1. 你只做结构判断,只输出 JSON 骨架。**严禁输出、复述、生成任何正文文字。**
2. role_label 只包含**角色类型**,严禁包含任何真人姓名/配音员/编导署名。
3. 所有段落必须归入某个 segment,骨架的 [para_start,para_end] 区间必须恰好覆盖
   第一个段头之后的所有段落一次——无缺口、无重叠、无越界。

【角色类型参考集(开放可超集)】
导视 / 主持人 / 解说 / 专家 / 嘉宾 / 旁白 / 三维动画解说 / 演播室主持人 /
小剧场角色(小剧场N-角色名,如小剧场1-主持人、小剧场1-卡拉什尼科夫、
小剧场1-尤金斯通纳) / 子标题(break 型,原文字保留)

【段头识别规则】
- 冒号式: 文本含"："或":"且冒号左边是角色类型标识(如"解说：""主持人："
  "专家：""三维动画解说：""演播室主持人："等)→ header_inline=true,
  段头与正文同段。该段 para_start==para_end==header_para_idx。
- 独占一行式: 独立段落标记角色/子标题(如"【手枪】""【步枪】""小剧场1-主持人"
  "解密鱼鳔的潜艇压载水舱系统")→ header_inline=false,段头占一个段落,
  正文从下一段开始。
- 子标题(break): 加粗+总结性的短语+无冒号→ type=break,header_inline=false。

【role_label 命名规则(最高优先级红线)】
- role_label 格式: "【角色类型N】",如"【解说1】""【主持人1】""【三维动画解说1】"
  "【演播室主持人1】""【小剧场1-主持人】""【小剧场1-卡拉什尼科夫】"
- 同类角色按出现顺序自动编号: 第一个解说→【解说1】,第二个→【解说2】...
- break 型: role_label 为子标题原文(不加工)
- ignore 型: role_label 为标记类型原文(如"【固】""【摇】""【轨】")
- **红线**: role_label 中**禁止包含**真人姓名/配音员姓名/编导署名。
  反面案例: "【演播室主持人:刘通】""【解说:穆佩弦】""【旁白:孙逸昊】"
  "【主持人:左鑫】"——全部非法。正确写法:"【演播室主持人1】""【解说1】"
  "【旁白1】""【主持人1】"。
- **小剧场角色(如卡拉什尼科夫、尤金斯通纳、小剧场1-主持人)保留**:
  这是被演绎的历史人物/剧情角色,非真人配音员,可以保留。
- 清理垃圾前缀: 如"Xr演播室"、"Xr"→ 提取真正的角色类型后命名。

【陷阱(必须归为 ignore,type="ignore")】
- 【固】【摇】【轨】【推】【拉】【跟】等运镜/镜头标记 → type=ignore
- (演播室)(此处为舞台表演)(掌声)等圆括号舞台提示 → type=ignore
- 纯标点/纯空白段 → type=ignore
- 注意: 整段加粗 ≠ 段头(如枪王期全文字幕加粗,需结合语义判断)
- 注意: "三维动画"、"三维动画解说"无冒号时可能是独立段头,结合上下文判断

【输出格式】
纯 JSON 数组,无 markdown 包装,无额外解释:
[
  {
    "type": "normal" | "break" | "ignore",
    "para_start": int,  // 此段覆盖的起始段落下标(inclusive)
    "para_end": int,    // 此段覆盖的结束段落下标(inclusive)
    "role_label": "【演播室主持人1】",
    "header_inline": bool,  // 段头是否与正文同段(冒号式)
    "header_para_idx": int  // 段头所在段落下标(用于后续切除段头)
  },
  ...
]

【注意】
- para_start/para_end 基于输入给你的 [idx] 编号
- 全文第一个段落是标题(含副标题等),请自动忽略(不纳入任何 segment)
- break 段: 单一段落,header_inline=false,body 为空
- ignore 段: 不进最终输出,但区间必须覆盖以通过全覆盖校验"""


def extract_skeleton_llm(
    paras: List[Dict[str, Any]],
    max_tokens: int = 16000,
) -> List[Dict[str, Any]]:
    """
    把全文段落列表喂给 LLM,返回 JSON 骨架。
    
    Args:
        paras: extract_a_paragraphs 的输出
        max_tokens: LLM max_tokens,长稿需调大(默认 16000)
    
    Returns:
        骨架列表 [{type, para_start, para_end, role_label, header_inline, header_para_idx}]
    
    Raises:
        ValueError: JSON 解析失败 / 返回格式不对
        LLMConfigError: API key 未配置
    """
    if not paras:
        raise ValueError("段落列表为空,无法提取骨架")

    # 序列化全文
    full_text = _serialize_paras_for_llm(paras)

    user_content = (
        f"全文共 {len(paras)} 个段落(第0段是标题,请自动忽略):\n\n"
        f"{full_text}\n\n"
        f"请输出 JSON 骨架数组。"
    )

    messages = [
        {"role": "system", "content": SKELETON_SYSTEM_PROMPT},
        {"role": "user", "content": user_content},
    ]

    raw_response = chat(
        messages,
        thinking=True,
        max_tokens=max_tokens,
        temperature=0.0,
    )

    # 解析 JSON
    parsed = _parse_skeleton_json(raw_response)

    # 校验每条记录的必要字段
    required_fields = ["type", "para_start", "para_end", "role_label", "header_inline", "header_para_idx"]
    for i, item in enumerate(parsed):
        for field in required_fields:
            if field not in item:
                raise ValueError(
                    f"骨架第 {i} 条缺少字段 '{field}': {json.dumps(item, ensure_ascii=False)[:200]}"
                )
        # 校验 type 值
        if item["type"] not in ("normal", "break", "ignore"):
            raise ValueError(
                f"骨架第 {i} 条 type='{item['type']}' 不合法,应为 normal/break/ignore"
            )

    return parsed


def _parse_skeleton_json(raw: str) -> List[dict]:
    """解析 LLM 返回的骨架 JSON 数组,去除 markdown code fences 等包装。"""
    text = raw.strip()

    # 去掉 markdown code fences
    if text.startswith("```"):
        lines = text.splitlines()
        if lines and lines[0].startswith("```"):
            lines = lines[1:]
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        text = "\n".join(lines).strip()

    try:
        result = json.loads(text)
    except json.JSONDecodeError as e:
        raise ValueError(
            f"LLM 骨架 JSON 解析失败: {e}\n"
            f"原始响应前 500 字符: {raw[:500]}\n"
            f"原始响应后 200 字符: {raw[-200:]}"
        )

    if not isinstance(result, list):
        raise ValueError(
            f"LLM 返回不是 JSON 数组, 类型为 {type(result).__name__}"
        )

    if len(result) == 0:
        raise ValueError("LLM 返回空骨架数组")

    return result


# ====================================================================
# 2b. 全覆盖硬校验
# ====================================================================


def validate_skeleton_coverage(
    skeleton: List[Dict[str, Any]],
    total_paras: int,
) -> Tuple[bool, str]:
    """
    校验骨架的 [para_start, para_end] 区间是否恰好覆盖 title 之后
    的每个段落一次(无缺口/无重叠/无越界)。

    注意: 第0段(title)不纳入覆盖范围。

    Args:
        skeleton: LLM 返回的骨架列表
        total_paras: 总段落数(含 title)

    Returns:
        (passed: bool, message: str)
    """
    if total_paras <= 1:
        return True, ""

    covered = set()
    expected_start = 1  # title 后第一个段落下标
    expected_end = total_paras - 1

    errors = []

    for i, seg in enumerate(skeleton):
        ps = seg.get("para_start")
        pe = seg.get("para_end")

        if ps is None or pe is None:
            errors.append(f"骨架第 {i} 条缺少 para_start/para_end")
            continue

        if not isinstance(ps, int) or not isinstance(pe, int):
            errors.append(
                f"骨架第 {i} 条 para_start/para_end 非整数: ps={ps}, pe={pe}"
            )
            continue

        if ps > pe:
            errors.append(
                f"骨架第 {i} 条 para_start({ps}) > para_end({pe})"
            )
            continue

        if ps < expected_start:
            errors.append(
                f"骨架第 {i} 条 para_start({ps}) < 预期最小值 {expected_start}(越界到 title 区)"
            )
            continue

        if pe > expected_end:
            errors.append(
                f"骨架第 {i} 条 para_end({pe}) > 预期最大值 {expected_end}(越界)"
            )
            continue

        # 检查重叠
        seg_range = set(range(ps, pe + 1))
        overlap = seg_range & covered
        if overlap:
            errors.append(
                f"骨架第 {i} 条 [{ps},{pe}] 与已覆盖下标重叠: {sorted(overlap)}"
            )
        covered |= seg_range

    # 检查缺口
    all_expected = set(range(expected_start, expected_end + 1))
    missing = all_expected - covered
    if missing:
        errors.append(
            f"段落下标缺口(missing): {sorted(missing)[:20]}"
            + ("..." if len(missing) > 20 else "")
        )

    # 检查多余
    extra = covered - all_expected
    if extra:
        errors.append(
            f"段落下标多余(extra): {sorted(extra)[:20]}"
            + ("..." if len(extra) > 20 else "")
        )

    if errors:
        return False, "; ".join(errors)
    else:
        return True, ""


# ====================================================================
# 3. 骨架落盘 + 预览
# ====================================================================


def save_skeleton(
    skeleton: List[Dict[str, Any]],
    paras: List[Dict[str, Any]],
    episode_id: str,
    output_dir: str,
) -> Path:
    """
    将骨架写入 programs/<episode_id>/<episode_id>_a_skeleton.json
    同时为每条 segment 附加正文前20字预览(仅用于人工核验,不入 JSON 机器读字段)。

    Returns: skeleton 文件路径
    """
    out_dir = Path(output_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    # 构建保存用的骨架(不含正文预览,纯机器可读)
    skeleton_path = out_dir / f"{episode_id}_a_skeleton.json"
    skeleton_path.write_text(
        json.dumps(skeleton, ensure_ascii=False, indent=2),
        encoding="utf-8",
    )
    return skeleton_path


def print_skeleton_table(
    skeleton: List[Dict[str, Any]],
    paras: List[Dict[str, Any]],
    title: str = "",
) -> None:
    """
    打印人类可读预览表:
    seq | type | role_label | 正文前20字
    用于制片人肉眼核验: role_label 是否含真人姓名? ignore 是否漏/多?
    """
    print(f"\n{'=' * 70}")
    print(f"  {title} 骨架预览表")
    print(f"{'=' * 70}")
    print(f"{'seq':>4} | {'type':<7} | {'role_label':<35} | 正文前20字")
    print(f"{'-' * 4}-+-{'-' * 7}-+-{'-' * 35}-+-{'-' * 20}")

    for seq_idx, seg in enumerate(skeleton, start=1):
        seg_type = seg.get("type", "?")
        role_label = seg.get("role_label", "")
        ps = seg.get("para_start", -1)
        pe = seg.get("para_end", -1)
        header_inline = seg.get("header_inline", False)

        # 取该段实际正文的前20字(非段头文字)
        preview = ""
        if 0 <= ps < len(paras):
            if seg_type == "break":
                # break 段无正文,显示子标题原文
                preview = paras[ps]["text"][:20]
            elif seg_type == "ignore":
                preview = paras[ps]["text"][:20]
            elif header_inline:
                # 冒号式: para_start 段落含"角色：正文",切掉角色部分
                full_text = paras[ps]["text"]
                # 找第一个中文/英文冒号
                colon_match = re.search(r"[：:]", full_text)
                if colon_match:
                    preview = full_text[colon_match.end():].strip()[:20]
                else:
                    preview = full_text[:20]
            else:
                # 独立段头: 段头占 ps,正文从 ps+1 开始
                body_start = ps + 1
                if body_start < len(paras) and body_start <= pe:
                    preview = paras[body_start]["text"][:20]
                else:
                    # 无正文段(如段头后无内容)
                    preview = "(无正文)"

        print(
            f"{seq_idx:>4} | {seg_type:<7} | {role_label:<35} | {preview}"
        )

    print(f"{'=' * 70}")
    print("[WARN] 请人工核验: role_label是否含真人姓名? ignore是否漏/多? type是否正确?")
    print()


# ====================================================================
# 4. run_skeleton — Step 1-3 编排
# ====================================================================


def run_skeleton(
    episode_id: str,
    a_script_path: str,
    output_dir: Optional[str] = None,
    max_tokens: int = 16000,
) -> Dict[str, Any]:
    """
    doco skeleton 命令主流程:
      1. extract_a_paragraphs
      2. extract_skeleton_llm
      3. validate_skeleton_coverage (全覆盖硬校验)
      4. save_skeleton + print_skeleton_table

    Returns 统计 dict: {skeleton_path, total_paras, skeleton_count, coverage_ok}
    """
    if output_dir is None:
        out_dir = Path("programs") / episode_id
    else:
        out_dir = Path(output_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    # Step 1: 段落提取
    print(f"[skeleton] 读取 A 稿: {a_script_path}")
    paras = extract_a_paragraphs(str(a_script_path))
    print(f"[skeleton] 段落数: {len(paras)} (含标题)")

    if not paras:
        raise ValueError(f"A稿无有效文本: {a_script_path}")

    title = paras[0]["text"]
    print(f"[skeleton] 标题: {title}")

    # Step 2: LLM 骨架
    print(f"[skeleton] 调用 LLM 提取分段骨架 (thinking=True, max_tokens={max_tokens})...")
    skeleton = extract_skeleton_llm(paras, max_tokens=max_tokens)
    print(f"[skeleton] LLM 返回骨架段数: {len(skeleton)}")

    # Step 3: 全覆盖校验
    coverage_ok, coverage_msg = validate_skeleton_coverage(skeleton, len(paras))
    if not coverage_ok:
        print(f"[skeleton] [FAIL] 全覆盖校验失败: {coverage_msg}", file=sys.stderr)
        # 仍打印预览表,方便人工排查
        print_skeleton_table(skeleton, paras, title=f"{episode_id} (校验失败)")
        raise ValueError(
            f"骨架全覆盖校验失败: {coverage_msg}\n"
            f"可能原因: LLM 数错下标/JSON 被截断/段落编号理解偏差。\n"
            f"建议: 调大 max_tokens(当前={max_tokens})后重试,或检查 prompt。"
        )
    print(f"[skeleton] [PASS] 全覆盖校验通过")

    # Step 4: 落盘(原始骨架) + 预览(合并去ignore 后的最终 skeleton)
    skeleton_path = save_skeleton(skeleton, paras, episode_id, str(out_dir))
    print(f"[skeleton] 骨架已保存: {skeleton_path}")

    # 合并展示(预览表展示 compose 真正会用的那份)
    skeleton_display = _merge_skeleton_segments(skeleton)
    if len(skeleton_display) != len(skeleton):
        print(
            f"[skeleton] 预览(合并后): {len(skeleton)} → {len(skeleton_display)} "
            f"段 ({len(skeleton) - len(skeleton_display)} 条同角色合并/丢弃 ignore)"
        )
    print_skeleton_table(skeleton_display, paras, title=episode_id)

    return {
        "skeleton_path": str(skeleton_path),
        "total_paras": len(paras),
        "skeleton_count": len(skeleton),
        "coverage_ok": coverage_ok,
    }


# ====================================================================
# 4b. 合并被 ignore 隔断的同角色段
# ====================================================================


def _merge_skeleton_segments(
    skeleton: List[Dict[str, Any]],
) -> List[Dict[str, Any]]:
    """
    后处理: 若两个相邻 normal 段 role_label 完全相同,
    且它们之间只隔着 ignore 段(无其它 normal/break),合并为一段。
    
    目的: 消除主持人话被运镜提示(（出枪柜）（换枪）)切断后产生的重复段头。
    
    Args:
        skeleton: LLM 产出的原始骨架列表
    
    Returns:
        合并后的骨架(ignore 已丢弃,相邻同角色 normal 已合并)
    """
    if not skeleton:
        return []
    
    merged: List[Dict[str, Any]] = []
    pending_normal: Optional[Dict[str, Any]] = None  # 待合并的 normal 段
    
    for seg in skeleton:
        seg_type = seg.get("type", "normal")
        
        if seg_type == "normal":
            if pending_normal is not None and pending_normal["role_label"] == seg.get("role_label", ""):
                # 同角色: 扩展 para 区间,保留第一个段头的 header 信息
                pending_normal["para_end"] = max(pending_normal["para_end"], seg.get("para_end", pending_normal["para_end"]))
            else:
                # 不同角色或首个 normal: 先 flush 前一个,再缓存当前
                if pending_normal is not None:
                    merged.append(pending_normal)
                # 浅拷贝一份避免修改原始 skeleton
                pending_normal = dict(seg)
        elif seg_type == "ignore":
            # 跳过,不打断 pending 链(就是这些 ignore 造成了切断)
            continue
        else:
            # break 或其它类型: flush pending,然后直接加入
            if pending_normal is not None:
                merged.append(pending_normal)
                pending_normal = None
            merged.append(seg)
    
    # 收尾
    if pending_normal is not None:
        merged.append(pending_normal)
    
    return merged


# ====================================================================
# 5. 解析 A 稿分段(改读 skeleton 替代正则)
# ====================================================================


def parse_a_segments(docx_path: str) -> dict:
    """
    读 A 稿 docx,返回 {
        "title": str,
        "segments": [
            {"seg_id":int, "type":"normal"|"break",
             "header":str, "body":str}
        ]
    }

    优先读 <stem>_a_skeleton.json (LLM 骨架),
    若不存在且首段匹配旧正则格式(ep001),走旧正则逻辑 fallback。
    否则报错。
    """
    p = Path(docx_path)
    if not p.exists():
        raise FileNotFoundError(f"A稿 docx 不存在: {docx_path}")

    # 推断 skeleton 路径
    # docx 在 programs/<episode_id>/xxx.docx
    # skeleton 在 programs/<episode_id>/<episode_id>_a_skeleton.json
    episode_dir = p.parent
    episode_id = episode_dir.name
    skeleton_path = episode_dir / f"{episode_id}_a_skeleton.json"

    if skeleton_path.exists():
        return _parse_a_segments_from_skeleton(str(p), str(skeleton_path))
    else:
        # Fallback: 检查是否为旧格式(ep001)
        doc = Document(str(p))
        paras = [para.text.strip() for para in doc.paragraphs if para.text.strip()]
        if paras and SEG_HEADER_PATTERN.match(paras[1] if len(paras) > 1 else ""):
            print(
                f"[fusion_align] 未找到 skeleton.json,检测到旧版【】格式,走正则解析"
            )
            return _parse_a_segments_regex(str(p), paras)
        else:
            raise FileNotFoundError(
                f"未找到分段骨架文件: {skeleton_path}\n"
                f"且 A 稿不匹配旧版【】段头格式。\n"
                f"请先运行: doco skeleton --episode-id {episode_id} --a-script {docx_path}"
            )


def _parse_a_segments_from_skeleton(
    docx_path: str, skeleton_path: str
) -> dict:
    """
    从 skeleton JSON 解析分段:
    - 按 para_start/para_end 从 docx 原样抽取正文
    - header_inline=true: 按第一个中文冒号切除段头,余下为 body
    - header_inline=false: role_label 为 header,后续段落为 body
    - type=ignore: 丢弃,不进 segments
    - type=break: 单独产出 segment
    """
    # 读 skeleton
    skeleton_raw = json.loads(Path(skeleton_path).read_text(encoding="utf-8"))

    # 合并被 ignore 隔断的同角色段（如主持人话被（出枪柜）（换枪）切断）
    skeleton = _merge_skeleton_segments(skeleton_raw)
    if len(skeleton) != len(skeleton_raw):
        print(
            f"[fusion_align] skeleton 合并: {len(skeleton_raw)} → {len(skeleton)} 段 "
            f"(合并了 {len(skeleton_raw) - len(skeleton)} 条同角色拆分+丢弃 ignore)"
        )

    # 读 docx 段落(按原下标)
    doc = Document(docx_path)
    all_paras = [para.text.strip() for para in doc.paragraphs if para.text.strip()]

    if not all_paras:
        raise ValueError(f"A稿 docx 无有效文本: {docx_path}")

    title = all_paras[0]

    # 为 skeleton 建立原段落下标到实际文本的映射
    # 注意: all_paras[0] = title, 所以 skeleton 的 para_start/para_end 对应 all_paras 的下标

    segments = []
    seg_id_counter = 0

    for seg in skeleton:
        seg_type = seg.get("type", "normal")

        if seg_type == "ignore":
            continue  # 丢弃镜头标记等

        ps = seg.get("para_start", -1)
        pe = seg.get("para_end", -1)
        role_label = seg.get("role_label", "")
        header_inline = seg.get("header_inline", False)

        if ps < 0 or pe < 0 or ps >= len(all_paras):
            print(
                f"[fusion_align] 警告: 骨架段 para_start/para_end 越界,跳过: {seg}",
                file=sys.stderr,
            )
            continue

        if seg_type == "break":
            # 子标题: header=role_label(原文), body 为空
            header_text = all_paras[ps] if ps < len(all_paras) else role_label
            segments.append(
                {
                    "seg_id": seg_id_counter,
                    "type": "break",
                    "header": role_label,
                    "body": "",
                }
            )
            seg_id_counter += 1
            continue

        # normal 段
        if header_inline:
            # 段头与正文同段: 按第一个中文冒号切分
            full_text = all_paras[ps] if ps < len(all_paras) else ""
            header_text, body_text = _split_inline_header(full_text)
            # header 用 role_label(规范化后), body 用切除后的
            body_parts = [body_text] if body_text else []
            # 如果 para_end > para_start,后续段落全是 body
            for extra_idx in range(ps + 1, pe + 1):
                if extra_idx < len(all_paras):
                    body_parts.append(all_paras[extra_idx])
        else:
            # 独立段头: header 段落自身是 role_label, body 从下一段开始
            header_text = role_label
            body_parts = []
            for body_idx in range(ps + 1, pe + 1):
                if body_idx < len(all_paras):
                    body_parts.append(all_paras[body_idx])

        segments.append(
            {
                "seg_id": seg_id_counter,
                "type": "normal",
                "header": role_label,
                "body": "\n".join(body_parts),
            }
        )
        seg_id_counter += 1

    return {"title": title, "segments": segments}


def _split_inline_header(text: str) -> Tuple[str, str]:
    """
    按第一个中文冒号切分段头和正文。
    如 "演播室主持人1：各位观众大家好" → ("演播室主持人1", "各位观众大家好")
    如果没有冒号,全文本当 body。
    """
    match = INLINE_HEADER_SEP.search(text)
    if match:
        header_part = text[: match.start()].strip()
        body_part = text[match.end() :].strip()
        return header_part, body_part
    else:
        return "", text


def _parse_a_segments_regex(docx_path: str, paras: List[str]) -> dict:
    """
    旧正则逻辑(ep001 及兼容): ^【.+?】$ 段头 + ^隔断：【】识别。
    保留不动。
    """
    title = paras[0]

    segments = []
    current_header = None
    current_body_parts = []
    seg_id_counter = 0

    for para_text in paras[1:]:
        break_match = SEG_BREAK_PATTERN.match(para_text)
        if break_match:
            if current_header is not None:
                segments.append(
                    {
                        "seg_id": seg_id_counter,
                        "type": "normal",
                        "header": current_header,
                        "body": "\n".join(current_body_parts),
                    }
                )
                seg_id_counter += 1

            break_title = break_match.group(1)
            segments.append(
                {
                    "seg_id": seg_id_counter,
                    "type": "break",
                    "header": break_title,
                    "body": "",
                }
            )
            seg_id_counter += 1

            current_header = None
            current_body_parts = []
            continue

        if SEG_HEADER_PATTERN.match(para_text):
            if current_header is not None:
                segments.append(
                    {
                        "seg_id": seg_id_counter,
                        "type": "normal",
                        "header": current_header,
                        "body": "\n".join(current_body_parts),
                    }
                )
                seg_id_counter += 1

            current_header = para_text
            current_body_parts = []
        else:
            if current_header is not None:
                current_body_parts.append(para_text)

    if current_header is not None:
        segments.append(
            {
                "seg_id": seg_id_counter,
                "type": "normal",
                "header": current_header,
                "body": "\n".join(current_body_parts),
            }
        )

    return {"title": title, "segments": segments}


# ====================================================================
# 1b. 提取 normal 段（供对齐使用）
# ====================================================================


def _get_normal_segments(segments: List[dict]) -> List[dict]:
    """从全局 segments 中筛出 type=="normal" 的段,按 seg_id 排序。"""
    normals = [seg for seg in segments if seg.get("type", "normal") == "normal"]
    normals.sort(key=lambda s: s["seg_id"])
    return normals


def _build_normal_seg_id_map(normal_segs: List[dict]) -> Dict[int, int]:
    """
    建立映射: global_seg_id → normal_seg_id (0-based continuous)
    反向映射: normal_seg_id → global_seg_id
    """
    g2n = {}
    n2g = {}
    for nid, seg in enumerate(normal_segs):
        g2n[seg["seg_id"]] = nid
        n2g[nid] = seg["seg_id"]
    return g2n, n2g


# ====================================================================
# 2. 构造分段对齐 Prompt
# ====================================================================

SYSTEM_PROMPT_ALIGN = """你是《军事科技》专题片分段对齐员。给你 A稿完整分段正文 和 融合B稿碎句(屏幕字幕,带行号,按播出时间排列)。
B句是播出字幕的逐句拆分,A稿是同一内容的书面稿,两者高度同源——请直接按"这句话的内容出现在A稿哪一段正文里"来归段。
规则:seg_id 随行号单调不减;边界以语义为准(如主持人开场白归主持人段,旁白归解说段)。
只返回JSON数组: [{"line_no":int,"seg_id":int,"confidence":0~1}]"""


def build_align_prompt(
    batch_b: List[dict],
    normal_segments: List[dict],
    min_normal_seg_id: int,
) -> List[dict]:
    """
    构造 messages:
    (a) 完整 normal 段清单: 每段 "seg_id | header | A稿完整正文"
    (b) 本批 B 句: 每句 "[全局行号] 文本"
    (c) 下界约束: 传入上一批最后一行归到的 normal_seg_id,本批不得小于它
    """
    seg_lines = []
    for idx, seg in enumerate(normal_segments):
        full_body = seg["body"].replace("\n", " ")
        seg_lines.append(
            f"seg_id={idx} | {seg['header']} | {full_body}"
        )
    seg_list_str = "\n".join(seg_lines)

    b_lines_str = "\n".join(
        f"[{bl['idx']}] {bl['text']}" for bl in batch_b
    )

    constraint_note = ""
    if min_normal_seg_id > 0:
        constraint_note = (
            f"\n\n【重要约束】上一批最后一行归到了 seg_id={min_normal_seg_id}，"
            f"本批所有行的 seg_id 必须 >= {min_normal_seg_id}，不得回退。"
        )

    user_content = (
        f"A稿分段清单（共 {len(normal_segments)} 段）：\n\n"
        f"{seg_list_str}\n\n"
        f"--- 本批 B 稿碎句（共 {len(batch_b)} 行）---\n\n"
        f"{b_lines_str}"
        f"{constraint_note}"
    )

    messages = [
        {"role": "system", "content": SYSTEM_PROMPT_ALIGN},
        {"role": "user", "content": user_content},
    ]
    return messages


# ====================================================================
# 3. 单批对齐
# ====================================================================


def align_batch(
    batch_b: List[dict],
    normal_segments: List[dict],
    min_normal_seg_id: int,
    no_ai: bool = False,
    total_b_lines: int = 743,
) -> List[dict]:
    """
    no_ai=True: 按全局行号比例均分到 normal 段(仅供验证管道)
    no_ai=False: 调 LLM(thinking=True) 返回 JSON
    返回 [{"line_no": int, "seg_id": int, "confidence": float}]
    **注意**: 返回的 seg_id 是 normal_segments 体系(0-based 连续编号)
    """
    if no_ai:
        n_segs = len(normal_segments)
        records = []
        for bl in batch_b:
            seg_idx = min(
                int((bl["idx"] - 1) / total_b_lines * n_segs), n_segs - 1
            )
            records.append(
                {"line_no": bl["idx"], "seg_id": seg_idx, "confidence": 0.5}
            )
        return records

    messages = build_align_prompt(batch_b, normal_segments, min_normal_seg_id)

    try:
        raw_response = chat(
            messages,
            thinking=True,
            max_tokens=4096,
            temperature=0.0,
        )
        parsed = _parse_align_json(raw_response, len(batch_b))
    except Exception as e:
        print(
            f"[fusion_align] LLM 调用失败,回退: {e}",
            file=sys.stderr,
        )
        records = []
        for bl in batch_b:
            records.append(
                {
                    "line_no": bl["idx"],
                    "seg_id": min_normal_seg_id,
                    "confidence": 0.3,
                }
            )
        return records

    records = []
    for item in parsed:
        records.append(
            {
                "line_no": int(item.get("line_no", 0)),
                "seg_id": int(item.get("seg_id", min_normal_seg_id)),
                "confidence": float(item.get("confidence", 0.5)),
            }
        )

    return records


def _parse_align_json(raw: str, expected_len: int) -> List[dict]:
    """解析 LLM 返回的 JSON 数组,去除 markdown code fences 等包装。"""
    text = raw.strip()

    if text.startswith("```"):
        lines = text.splitlines()
        if lines and lines[0].startswith("```"):
            lines = lines[1:]
        if lines and lines[-1].strip() == "```":
            lines = lines[:-1]
        text = "\n".join(lines).strip()

    try:
        result = json.loads(text)
    except json.JSONDecodeError as e:
        raise ValueError(
            f"LLM 返回 JSON 解析失败: {e}\n"
            f"原始响应前 500 字符: {raw[:500]}"
        )

    if not isinstance(result, list):
        raise ValueError(
            f"LLM 返回不是 JSON 数组, 类型为 {type(result).__name__}"
        )

    if expected_len > 0 and len(result) != expected_len:
        raise ValueError(
            f"LLM 返回 {len(result)} 条记录, 期望 {expected_len} 条"
        )

    return result


# ====================================================================
# 4. 单调修正
# ====================================================================


def enforce_monotonic(
    records: List[dict],
    min_seg_id: int = 0,
) -> List[dict]:
    """
    确保 seg_id 随 line_no 单调不减。
    若出现回退(seg_id < 前值), 强制改回前值 + confidence 降到 0.3。
    返回修正日志列表 [{line_no, original_seg_id, forced_seg_id}]。
    """
    audit_log = []
    prev_seg_id = min_seg_id

    for rec in records:
        if rec["seg_id"] < prev_seg_id:
            audit_log.append(
                {
                    "line_no": rec["line_no"],
                    "original_seg_id": rec["seg_id"],
                    "forced_seg_id": prev_seg_id,
                }
            )
            rec["seg_id"] = prev_seg_id
            rec["confidence"] = 0.3
        else:
            prev_seg_id = rec["seg_id"]

    return audit_log


# ====================================================================
# 5. 分段对齐主函数
# ====================================================================


def align_lines_to_segments(
    b_lines: List[dict],
    segments: List[dict],
    no_ai: bool = False,
    batch_size: int = 40,
    cache_dir: Optional[Path] = None,
) -> Tuple[List[dict], List[dict], List[dict]]:
    """
    分批送 LLM 对齐(或 no_ai 均分),只对齐 normal 段。
    返回:
      (alignment_records, audit_logs, normal_segments)
    """
    normal_segs = _get_normal_segments(segments)
    if not normal_segs:
        raise ValueError("normal segments 为空,无法对齐")

    if cache_dir is None:
        cache_dir = Path(".c4_cache")
    cache_dir.mkdir(parents=True, exist_ok=True)

    if no_ai:
        n_segs = len(normal_segs)
        total = len(b_lines)
        records = []
        for bl in b_lines:
            seg_idx = min(
                int((bl["idx"] - 1) / total * n_segs), n_segs - 1
            )
            records.append(
                {"line_no": bl["idx"], "seg_id": seg_idx, "confidence": 0.5}
            )
        return records, [], normal_segs

    all_records = []
    all_audit = []
    total_batches = (len(b_lines) + batch_size - 1) // batch_size
    last_normal_seg_id = 0

    for batch_idx in range(total_batches):
        start = batch_idx * batch_size
        end = min(start + batch_size, len(b_lines))
        batch_b = b_lines[start:end]

        cache_path = cache_dir / f"batch_{batch_idx}.json"

        if cache_path.exists():
            try:
                cached = json.loads(cache_path.read_text(encoding="utf-8"))
                print(
                    f"[fusion_align] 复用缓存 batch_{batch_idx} ({len(cached)} 条)"
                )
                all_records.extend(cached)
                if cached:
                    last_normal_seg_id = max(rec["seg_id"] for rec in cached)
                continue
            except Exception as e:
                print(
                    f"[fusion_align] 缓存 batch_{batch_idx} 损坏,重新计算: {e}",
                    file=sys.stderr,
                )

        print(
            f"[fusion_align] 对齐 batch {batch_idx + 1}/{total_batches} "
            f"(行 {start + 1}-{end})..."
        )

        batch_records = align_batch(
            batch_b, normal_segs, last_normal_seg_id, no_ai=False
        )

        audit = enforce_monotonic(batch_records, min_seg_id=last_normal_seg_id)
        if audit:
            all_audit.extend(audit)
            print(
                f"[fusion_align] batch {batch_idx + 1} 单调修正 {len(audit)} 行"
            )

        if batch_records:
            last_normal_seg_id = max(rec["seg_id"] for rec in batch_records)

        cache_path.write_text(
            json.dumps(batch_records, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )
        all_records.extend(batch_records)

    return all_records, all_audit, normal_segs


# ====================================================================
# 5b. Speaker-aware alignment (v2)
# ====================================================================


def _check_has_speaker_data(asr_raw_path: Path) -> bool:
    """检查 ASR raw JSON 是否含说话人分离数据(rl!=0)。"""
    if not asr_raw_path.exists():
        return False
    try:
        from .asr_adapter import parse_order_result_with_speaker
        raw = asr_raw_path.read_text(encoding="utf-8")
        entries = parse_order_result_with_speaker(raw)
        return any(rl != 0 for _, _, _, rl in entries)
    except Exception:
        return False


def _annotate_b_lines_with_speakers(
    b_lines: List[dict], asr_raw_path: Path
) -> None:
    """按时间区间匹配，给每行 B稿挂上 speaker_id。

    优先：B稿时间戳落在 ASR 句子的 [bg, ed] 区间内 → 用该句的说话人。
    退回：无区间命中时，按最近 bg 距离匹配。
    """
    from .asr_adapter import parse_order_result_with_speaker
    raw = asr_raw_path.read_text(encoding="utf-8")
    asr_entries = parse_order_result_with_speaker(raw)
    # (start_ms, end_ms, speaker_id)
    asr_ranges = [(int(bg), int(ed), int(rl)) for bg, ed, _, rl in asr_entries]

    for bl in b_lines:
        ts_ms = bl["ts_sec"] * 1000

        # 优先：区间匹配
        matched = False
        for bg, ed, rl in asr_ranges:
            if bg <= ts_ms <= ed:
                bl["speaker_id"] = rl
                matched = True
                break

        if not matched:
            # 退回：最近距离
            best_spk = 0
            best_dist = float("inf")
            for bg, ed, rl in asr_ranges:
                dist = abs(bg - ts_ms)
                if dist < best_dist:
                    best_dist = dist
                    best_spk = rl
            bl["speaker_id"] = best_spk


def _detect_speaker_blocks(b_lines: List[dict]) -> List[dict]:
    """将连续同说话人的 B稿行分组为 block。"""
    blocks: List[dict] = []
    current_spk = None
    current_lines: List[dict] = []

    for bl in b_lines:
        spk = bl.get("speaker_id", 0)
        if spk != current_spk and current_lines:
            blocks.append({
                "block_id": len(blocks),
                "speaker_id": current_spk,
                "lines": list(current_lines),
            })
            current_lines = []
        current_spk = spk
        current_lines.append(bl)

    if current_lines:
        blocks.append({
            "block_id": len(blocks),
            "speaker_id": current_spk,
            "lines": list(current_lines),
        })

    return blocks


SYSTEM_PROMPT_SPEAKER_ALIGN = """你是《军事科技》专题片分段对齐员。给你 A稿分段骨架（编导录制前写的分段结构）和播出版按说话人自动分组的段落块。

A稿是编导在录制前写的脚本，播出版可能有以下差异：
- 段落顺序调整（如解说和专家段互换位置）
- 隔断段（如"街头采访"）在A稿只是一个标题，但播出中有多人实际说话
- 播出中新增了A稿没有的内容
- A稿有的段播出中删掉了

你的任务：为每个播出版段落块(block)分配段落标签。**关键：一个block可能跨越多个A稿段落（尤其是同一配音员念不同类型内容，如解说→三维动画解说→解说），此时必须拆分。**

规则：
1. 说话人切换是分段的天然边界。
2. 多个不同说话人的短段落如果聚在一起（如街采多人发言），应统一标为一个街采段，给它们分配同一个seg_id（对应A稿的隔断段），segment_label统一为【街采N】。
3. 同一说话人在不同时段出现，通常属于不同分段（如主持人出现多次是不同的主持人段）。
4. 隔断段如果播出中有对应内容，它就应该接收block。
5. **segment_label必须使用A稿骨架中的原始标签**，包括修饰词。如果A稿骨架写的是【三维动画解说1】就必须用【三维动画解说1】，**绝不简化为【解说N】**。同理【演播室主持人N】不简化为【主持人N】。
6. **大block拆分**：对于标记了"(需拆分)"的大block，会提供逐行明细（行号+时间戳+开头文字）。你必须结合A稿骨架判断该block实际覆盖了哪几个A稿段落，然后输出多条记录，每条用 start_line 和 end_line 指定行范围（block内部从0开始的下标，闭区间）。例如一个92行block实际是"解说1(0-30行)+三维动画解说1(31-60行)+解说2(61-91行)"，就输出3条。
7. **小block**（没有逐行明细的）：直接分配一个段落，不需要 start_line/end_line。

输出JSON数组，每条格式：
{"block_id":int, "seg_id":int, "segment_label":"【...】", "confidence":0.0~1.0, "start_line":int或null, "end_line":int或null}

其中 start_line/end_line 只在拆分大block时才需要填（block内部行号，从0开始，闭区间）。小block不填或填null。"""


def _align_speaker_blocks(
    blocks: List[dict],
    all_segments: List[dict],
    cache_dir: Optional[Path] = None,
) -> List[dict]:
    """一次 LLM 调用，为每个说话人 block 分配段落。"""

    seg_lines = []
    for seg in all_segments:
        seg_type = seg.get("type", "normal")
        type_tag = " [隔断]" if seg_type == "break" else ""
        body = seg.get("body", "")
        body_preview = body[:200].replace("\n", " ") if body else "(无正文)"
        seg_lines.append(
            f"seg_{seg['seg_id']} | {seg['header']}{type_tag} | {body_preview}"
        )

    LARGE_BLOCK_THRESHOLD = 40  # 超过此行数的block提供逐行明细

    block_lines = []
    for block in blocks:
        spk = block["speaker_id"]
        n = len(block["lines"])
        first_ts = block["lines"][0]["ts_sec"]
        last_ts = block["lines"][-1]["ts_sec"]
        ts_start = f"{first_ts // 60}m{first_ts % 60}s"
        ts_end = f"{last_ts // 60}m{last_ts % 60}s"

        texts = [bl["text"] for bl in block["lines"]]
        is_large = n > LARGE_BLOCK_THRESHOLD

        if is_large:
            # 大 block: 提供逐行明细, 让 LLM 识别内部分段边界
            header = (
                f"block_{block['block_id']} | 说话人{spk} | "
                f"{ts_start}-{ts_end} | {n}句 (需拆分)"
            )
            detail_lines = []
            for idx, bl in enumerate(block["lines"]):
                t = bl["ts_sec"]
                ts_fmt = f"{t // 60}m{t % 60}s"
                detail_lines.append(f"    行{idx}: [{ts_fmt}] {bl['text'][:50]}")
            block_lines.append(header + "\n" + "\n".join(detail_lines))
        else:
            if len(texts) <= 3:
                preview = " / ".join(t[:60] for t in texts)
            else:
                preview = f"{texts[0][:60]} / ... / {texts[-1][:60]}"
            block_lines.append(
                f"block_{block['block_id']} | 说话人{spk} | "
                f"{ts_start}-{ts_end} | {n}句 | {preview[:200]}"
            )

    user_content = (
        f"A稿分段骨架（共 {len(all_segments)} 段）：\n\n"
        + "\n".join(seg_lines)
        + f"\n\n--- 播出版说话人段落（共 {len(blocks)} 块，按播出时间排列）---\n\n"
        + "\n".join(block_lines)
        + "\n\n请为每个 block 分配 seg_id 和 segment_label。"
    )

    messages = [
        {"role": "system", "content": SYSTEM_PROMPT_SPEAKER_ALIGN},
        {"role": "user", "content": user_content},
    ]

    if cache_dir:
        cache_dir.mkdir(parents=True, exist_ok=True)
        cache_path = cache_dir / "speaker_align.json"
        if cache_path.exists():
            try:
                cached = json.loads(cache_path.read_text(encoding="utf-8"))
                # 缓存条数 >= blocks 数即合法（大 block 拆分后条数更多）
                cached_block_ids = {a.get("block_id") for a in cached}
                if all(b["block_id"] in cached_block_ids for b in blocks):
                    print(
                        f"[fusion_align] 复用说话人分段缓存 ({len(cached)} 条)"
                    )
                    return cached
            except Exception:
                pass

    print(f"[fusion_align] 调用 LLM 说话人分段对齐 ({len(blocks)} 块)...")
    raw_response = chat(
        messages,
        thinking=True,
        max_tokens=12000,
        temperature=0.0,
    )

    # 允许条数 >= blocks（大 block 拆分后多条）
    parsed = _parse_align_json(raw_response, -1)

    if cache_dir:
        cache_path = cache_dir / "speaker_align.json"
        cache_path.write_text(
            json.dumps(parsed, ensure_ascii=False, indent=2),
            encoding="utf-8",
        )

    return parsed


def _build_broadcast_segments(
    blocks: List[dict],
    block_assignments: List[dict],
) -> List[dict]:
    """
    从 block 赋值结果构建播出序分段。
    支持大 block 拆分（一个 block 多条 assignment，按 start_line/end_line 切行）。
    相邻同 label 的片段合并为一段。
    """
    # 按 block_id 分组 assignment，保持顺序
    from collections import defaultdict
    assignments_by_block: Dict[int, List[dict]] = defaultdict(list)
    for a in block_assignments:
        assignments_by_block[a["block_id"]].append(a)

    # 展开：每个 block 可能产出多个 (label, seg_id, lines) 片段
    expanded: List[Tuple[str, int, List[str]]] = []

    for block in blocks:
        bid = block["block_id"]
        assigns = assignments_by_block.get(bid, [])
        block_line_texts = [bl["text"] for bl in block["lines"]]

        if len(assigns) == 1 and assigns[0].get("start_line") is None:
            # 简单赋值，整个 block 一个标签
            label = assigns[0].get("segment_label", "【未知】")
            seg_id = assigns[0].get("seg_id", -1)
            expanded.append((label, seg_id, block_line_texts))
        elif len(assigns) > 1:
            # 大 block 拆分：按 start_line/end_line 切行
            # 排序确保按行号顺序
            sorted_assigns = sorted(
                assigns,
                key=lambda x: x.get("start_line", 0) or 0,
            )
            for sa in sorted_assigns:
                sl = sa.get("start_line", 0) or 0
                el = sa.get("end_line", len(block_line_texts) - 1)
                if el is None:
                    el = len(block_line_texts) - 1
                label = sa.get("segment_label", "【未知】")
                seg_id = sa.get("seg_id", -1)
                lines_slice = block_line_texts[sl:el + 1]
                if lines_slice:
                    expanded.append((label, seg_id, lines_slice))
        else:
            # fallback: 单条有 start_line
            for sa in assigns:
                sl = sa.get("start_line", 0) or 0
                el = sa.get("end_line", len(block_line_texts) - 1)
                if el is None:
                    el = len(block_line_texts) - 1
                label = sa.get("segment_label", "【未知】")
                seg_id = sa.get("seg_id", -1)
                lines_slice = block_line_texts[sl:el + 1]
                if lines_slice:
                    expanded.append((label, seg_id, lines_slice))

    # 合并相邻同 label 片段
    result: List[dict] = []
    current_label = None
    current_seg_id = None
    current_lines: List[str] = []

    for label, seg_id, lines in expanded:
        if label != current_label:
            if current_lines:
                result.append({
                    "header": current_label,
                    "type": "normal",
                    "body_lines": list(current_lines),
                    "a_seg_id": current_seg_id,
                })
            current_label = label
            current_seg_id = seg_id
            current_lines = list(lines)
        else:
            current_lines.extend(lines)

    if current_lines:
        result.append({
            "header": current_label,
            "type": "normal",
            "body_lines": list(current_lines),
            "a_seg_id": current_seg_id,
        })

    return result


def _compose_with_speaker(
    b_lines: List[dict],
    segments: List[dict],
    title: str,
    a_path: Path,
    out_dir: Path,
    asr_raw_path: Path,
    no_ai: bool = False,
) -> dict:
    """说话人分段模式的完整 compose 流程。"""

    print("[fusion_align] === 说话人分段模式 (v2) ===")

    _annotate_b_lines_with_speakers(b_lines, asr_raw_path)
    blocks = _detect_speaker_blocks(b_lines)

    print(f"[fusion_align] 说话人段落块: {len(blocks)} 块")
    for block in blocks:
        spk = block["speaker_id"]
        n = len(block["lines"])
        first_ts = block["lines"][0]["ts_sec"]
        ts_str = f"{first_ts // 60}m{first_ts % 60}s"
        preview = block["lines"][0]["text"][:40]
        print(
            f"  block_{block['block_id']:2d} | spk={spk} | "
            f"{ts_str} | {n:3d}句 | {preview}"
        )

    cache_dir = out_dir / ".c4_cache_spk"
    block_assignments = _align_speaker_blocks(blocks, segments, cache_dir)

    for a in block_assignments:
        bid = a.get("block_id", "?")
        sid = a.get("seg_id", "?")
        label = a.get("segment_label", "?")
        conf = a.get("confidence", 0)
        print(f"  block_{bid} -> seg_{sid} | {label} | conf={conf:.2f}")

    broadcast_segs = _build_broadcast_segments(blocks, block_assignments)
    print(f"[fusion_align] 播出序分段: {len(broadcast_segs)} 段")
    for i, bseg in enumerate(broadcast_segs):
        n = len(bseg["body_lines"])
        print(f"  [{i:2d}] {bseg['header']} | {n} 行")

    ref_body_map = {seg["seg_id"]: seg.get("body", "") for seg in segments}

    seg_texts: List[str] = []
    punct_results: Dict[int, bool] = {}
    for i, bseg in enumerate(broadcast_segs):
        bare_text = compose_segment_text(bseg["body_lines"])

        if no_ai or not bare_text.strip():
            seg_texts.append(bare_text)
            punct_results[i] = True
            continue

        ref_body = ref_body_map.get(bseg.get("a_seg_id", -1), "")
        punct_text, punct_ok = punctuate_segment(
            bare_text, ref_body,
            cache_dir=cache_dir,
            seg_id=i,
        )
        seg_texts.append(punct_text)
        punct_results[i] = punct_ok

    docx_path = out_dir / f"{a_path.stem}_融合A稿.docx"
    render_docx(title, broadcast_segs, seg_texts, str(docx_path))
    print(f"[fusion_align] 融合A稿: {docx_path}")

    csv_path = out_dir / "c4_alignment.csv"
    rows = ["seg_idx,header,line_count,punct_ok"]
    for i, bseg in enumerate(broadcast_segs):
        header = bseg["header"].replace('"', '""')
        n = len(bseg["body_lines"])
        pok = punct_results.get(i, True)
        rows.append(f'{i},"{header}",{n},{pok}')
    csv_path.write_text("\n".join(rows) + "\n", encoding="utf-8")
    print(f"[fusion_align] 留痕 CSV: {csv_path}")

    punct_failed = sum(1 for v in punct_results.values() if not v)
    stats = {
        "total_lines": sum(len(s["body_lines"]) for s in broadcast_segs),
        "segment_count": len(broadcast_segs),
        "punct_failed_segs": punct_failed,
        "docx_path": str(docx_path),
        "csv_path": str(csv_path),
    }

    print(f"\n[fusion_align] === 统计 (v2) ===")
    print(f"  总行数: {stats['total_lines']}")
    print(f"  播出序段数: {stats['segment_count']}")
    print(f"  标点回退段数: {stats['punct_failed_segs']}")
    for i, bseg in enumerate(broadcast_segs):
        n = len(bseg["body_lines"])
        pflag = " [标点回退]" if not punct_results.get(i, True) else ""
        print(f"    [{i:2d}] {bseg['header']}: {n} 行{pflag}")

    return stats


# ====================================================================
# 6. 硬校验
# ====================================================================


def hard_validate(
    records: List[dict],
    b_line_count: int,
    seg_count: int,
) -> None:
    """硬校验,任一不过 raise ValueError,不写出半成品文件。"""
    total = len(records)
    if total != b_line_count:
        raise ValueError(
            f"对齐结果行数 {total} != B稿行数 {b_line_count}"
        )

    line_nos = [rec["line_no"] for rec in records]
    expected = list(range(1, b_line_count + 1))
    if line_nos != expected:
        missing = set(expected) - set(line_nos)
        extra = set(line_nos) - set(expected)
        msg_parts = []
        if missing:
            msg_parts.append(f"缺失行号: {sorted(missing)[:15]}")
        if extra:
            msg_parts.append(f"多余行号: {sorted(extra)[:15]}")
        raise ValueError(f"line_no 不连续: {'; '.join(msg_parts)}")

    prev_seg = -1
    for rec in records:
        if rec["seg_id"] < prev_seg:
            raise ValueError(
                f"行 {rec['line_no']} seg_id={rec['seg_id']} ＜ 前值 "
                f"{prev_seg},单调性被破坏"
            )
        prev_seg = rec["seg_id"]

    max_seg = seg_count - 1
    for rec in records:
        sid = rec["seg_id"]
        if sid < 0 or sid > max_seg:
            raise ValueError(
                f"行 {rec['line_no']} seg_id={sid} 越界 [0, {max_seg}]"
            )


# ====================================================================
# 7. 正文拼接（纯规则，零改字）
# ====================================================================


def compose_segment_text(seg_lines: List[str]) -> str:
    """
    逐句顺接,零改字规则:
    - 行尾若有标点(。！？；…!?;)则保留,否则补"，"
    - 整段最后一句末尾的"，"换成"。"
    - 空列表返回 ""
    """
    if not seg_lines:
        return ""

    parts = []
    for text in seg_lines:
        t = text.strip()
        if not t:
            continue
        if t[-1] in SENTENCE_END_PUNCT:
            parts.append(t)
        else:
            parts.append(t + "，")

    if not parts:
        return ""

    last = parts[-1]
    if last.endswith("，"):
        last = last[:-1] + "。"
    elif last[-1] not in "。！？…!?":
        last = last + "。"
    parts[-1] = last

    return "".join(parts)


# ====================================================================
# 7b. 标点恢复（AI 加标点，硬校验守死）
# ====================================================================


PUNCTUATE_SYSTEM_PROMPT = """你是文稿标点校订员。给你一段【无标点的电视字幕文本】和一段【仅供参考的书面稿】。
你的唯一任务:在【字幕文本】相邻字符之间插入中文标点(。，、；：？！""''《》()…),让它符合阅读习惯。

铁律(违反即作废):
1. 只能插入标点。禁止增加任何汉字——包括"那么""此外""相比之下""防不胜防"这类连接词或成语,一个字都不能加。
2. 禁止删除任何汉字。禁止替换任何字("的/地/得"不能互换,"专门用于"不能改成"专司")。
3. 即使字幕读起来不通顺、有重复、缺字、有错别字,也原样保留每一个字,只在字之间加标点。
4. 参照稿只帮你判断在哪断句、哪里用顿号,绝不照抄它的字词。
5. 正常使用句号断句。一个完整陈述句结束时用句号"。",不要把所有断句都用逗号","连接。问句用问号"？",感叹用感叹号"！"。逗号只用于句内停顿、并列成分之间。

错误示范:
  ❌ 它的重量仅有3.6千克,非常便于携带,该枪采用了短行程活塞导气式自动方式,这种设计……
  ✅ 它的重量仅有3.6千克,非常便于携带。该枪采用了短行程活塞导气式自动方式,这种设计……

只返回加好标点的字幕文本,不要解释。"""


def strip_punct(text: str) -> str:
    """剔除 Unicode 标点 + 空白字符。用于硬校验。"""
    result = []
    for ch in text:
        cat = unicodedata.category(ch)
        if cat.startswith("P"):
            continue
        if ch.isspace():
            continue
        result.append(ch)
    return "".join(result)


def punctuate_segment(
    bare_text: str,
    ref_body: str,
    cache_dir: Optional[Path] = None,
    seg_id: int = -1,
) -> Tuple[str, bool]:
    """
    调 LLM 为 bare_text 添加标点符号。
    返回 (文本, punct_ok)
    """
    if not bare_text.strip():
        return bare_text, True

    if cache_dir is not None and seg_id >= 0:
        cache_path = cache_dir / f"punct_seg_{seg_id}.json"
        if cache_path.exists():
            try:
                cached = json.loads(cache_path.read_text(encoding="utf-8"))
                if cached.get("bare_text") == bare_text:
                    print(f"[fusion_align] 复用标点缓存 seg_{seg_id}")
                    return cached.get("punct_text", bare_text), cached.get(
                        "punct_ok", False
                    )
            except Exception as e:
                print(
                    f"[fusion_align] 标点缓存 seg_{seg_id} 损坏: {e}",
                    file=sys.stderr,
                )

    user_content = f"【书面参照稿】\n{ref_body}\n\n【无标点字幕文本】\n{bare_text}"

    messages = [
        {"role": "system", "content": PUNCTUATE_SYSTEM_PROMPT},
        {"role": "user", "content": user_content},
    ]

    last_punct_text = bare_text
    for attempt in range(3):
        try:
            raw_response = chat(
                messages,
                thinking=False,
                max_tokens=3000,
                temperature=0.0,
            )
            punct_text = raw_response.strip()

            if strip_punct(punct_text) == strip_punct(bare_text):
                if cache_dir is not None and seg_id >= 0:
                    cache_path = cache_dir / f"punct_seg_{seg_id}.json"
                    cache_path.write_text(
                        json.dumps(
                            {
                                "bare_text": bare_text,
                                "punct_text": punct_text,
                                "punct_ok": True,
                            },
                            ensure_ascii=False,
                            indent=2,
                        ),
                        encoding="utf-8",
                    )
                if attempt > 0:
                    print(
                        f"[fusion_align] seg_{seg_id} 标点恢复第 {attempt + 1} 次重试通过",
                        file=sys.stderr,
                    )
                return punct_text, True
            else:
                last_punct_text = punct_text
                print(
                    f"[fusion_align] seg_{seg_id} 标点恢复第 {attempt + 1} 次尝试失败(模型改字),"
                    + (f" 将重试..." if attempt < 2 else f" 已达上限,回退"),
                    file=sys.stderr,
                )
        except Exception as e:
            print(
                f"[fusion_align] seg_{seg_id} 标点 LLM 调用异常(第 {attempt + 1} 次): {e}",
                file=sys.stderr,
            )

    if cache_dir is not None and seg_id >= 0:
        cache_path = cache_dir / f"punct_seg_{seg_id}.json"
        cache_path.write_text(
            json.dumps(
                {
                    "bare_text": bare_text,
                    "punct_text": bare_text,
                    "punct_ok": False,
                },
                ensure_ascii=False,
                indent=2,
            ),
            encoding="utf-8",
        )
    return bare_text, False


# ====================================================================
# 8. 出 docx（GB/T 9704 公文格式）
# ====================================================================


def _set_run_font(run, font_name: str, size_pt: float, bold: bool = False):
    run.font.name = font_name
    run.font.size = Pt(size_pt)
    run.bold = bold

    rPr = run._element.get_or_add_rPr()
    rFonts = rPr.find(qn("w:rFonts"))
    if rFonts is None:
        rFonts = OxmlElement("w:rFonts")
        rPr.insert(0, rFonts)
    rFonts.set(qn("w:ascii"), font_name)
    rFonts.set(qn("w:hAnsi"), font_name)
    rFonts.set(qn("w:eastAsia"), font_name)


def _set_line_spacing(paragraph, ratio: float = 1.25):
    pPr = paragraph._element.get_or_add_pPr()
    spacing = pPr.find(qn("w:spacing"))
    if spacing is None:
        spacing = OxmlElement("w:spacing")
        pPr.append(spacing)
    spacing.set(qn("w:line"), str(int(ratio * 240)))
    spacing.set(qn("w:lineRule"), "auto")


def _set_first_line_indent(
    paragraph, chars: float = 2.0, font_size_pt: float = 14.0
):
    pPr = paragraph._element.get_or_add_pPr()
    ind = pPr.find(qn("w:ind"))
    if ind is None:
        ind = OxmlElement("w:ind")
        pPr.append(ind)
    indent_twips = int(chars * font_size_pt * 20)
    ind.set(qn("w:firstLine"), str(indent_twips))


def render_docx(
    title: str,
    segments: List[dict],
    seg_texts: List[str],
    out_path: str,
) -> None:
    """输出公文格式 docx (GB/T 9704)。"""
    doc = Document()

    section = doc.sections[0]
    section.page_width = Cm(21.0)
    section.page_height = Cm(29.7)
    section.top_margin = Cm(3.7)
    section.bottom_margin = Cm(3.5)
    section.left_margin = Cm(2.8)
    section.right_margin = Cm(2.6)

    title_para = doc.add_paragraph()
    title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
    _set_run_font(title_para.add_run(title), TITLE_FONT_PRIMARY, 22, bold=False)

    for seg, seg_text in zip(segments, seg_texts):
        if seg.get("type") == "break":
            h_para = doc.add_paragraph()
            h_para.alignment = WD_ALIGN_PARAGRAPH.CENTER
            _set_run_font(
                h_para.add_run(seg["header"]),
                BREAK_HEADER_FONT,
                15,
                bold=True,
            )
            _set_line_spacing(h_para, 1.25)
        else:
            h_para = doc.add_paragraph()
            _set_run_font(h_para.add_run(seg["header"]), HEADER_FONT, 16)
            _set_line_spacing(h_para, 1.25)

            body_text = seg_text if seg_text else EMPTY_SEG_PLACEHOLDER
            b_para = doc.add_paragraph()
            _set_run_font(b_para.add_run(body_text), BODY_FONT, 14)
            _set_line_spacing(b_para, 1.25)
            _set_first_line_indent(b_para, 2.0, 14.0)

    doc.save(out_path)


# ====================================================================
# 9. 写留痕 CSV (c4_alignment.csv)
# ====================================================================


def write_alignment_csv(
    csv_path: str,
    segments: List[dict],
    alignment: List[dict],
    audit_logs: List[dict],
    punct_results: Dict[int, bool],
) -> None:
    """写 c4_alignment.csv。"""
    normal_segs = _get_normal_segments(segments)
    g2n, n2g = _build_normal_seg_id_map(normal_segs)

    seg_data: Dict[int, dict] = {}
    for rec in alignment:
        normal_sid = rec["seg_id"]
        global_sid = n2g.get(normal_sid, normal_sid)
        if global_sid not in seg_data:
            seg_data[global_sid] = {"lines": [], "confidences": []}
        seg_data[global_sid]["lines"].append(rec["line_no"])
        seg_data[global_sid]["confidences"].append(rec["confidence"])

    forced_lines = {a["line_no"] for a in audit_logs}

    rows = [
        "seg_id,header,start_line,end_line,line_count,min_confidence,punct_ok,note"
    ]

    for seg in segments:
        sid = seg["seg_id"]

        if seg.get("type") == "break":
            note = "隔断"
            rows.append(
                f'{sid},"{seg["header"]}",,,0,0.0000,,"{note}"'
            )
            continue

        sd = seg_data.get(sid, {"lines": [], "confidences": []})

        lines = sorted(sd["lines"])
        if lines:
            start_line = lines[0]
            end_line = lines[-1]
            line_count = len(lines)
            min_conf = min(sd["confidences"])
        else:
            start_line = ""
            end_line = ""
            line_count = 0
            min_conf = 1.0

        punct_ok = punct_results.get(sid, True)

        notes = []
        if line_count == 0:
            notes.append("空段:无对应字幕")
        if min_conf < 0.6 and line_count > 0:
            notes.append(f"低把握(min_confidence={min_conf:.2f})")
        if not punct_ok:
            notes.append("标点恢复失败已回退,需人工")

        forced_in_seg = [ln for ln in lines if ln in forced_lines]
        if forced_in_seg:
            preview = forced_in_seg[:10]
            suffix = "..." if len(forced_in_seg) > 10 else ""
            notes.append(
                f"单调强制修正行: {preview}{suffix}"
            )

        note = "; ".join(notes)

        header_escaped = seg["header"].replace('"', '""')
        note_escaped = note.replace('"', '""')

        rows.append(
            f'{sid},"{header_escaped}",{start_line},{end_line},'
            f'{line_count},{min_conf:.4f},{punct_ok},"{note_escaped}"'
        )

    Path(csv_path).write_text(
        "\n".join(rows) + "\n", encoding="utf-8"
    )


# ====================================================================
# 10. 主流程 (doco compose)
# ====================================================================


def run_compose(
    episode_id: str,
    output_dir: str,
    no_ai: bool = False,
    batch_size: int = 40,
) -> dict:
    """
    C4 主流程:
    1. 找 A 稿 docx + 检查 skeleton 先决条件
    2. parse_a_segments 解析分段(优先读 skeleton,fallback 正则)
    3. align_lines_to_segments 分批对齐 normal 段
    4. hard_validate 硬校验
    5. compose_segment_text 纯规则拼接正文
    6. punctuate_segment AI标点恢复 + 硬校验
    7. render_docx 出融合A稿.docx
    8. write_alignment_csv 保留痕

    返回统计 dict
    """
    out_dir = Path(output_dir)
    out_dir.mkdir(parents=True, exist_ok=True)

    # ---- 找 A 稿 docx ----
    # 只认原始 A 稿:排除本流程产出的"融合"稿与人工"批改"稿,
    # 否则重跑 compose 时 glob 可能把自己的输出当成输入 A 稿。
    a_candidates = [
        f
        for f in out_dir.glob("*.docx")
        if not f.name.startswith("~$")
        and "融合" not in f.name
        and "_批改" not in f.name
    ]
    if not a_candidates:
        raise FileNotFoundError(
            f"在 {out_dir} 中找不到 docx 文件\n"
            f"目录下的 docx 文件: {[d.name for d in out_dir.glob('*.docx')]}"
        )
    a_path = a_candidates[0]
    print(f"[fusion_align] A稿: {a_path}")

    # ---- 前置检查: skeleton ----
    skeleton_path = out_dir / f"{episode_id}_a_skeleton.json"
    if skeleton_path.exists():
        print(f"[fusion_align] 分段骨架: {skeleton_path}")
    else:
        # 检查是否 ep001 格式(旧正则能过)
        doc = Document(str(a_path))
        paras = [para.text.strip() for para in doc.paragraphs if para.text.strip()]
        first_non_empty = paras[1] if len(paras) > 1 else ""
        if SEG_HEADER_PATTERN.match(first_non_empty):
            print(
                "[fusion_align] 未找到 skeleton.json,检测到旧版【】格式,走正则 fallback"
            )
        else:
            raise FileNotFoundError(
                f"未找到分段骨架文件: {skeleton_path}\n"
                f"请先运行: doco skeleton --episode-id {episode_id} --a-script {a_path}"
            )

    # ---- 读 B 稿 ----
    b_path = out_dir / "融合B稿.txt"
    if not b_path.exists():
        raise FileNotFoundError(f"融合B稿.txt 不存在: {b_path}")
    b_lines = parse_timed_lines(b_path)
    print(f"[fusion_align] B稿: {len(b_lines)} 行")

    # ---- 解析 A 稿 ----
    a_data = parse_a_segments(str(a_path))
    title = a_data["title"]
    segments = a_data["segments"]
    print(f"[fusion_align] 标题: {title}")
    print(f"[fusion_align] 全局段数: {len(segments)}")
    for seg in segments:
        type_str = seg.get("type", "normal")
        print(
            f"  seg_id={seg['seg_id']} [{type_str}] | {seg['header']} "
            f"| body_len={len(seg['body'])}"
        )

    # ---- 检查说话人数据，有则走 v2 ----
    asr_raw_path = out_dir / "asr_result_raw.json"
    if _check_has_speaker_data(asr_raw_path):
        return _compose_with_speaker(
            b_lines, segments, title, a_path, out_dir, asr_raw_path, no_ai,
        )

    # ---- 对齐（只对 normal 段，旧路径） ----
    cache_dir = out_dir / ".c4_cache"
    alignment, audit_logs, normal_segs = align_lines_to_segments(
        b_lines,
        segments,
        no_ai=no_ai,
        batch_size=batch_size,
        cache_dir=cache_dir,
    )

    g2n, n2g = _build_normal_seg_id_map(normal_segs)

    # ---- 硬校验 (normal 段数) ----
    hard_validate(alignment, len(b_lines), len(normal_segs))
    print("[fusion_align] [OK] 硬校验通过")

    # ---- 拼接各段正文(裸文本) + 标点恢复 ----
    seg_b_texts: Dict[int, list] = {}
    for rec, bl in zip(alignment, b_lines):
        normal_sid = rec["seg_id"]
        global_sid = n2g.get(normal_sid, normal_sid)
        seg_b_texts.setdefault(global_sid, []).append(bl["text"])

    seg_texts = []
    punct_results: Dict[int, bool] = {}

    for seg in segments:
        sid = seg["seg_id"]

        if seg.get("type") == "break":
            seg_texts.append("")
            continue

        lines = seg_b_texts.get(sid, [])
        if not lines:
            seg_texts.append("")
            punct_results[sid] = True
            continue

        bare_text = compose_segment_text(lines)

        ref_body = seg["body"]
        punct_text, punct_ok = punctuate_segment(
            bare_text,
            ref_body,
            cache_dir=cache_dir,
            seg_id=sid,
        )
        seg_texts.append(punct_text)
        punct_results[sid] = punct_ok

    # ---- 出 docx ----
    # 出稿命名 = 原始 A 稿名 + "_融合A稿"(不覆盖原始定稿,且带本期日期/节目/编导信息)
    docx_path = out_dir / f"{a_path.stem}_融合A稿.docx"
    render_docx(title, segments, seg_texts, str(docx_path))
    print(f"[fusion_align] 融合A稿: {docx_path}")

    # ---- 出 CSV ----
    csv_path = out_dir / "c4_alignment.csv"
    write_alignment_csv(
        str(csv_path), segments, alignment, audit_logs, punct_results
    )
    print(f"[fusion_align] 留痕 CSV: {csv_path}")

    # ---- 统计 ----
    seg_line_counts: Dict[int, int] = {}
    for rec, bl in zip(alignment, b_lines):
        global_sid = n2g.get(rec["seg_id"], rec["seg_id"])
        seg_line_counts[global_sid] = seg_line_counts.get(global_sid, 0) + 1

    empty_segs = 0
    low_conf_segs = 0
    punct_failed_segs = 0
    for seg in segments:
        sid = seg["seg_id"]
        if seg.get("type") == "break":
            continue
        count = seg_line_counts.get(sid, 0)
        if count == 0:
            empty_segs += 1
        else:
            min_c = min(
                rec["confidence"]
                for rec, bl in zip(alignment, b_lines)
                if n2g.get(rec["seg_id"], rec["seg_id"]) == sid
            )
            if min_c < 0.6:
                low_conf_segs += 1
        if not punct_results.get(sid, True):
            punct_failed_segs += 1

    stats = {
        "total_lines": len(b_lines),
        "segment_count": len(segments),
        "seg_line_counts": seg_line_counts,
        "empty_segments": empty_segs,
        "low_confidence_segments": low_conf_segs,
        "audit_forced_lines": len(audit_logs),
        "punct_failed_segs": punct_failed_segs,
        "docx_path": str(docx_path),
        "csv_path": str(csv_path),
    }

    print(f"\n[fusion_align] === 统计 ===")
    print(f"  总行数: {stats['total_lines']}")
    print(f"  段数: {stats['segment_count']}")
    print(f"  空段数: {stats['empty_segments']}")
    print(f"  低把握段数: {stats['low_confidence_segments']}")
    print(f"  单调修正行数: {stats['audit_forced_lines']}")
    print(f"  标点回退段数: {stats['punct_failed_segs']}")
    print(f"  各段行数分布:")
    for seg in segments:
        sid = seg["seg_id"]
        if seg.get("type") == "break":
            print(f"    [{sid:2d}] [隔断] {seg['header']}")
            continue
        count = seg_line_counts.get(sid, 0)
        flag = ""
        if count == 0:
            flag = " [空段]"
        elif count > 0:
            min_c = min(
                (rec["confidence"] for rec, bl in zip(alignment, b_lines)
                 if n2g.get(rec["seg_id"], rec["seg_id"]) == sid),
                default=1.0,
            )
            if min_c < 0.6:
                flag = f" [低把握 min_c={min_c:.2f}]"
        punct_flag = " [标点回退]" if not punct_results.get(sid, True) else ""
        print(f"    [{sid:2d}] {seg['header']}: {count} 行{flag}{punct_flag}")

    return stats