#!/usr/bin/env python3
"""AGF 培训 deck · build_main.py · v0.1 骨架版

按 spec v2.3 §14.1 表 138 slide 一对一构建。

本版本（v0.1）：
- 完整 138 slide 骨架
- 封面 / CONTENTS / 节标题 / 章节轻分隔 / 休息过渡 / Thanks 真实实现
- 内容页 layout[3] stub 占位（仅 title + 大纲提示，待 build_sec_M*.py 填充）

下版本（v0.2+）：
- 拆 build_sec_M1-M9.py 分章节实现（worktree 并行友好）
- 嵌入 41 张 draw.io 图（docs/training/diagrams/png/）
- 嵌入 8 份样例素材摘录（docs/training/samples/postcard-feature/）

依赖：
- template/Template.pptx（iSlide 商务团队主题）
- .claude/skills/agf-writing-pptx-reports/template.py（_fix_ph_font / clear_template_slides）

用法：
    python3 docs/training/build_main.py
    # 产物：docs/training/agf-overview.pptx
"""

import sys
from pathlib import Path

ROOT = Path(__file__).resolve().parent.parent.parent
SKILL_DIR = ROOT / ".claude" / "skills" / "agf-writing-pptx-reports"
TEMPLATE_PATH = ROOT / "template" / "Template.pptx"
OUTPUT_PATH = ROOT / "docs" / "training" / "agf-overview.pptx"

# 引入 skill 的 template.py helpers
sys.path.insert(0, str(SKILL_DIR))
# 让 build_sec_M*.py 可被 import（与 build_main.py 同目录）
sys.path.insert(0, str(Path(__file__).resolve().parent))
import template as T  # noqa: E402

from pptx import Presentation  # noqa: E402
from pptx.util import Inches, Pt, Emu  # noqa: E402
from pptx.dml.color import RGBColor  # noqa: E402
from pptx.enum.text import PP_ALIGN, MSO_ANCHOR  # noqa: E402
from pptx.enum.shapes import MSO_SHAPE  # noqa: E402
from pptx.oxml.ns import qn  # noqa: E402
from lxml import etree  # noqa: E402


# ============================================================
# 设计 token（与 template-team-guide.md §4 一致 + diagram-generation-guide §3 对齐）
# ============================================================

CORAL        = RGBColor(0xF0, 0x69, 0x66)
CORAL_LIGHT  = RGBColor(0xFF, 0xE5, 0xE5)
NAVY         = RGBColor(0x0E, 0x35, 0x69)
BLUE_ACCENT  = RGBColor(0x3B, 0x82, 0xF6)
GOLD         = RGBColor(0xFF, 0xBF, 0x00)
GREEN        = RGBColor(0x2E, 0x8B, 0x57)
GRAY_900     = RGBColor(0x1A, 0x1A, 0x1A)
GRAY_700     = RGBColor(0x4A, 0x4A, 0x4A)
GRAY_500     = RGBColor(0x8C, 0x8C, 0x8C)
GRAY_300     = RGBColor(0xD9, 0xD9, 0xD9)
GRAY_50      = RGBColor(0xFA, 0xFA, 0xFA)
WHITE        = RGBColor(0xFF, 0xFF, 0xFF)

FONT_CN = "PingFang SC"
# Display 字体：60pt+ 大字号用 Heiti SC 避免 LibreOffice fallback 到楷体（PingFang SC
# 在 macOS 是 MobileAsset on-demand 字体，headless soffice 进程访问不到大字号变体）
FONT_CN_DISPLAY = "Heiti SC"

# 字号梯度规范（design token · 新代码统一从这里取，存量值保留 context-tuned 不动）：
FONT_SIZE_DECO = 96      # 装饰大数字（chapter_intro 主大字）
FONT_SIZE_HERO = 72      # Hero 大标题（Thanks / 大字 quote）
FONT_SIZE_H1 = 44        # H1 章节扉页主题字（如 CONTENTS）
FONT_SIZE_H2 = 28        # H2 内容页大字标题
FONT_SIZE_TITLE = 26     # 内容页 title placeholder 默认（_title helper）
FONT_SIZE_H3 = 18        # H3 小节标题
FONT_SIZE_BODY_LG = 16   # body 大（强调段落）
FONT_SIZE_BODY = 14      # body 标准（默认 bullet / 正文）
FONT_SIZE_BODY_SM = 12   # body 小（密集卡片正文）
FONT_SIZE_CAPTION = 11   # 注脚 / 副提示
FONT_SIZE_FOOTNOTE = 10  # 出处脚注 / 引用 URL
FONT_SIZE_TINY = 9       # 极小 caption（页脚 / 数据来源标签）

# 卡片色轮换（用于"中性列举"避免全同色单调，v0.3）
ROTATE_4 = [NAVY, CORAL, GOLD, GREEN]
ROTATE_5 = [NAVY, CORAL, GOLD, GREEN, BLUE_ACCENT]

def rotate4(i):
    """按索引轮换 4 主色（NAVY/CORAL/GOLD/GREEN）"""
    return ROTATE_4[i % 4]

def rotate5(i):
    """按索引轮换 5 色（NAVY/CORAL/GOLD/GREEN/BLUE_ACCENT）"""
    return ROTATE_5[i % 5]


# ============================================================
# 章节内容清单（按 spec §14.1 + §5 章节大纲）
# 每个 SEC_MN_PAGES 的第 1 项作为章节扉页（chapter_intro 或 section_header），
# 剩余项作为内容页 stub。
# ============================================================

# §0 开场 + 垫层 · slide 3-12（chapter_intro + 9 内容 = 10 页）
SEC_M1_PAGES = [
    ("封面页", "§0 开场垫层"),  # → chapter_intro
    ("6 小时你会得到什么", "4 个场景 + Git 上手 + Lab + 设计取舍 4 颗钩子（embedded G1）"),
    ("5 分钟自定位", "P1/P2/P3 各画像 · 必听 / 可跳页码表（embedded G2）"),
    ("软件交付 3 个老大难", "改方向贵 / 写完才测 / 多人协作上下文丢（embedded B1）"),
    ("Agile · 心法不是方法", "两周一交付 vs 一锤子三月（embedded B2）"),
    ("Scrum · 足球队类比", "角色 + 节奏 + 仪式（embedded B3）"),
    ("TDD · 先写测试 = 先想验收", "红→绿→重构 三阶段循环（embedded B4 / A5）"),
    ("AGF 一句话定位", "心法嵌进 enforcement 路径，不靠 agent 自觉"),
    ("今天主线：旅游明信片生成器", "故事钩子 + 三步流水线图（embedded A3）"),
    ("6h 时间线 + 你需要装什么", "Claude Code v2.1.130+ · Git · Python · 豆包 API key"),
]

# §2 Git/GitHub 基础 · slide 13-25（chapter_intro + 12 内容 = 13 页）
SEC_M2_PAGES = [
    ("封面页", "§1 Git/GitHub 基础"),
    ("Git 是什么", "类比：有时光机的文件夹（embedded B5）"),
    ("关键概念图", "工作区/暂存区/本地仓库/远程仓库 4 区（embedded A7）"),
    ("5 个最常用命令", "clone / status / add / commit / push 真实截图"),
    ("GitHub 是什么", "类比：git 仓库的网盘 + 协作平台"),
    ("看懂 commit history", "git log --oneline + 前缀约定（feat/fix/refactor/test/docs）"),
    ("TDD ↔ Git: commit 顺序", "test: 必须早于 feat:（embedded A8）"),
    ("分支 + PR", "feature 分支 → push → 开 PR → review → merge"),
    ("Worktree：dev 并行隔离", "git worktree add 让多 dev 不打架（embedded A9）"),
    ("pre-commit hook", "scan-commit.sh 防密钥泄漏（embedded A10）"),
    ("Tag：Release 版本快照", "MAJOR/MINOR 必打 + annotation 列 tagged-along commits"),
    ("GitHub Release / Issue / PR 全流程实战", "gh release + Issue 模板 + PR template（embedded A15 / A16）"),
    ("Lab 小练习（5 min）", "现场 git clone AGF + git log --oneline -20 找 TDD red commit"),
]

# §3 场景 A · 明信片 Happy Path · slide 43-59（section_header + 14 内容 = 15 页）
SEC_M3_PAGES = [
    ("封面页", "§2 节标题"),
    ("14 同事一览（卡片墙）", "14 同事 + 颜色 + tagline · 圈出明信片 8 人（embedded D1）"),
    ("Scrum 三角色映射", "PO+SM 合并 = product-lead 取舍说明（embedded C1）"),
    ("PL 调 brainstorming 澄清", "风格偏好 / 是否分享 / cost 预算（embedded A14）"),
    ("三步流水线架构图", "Vision → LLM → 文生图 全景（embedded A3）"),
    ("PL 写 PRD 10 节", "贴 PRD §1-§4 摘录 + 5 条 AC（引用 00-prd.md）"),
    ("派单 6 段 schema", "贴真实 SendMessage（embedded D2 / 引用 02-dispatch.md）"),
    ("PRD → Task 拆分铁律", "按角色边界切 FE/BE/AI/ML（embedded C5）"),
    ("TL 出 ADR：模型选型", "4 厂商 × 3 类 → 选豆包栈（引用 01-adr-model-selection.md）"),
    ("dev 并行：worktree 强制", "ai-agent / backend / frontend 三 worktree（embedded A9）"),
    ("ai-agent-dev 独门：prompt 是代码", "prompt versioned + 改 prompt 走 PR review"),
    ("ml-engineer 独门：模型选型矩阵", "双矩阵 4 厂商 + 豆包内部 fallback（embedded C2）"),
    ("progress/<role>.md 真实条目", "贴 ai-agent-dev progress 样例（引用 03-progress.md）"),
    ("dev 自跑 SIT + 证据落 progress", "5 道盲测 + Vision 准确率 ≥ 85%"),
    ("多模态输出三指标", "Vision 准确率 + LLM 相关性 + 文生图风格（embedded F1 雷达）"),
]

# §4 场景 B · 机制兜底 · slide 60-74（section_header + 12 内容 = 13 页）
SEC_M4_PAGES = [
    ("封面页", "§3 节标题"),
    ("类比：AI 同事 = 新员工", "三道防线（红线 / 流程 / 事后扫）（embedded B6）"),
    ("4 层 hook 全景", "硬阻断 + 软告警 + workflow + git pre-commit（embedded A4）"),
    ("Hook 1：危险命令拦截", "rm -rf / DROP TABLE / push --force / reset --hard 样例"),
    ("Hook 2：密钥泄漏拦截", "DeepSeek / Doubao / Qwen / MiniMax key + AWS/GitHub key"),
    ("Hook 3：prompt-injection 软告警", "Vision 场景 PII 风险（embedded A13）"),
    ("Hook 4：commit 前再扫 staged diff", "防 Edit/Write 绕过 prompt scanner"),
    ("Skill 强制 vs 推荐", "强制：TDD / SIT / multi-llm；推荐：debugging / verification"),
    ("agf-wiring-multi-llm-sdk 强制点", "OpenAI 兼容 adapter / env-var / fallback / cost guardrail"),
    ("DoR vs DoD 3 层结构", "PBI / Sprint / Release（embedded C4）"),
    ("progress hook 兜底", "dev idle 前没写 progress → check-progress-file.sh 阻断"),
    ("实战互动：找出违规命令", "5 个 hook 触发样例让学员判断哪一层会拦"),
    ("No Equivalent Bypass", "撞硬阻断不绕路，找 PL 授权"),
]

# §5 场景 C · 失败回路 + TDD · slide 76-90（section_header + 12 内容 = 13 页）
SEC_M5_PAGES = [
    ("封面页", "§4 节标题"),
    ("失败回路总览", "6 阶段门任一失败 → 回 S3，不跳级（embedded A2）"),
    ("code-reviewer 双 verdict", "代码 + SIT Audit 独立判定（embedded D3）"),
    ("code-reviewer 红线", "永远不动源码 / 只写 docs/reviews/（embedded C3）"),
    ("SIT Audit 4 项", "链路完整 / AC 覆盖 / 证据真实 / 反例边界（引用 04-review-report.md）"),
    ("dev 收 review skill", "不盲改 / 不 performative 同意 / 技术不同意 push back 带证据"),
    ("Push back 真实案例", "文生图风格评分 A/B 实验数据（embedded F2）"),
    ("TDD 救场：red→green→refactor", "Kent Beck Canon TDD（embedded A5）"),
    ("TDD 强制链路：三层兜底", "commit 顺序 + skill + DoD + reviewer 顺序检查（embedded A6）"),
    ("qa-engineer Verdict 决策树", "P0 / P1 / cosmetic 各自的决策（embedded D4）"),
    ("qa-engineer Verdict 默认 ", "没 curl / 截图 / DB diff 不升 Pass"),
    ("ai-agent-dev Plan Mode 6 触发", "prompt / 模型 / 链路 / token / 工具 / schema"),
    ("为什么 review/QA 不动源码", "设计取舍：避免既裁判又球员"),
]

# §6 场景 D · UAT + Release Retro · slide 69-79（section_header + 10 内容 = 11 页）
SEC_M6_PAGES = [
    ("封面页", "§5 节标题"),
    ("E2E：chrome-devtools-mcp + 真机", "自动化 + 人工抽测"),
    ("明信片 E2E：5 道盲测题", "业务 / qa 各出 5 张照片 → 命中率 ≥ 80%（embedded F3）"),
    ("UAT：业务 pass vs 技术 pass", "业务方关心 '分享出去不丢人'，不是 latency"),
    ("UAT 报告样例", "AC P0/P1 逐条 //+ 签字栏（引用 05-uat-report.md）"),
    ("归档：progress → docs/qa", "archive-progress.sh 真实命令（embedded A12）"),
    ("Release：MAJOR/MINOR 必打 tag", "同会话 + annotation 列 tagged-along commits"),
    ("CHANGELOG.md 规范 + tag 反模式", "Keep a Changelog 6 节 / SemVer 映射（embedded A16）"),
    ("/agf-release-retro 7 步", "7 步骨架 + Owner+Due 强制（embedded A11 / 引用 06-retro.md）"),
    ("为什么 PATCH 不做 retro", "避免为复盘而复盘"),
    ("Retro 真实样例", "贴一份脱敏 retro 摘录（引用 06-retro.md）"),
]

# §7 综合 Lab · slide 105-119（chapter_intro + 11 内容 = 12 页）
SEC_M7_PAGES = [
    ("封面页", "§7 综合 Lab"),
    ("准备清单", "Claude Code ≥ v2.1.130 + init-team.sh + pre-commit + Python + 豆包 API key"),
    ("第 1 步：clone + init-team.sh", "命令 + 预期输出"),
    ("第 2 步：3 道 AI 题选一", "旅游明信片 / 产品概念海报 / 运动打卡卡片"),
    ("第 3 步：/agf-team-start 启动", "Team Mode + dashboard 同步打开"),
    ("第 4 步：观察 PL 写 PRD + 派单", "dashboard 实时看"),
    ("第 5 步：跟 ai-agent-dev 跑 Vision 链路", "progress / Unit / SIT"),
    ("第 6 步：worktree 看并行", "ls ../my-feature-* 看两个 worktree"),
    ("第 7 步：code review + SIT Audit", "docs/reviews/ + 双 verdict"),
    ("第 8 步：E2E + UAT 签字", "完整闭环"),
    ("第 9 步：CHANGELOG + tag + Release + retro", "真跑一遍全套"),
    ("常见踩坑 Q&A", "8 高频踩坑 grid（embedded G4）"),
]

# §8 拓展 · slide 120-132（section_header + 9 内容 = 10 页）
SEC_M8_PAGES = [
    ("封面页", "§7 节标题"),
    ("设计取舍 1", "心法嵌 enforcement vs 写 wiki（学术参考 Springer 2024）"),
    ("设计取舍 2", "14 同事职责契约 vs 单 agent 全能"),
    ("设计取舍 3", "dev 自跑 SIT vs 独立 QA stage"),
    ("反模式对照表（7 条）", "错的做法 vs AGF 正确做法（embedded G5）"),
    ("迁移：保留 3 件事", "4 层 hook / DoD 9 项 / AC 摘录派单"),
    ("迁移：可替换 3 件事", "后端栈 / LLM 厂商 / 同事数量裁剪"),
    ("国产 LLM fallback 机制", "DeepSeek / Doubao / Qwen / MiniMax + cost guardrail"),
    ("多模态 / 微信小程序变体", "ml-engineer 多模态 + miniapp-dev 三件套"),
    ("Issue 自动化 + GitHub Actions", "claude-code.yml + auto-label + stale bot（embedded A17）"),
]

# §9 收尾 · slide 133-137（chapter_intro + 3 内容 = 4 页）
SEC_M9_PAGES = [
    ("封面页", "§9 收尾"),
    ("6 小时回顾", "4 场景 + Lab + Git + 拓展，回 §0 三个老大难"),
    ("资源地图", "README · FIRST_RUN · examples · CHANGELOG · GitHub Issues（embedded G6）"),
    ("学完后下一步（按画像）", "P1/P2/P3 各一条具体路径"),
]


# ============================================================
# Helper 函数
# ============================================================

def _set_run_font(run, *, name=FONT_CN, size_pt=14, bold=False, color=GRAY_900):
    """run 级写字体（含 a:ea + a:cs 显式设置，防 LibreOffice fallback）"""
    run.font.name = name
    run.font.size = Pt(size_pt)
    run.font.bold = bold
    run.font.color.rgb = color
    rPr = run._r.get_or_add_rPr()
    for tag in ("a:ea", "a:cs"):
        elem = rPr.find(qn(tag))
        if elem is None:
            elem = etree.SubElement(rPr, qn(tag))
        elem.set("typeface", name)


def _fill_ph(slide, idx, text, *, size_pt=18, color=GRAY_700, bold=False):
    """填 placeholder by idx + 修字体"""
    for ph in slide.placeholders:
        if ph.placeholder_format.idx == idx:
            ph.text = text
            T._fix_ph_font(ph, name=FONT_CN, size_pt=size_pt, bold=bold, color=color)
            return ph
    return None


def _add_textbox(slide, x, y, w, h, text, *, size_pt=14, color=GRAY_700,
                 bold=False, align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP,
                 font_name=None):
    """加 textbox + 写中文字体

    font_name: 默认 FONT_CN (PingFang SC)。display 场景（60pt+ 大字）建议
    传 FONT_CN_DISPLAY (Heiti SC) 避免 LibreOffice fallback 到楷体。
    """
    box = slide.shapes.add_textbox(x, y, w, h)
    tf = box.text_frame
    tf.margin_left = tf.margin_right = Emu(0)
    tf.margin_top = tf.margin_bottom = Emu(0)
    tf.word_wrap = True
    tf.vertical_anchor = anchor
    p = tf.paragraphs[0]
    p.alignment = align
    p.line_spacing = 1.45
    r = p.add_run()
    r.text = text
    _set_run_font(r, name=(font_name or FONT_CN),
                  size_pt=size_pt, bold=bold, color=color)
    return box


def _add_card(slide, x, y, w, h, *, fill=WHITE, border=GRAY_300, accent=None,
              round_radius=0.05):
    """圆角矩形卡片（可选左侧 accent 色条）"""
    card = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, x, y, w, h)
    card.fill.solid()
    card.fill.fore_color.rgb = fill
    card.line.color.rgb = border
    card.line.width = Pt(0.75)
    card.adjustments[0] = round_radius
    if accent:
        bar = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, x, y, Emu(36000), h)
        bar.fill.solid()
        bar.fill.fore_color.rgb = accent
        bar.line.fill.background()
    return card


# ============================================================
# Slide 构建函数
# ============================================================

def build_cover(prs):
    """slide 1 · 封面 · layout[0] 标题幻灯片
    v0.3: 主标题加大（40→60pt）+ 副标分级 + 加 CORAL 装饰横线 + 重排
    """
    s = prs.slides.add_slide(prs.slide_layouts[0])
    # 关掉所有 placeholder 的 line（封面专用 fix）
    for ph in s.placeholders:
        try:
            ph.line.fill.background()
        except Exception:
            pass
    # 主标题 60pt（原 40pt → 视觉权重提升）
    _fill_ph(s, 0, "AGF 内部培训", size_pt=60, bold=True, color=CORAL)
    # 副标题分两级：第 1 行 NAVY 加粗 / 第 2 行 GRAY 普通
    _fill_ph(s, 1,
             "团队协作 6h workshop\n旅游明信片实战 · 14 同事 · 41 张架构图",
             size_pt=20, color=NAVY, bold=True)
    _fill_ph(s, 10, "讲师：content-writer + product-lead", size_pt=14, color=GRAY_500)
    # 清空 placeholder 11（原 version 行被 TEAM 图遮挡）· 改用 textbox 放左下角避开 TEAM 图
    _fill_ph(s, 11, "", size_pt=1, color=WHITE)
    _add_textbox(s, Inches(0.55), Inches(7.05), Inches(8.5), Inches(0.4),
                 "2026-05-17 · v1.4.1（§1 AI 工具基础 12 张 + AI-Native 业界对标 10 项）",
                 size_pt=11, color=GRAY_500, align=PP_ALIGN.LEFT)
    # CORAL 装饰横线（在主标题下方）— layout[0] 的 placeholder 已经有 TEAM 装饰
    return s


def build_contents(prs, active_idx=0):
    """CONTENTS 目录页 · v1.2.3 加 active_idx 参数支持 chapter divider 复用
    布局：左半 "CONTENTS" 大字（边框框住）/ 右半 5 行（菱形数字 + 标题 + 说明 + 分隔线）
    active_idx: 0~4 当前激活行的索引（高亮 CORAL），其他行用 GRAY_700
    """
    from pptx.enum.shapes import MSO_SHAPE as _MS
    s = prs.slides.add_slide(prs.slide_layouts[4])

    # ===== 左半：CONTENTS 边框大字 =====
    # 外框（灰边线，无填充）
    box = s.shapes.add_shape(_MS.RECTANGLE,
                              Inches(0.85), Inches(2.6),
                              Inches(4.3), Inches(2.3))
    box.fill.background()  # 透明
    box.line.color.rgb = GRAY_500
    box.line.width = Pt(0.75)
    # CONTENTS 大字（44pt 单行装下 4.3 in 宽）
    _add_textbox(s, Inches(0.85), Inches(2.6), Inches(4.3), Inches(2.3),
                 "CONTENTS", size_pt=44, bold=True, color=GRAY_900,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    # 下方小字说明
    _add_textbox(s, Inches(0.85), Inches(5.05), Inches(4.3), Inches(0.7),
                 "AGF · 6.5 小时团队协作 workshop\n5 个章节 · 138 张 deck",
                 size_pt=14, color=GRAY_700, align=PP_ALIGN.CENTER)

    # ===== 右半：5 行菱形列表 =====
    items = [
        ("01", "基础垫层",   "§0 开场 + §1 AI 工具 + §2 Git · 3 章 38 张"),
        ("02", "场景剧本",   "§3 Happy Path · §4 机制兜底 · §5 失败回路 · §6 UAT + Retro"),
        ("03", "综合 Lab",   "§7 学员现场跑 AI feature 9 步流程（含 2 张 check-in）"),
        ("04", "拓展取舍",   "§8 设计取舍 + 迁移 + AI-Native 业界对标 · 4 件保留 / 3 件替换"),
        ("05", "收尾 Q&A",   "§9 6 小时回顾 + 资源地图 + 按画像下一步 + Thanks"),
    ]
    # active_idx 由参数传入；高亮该行（CORAL），其他行 GRAY_700
    list_x = Inches(6.0)
    diamond_size = Inches(0.85)

    for i, (num, title, desc) in enumerate(items):
        y_base_in = 0.85 + i * 1.25  # in
        y = Inches(y_base_in)

        # 菱形（旋转 45° 的方块）— 用 MSO_SHAPE.DIAMOND
        c = CORAL if i == active_idx else GRAY_700
        diamond = s.shapes.add_shape(_MS.DIAMOND,
                                      list_x, y + Inches(0.1),
                                      diamond_size, diamond_size)
        diamond.fill.solid(); diamond.fill.fore_color.rgb = c
        diamond.line.fill.background()
        # 菱形内数字（白色）
        _add_textbox(s, list_x, y + Inches(0.1), diamond_size, diamond_size,
                     num, size_pt=18, bold=True, color=WHITE,
                     align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)

        # 右侧标题（粗黑）
        text_x = list_x + Inches(1.15)
        text_w = Inches(13.333 - 6.0 - 1.15 - 0.3)  # 到 slide 右边距 0.3 in
        _add_textbox(s, text_x, y, text_w, Inches(0.5),
                     title, size_pt=20, bold=True, color=GRAY_900)
        # 说明文字（细灰）
        _add_textbox(s, text_x, y + Inches(0.5), text_w, Inches(0.5),
                     desc, size_pt=12, color=GRAY_700)

        # 分隔线（除最后一行）
        if i < len(items) - 1:
            line = s.shapes.add_shape(_MS.RECTANGLE,
                                       text_x, y + Inches(1.08),
                                       text_w, Inches(0.012))
            line.fill.solid(); line.fill.fore_color.rgb = GRAY_300
            line.line.fill.background()
    return s


SECTION_COLORS = {
    1: CORAL,         # /01 场景 A · Happy Path
    2: GOLD,          # /02 场景 B · 机制兜底
    3: GREEN,         # /03 场景 C · 失败回路 + TDD
    4: NAVY,          # /04 场景 D · UAT + Retro
    5: BLUE_ACCENT,   # /05 拓展 · 设计取舍 + 迁移
}


def build_section_header(prs, num, title, subtitle):
    """节标题 · v1.1.1: 改用 layout[4] 空白（避开 layout[2] 自带 TEAM 图全覆盖遮挡 bug）
    布局：左 2/3 = 大编号 + 标题 + 副标题；右下 1/4 = 章节色 mini 装饰块（仅占 5×3 in）
    """
    from pptx.enum.shapes import MSO_SHAPE as _MS
    s = prs.slides.add_slide(prs.slide_layouts[4])
    color = SECTION_COLORS.get(num, CORAL)
    # 章节 emoji 装饰（按 num 配对）
    chapter_emoji = {1: "", 2: "", 3: "🔁", 4: "", 5: "🧭"}.get(num, "✦")

    # 左 2/3 主体区（限宽 ≤ 8.5 in，确保不与右下装饰重叠）
    # 大 /0X 编号（左上，章节主色）
    _add_textbox(s, Inches(0.55), Inches(0.8), Inches(3.0), Inches(1.6),
                 f"/{num:02d}", size_pt=80, bold=True, color=color,
                 anchor=MSO_ANCHOR.MIDDLE)
    # 主标题（占左 2/3，限宽 8.3 in；字号 44→40 避免长 title 折行 orphan）
    # 40pt 大字用 Heiti SC 避免 PingFang SC fallback 到楷体
    _add_textbox(s, Inches(0.55), Inches(2.8), Inches(8.3), Inches(1.8),
                 title, size_pt=40, bold=True, color=NAVY,
                 anchor=MSO_ANCHOR.MIDDLE, font_name=FONT_CN_DISPLAY)
    # CORAL 装饰横线
    deco = s.shapes.add_shape(_MS.RECTANGLE,
                               Inches(0.55), Inches(4.85),
                               Inches(1.2), Inches(0.05))
    deco.fill.solid(); deco.fill.fore_color.rgb = color
    deco.line.fill.background()
    # 副标题（限宽 8.3 in，绝不延伸到右下装饰区）
    _add_textbox(s, Inches(0.55), Inches(5.15), Inches(8.3), Inches(1.6),
                 subtitle, size_pt=17, color=GRAY_700, anchor=MSO_ANCHOR.TOP)

    # 右下 1/4 装饰区：章节色圆角矩形 + emoji + "TEAM" 字
    # slide 13.333 × 7.5 → 右下从 (8.7, 4.0) ~ (12.7, 7.0)，约占 4 × 3 in
    deco_card = s.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE,
                                    Inches(8.7), Inches(4.0),
                                    Inches(4.0), Inches(3.0))
    deco_card.fill.solid(); deco_card.fill.fore_color.rgb = color
    deco_card.line.fill.background()
    deco_card.adjustments[0] = 0.08
    # 装饰区 emoji（大字）
    _add_textbox(s, Inches(8.7), Inches(4.2), Inches(4.0), Inches(1.8),
                 chapter_emoji, size_pt=90, color=WHITE,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    # 装饰区 "TEAM" 字
    _add_textbox(s, Inches(8.7), Inches(5.9), Inches(4.0), Inches(0.7),
                 "TEAM", size_pt=36, bold=True, color=WHITE,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    # 装饰区副字（章节编号）
    _add_textbox(s, Inches(8.7), Inches(6.5), Inches(4.0), Inches(0.4),
                 f"场景 {chr(ord('A')+num-1)}" if num <= 4 else "拓展",
                 size_pt=14, color=WHITE,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    return s


def build_chapter_intro(prs, num_label, title, subtitle):
    """章节轻分隔（§0 §1 §6 §8 用，不占 /01-/05 编号）· layout[4] 空白
    v0.3: 主字 80pt CORAL + 副标题 36pt NAVY + 装饰横线 + 副副标题 18pt
    """
    s = prs.slides.add_slide(prs.slide_layouts[4])
    # 主大字 · 96pt 用 Heiti SC 避免 PingFang SC 触发 fallback 到楷体
    _add_textbox(s, Inches(0.55), Inches(2.0), Inches(12.2), Inches(1.5),
                 num_label, size_pt=96, bold=True, color=CORAL,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
                 font_name=FONT_CN_DISPLAY)
    # 副标题（拉大字号差距）· 36pt 同样建议 Heiti SC（防 fallback 边界）
    _add_textbox(s, Inches(0.55), Inches(3.8), Inches(12.2), Inches(1.0),
                 title, size_pt=36, bold=True, color=NAVY,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
                 font_name=FONT_CN_DISPLAY)
    # CORAL 装饰横线
    from pptx.enum.shapes import MSO_SHAPE as _MS
    deco = s.shapes.add_shape(_MS.RECTANGLE,
                               Inches(6.16), Inches(4.85),
                               Inches(1.0), Inches(0.04))
    deco.fill.solid(); deco.fill.fore_color.rgb = CORAL
    deco.line.fill.background()
    # 副副标题
    _add_textbox(s, Inches(0.55), Inches(5.0), Inches(12.2), Inches(0.8),
                 subtitle, size_pt=18, color=GRAY_700,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    return s


BREAK_DIAGRAM_PATH = ROOT / "docs" / "training" / "diagrams" / "png"

def build_break_page(prs, emoji, label, duration, *, diagram=None):
    """休息过渡 · slide 26/55/80 · layout[4] 空白
    v0.3: 嵌入 drawio 矢量图（替 emoji 像素照片）· diagram 文件名（不含扩展）
    """
    s = prs.slides.add_slide(prs.slide_layouts[4])
    if diagram:
        img_path = BREAK_DIAGRAM_PATH / f"{diagram}.png"
        if img_path.exists():
            # 整张图占主视觉（13.33 × 7.5 slide，居中放 11 × 6.2 in）
            s.shapes.add_picture(str(img_path),
                                  Inches(1.0), Inches(0.4),
                                  Inches(11.33), Inches(6.6))
            return s
    # fallback: 旧 emoji 版（如果 diagram=None）
    _add_textbox(s, Inches(0.55), Inches(1.5), Inches(12.2), Inches(2.5),
                 emoji, size_pt=180, color=GOLD,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    _add_textbox(s, Inches(0.55), Inches(4.5), Inches(12.2), Inches(1.0),
                 label, size_pt=44, bold=True, color=NAVY,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE,
                 font_name=FONT_CN_DISPLAY)
    _add_textbox(s, Inches(0.55), Inches(5.8), Inches(12.2), Inches(0.8),
                 duration, size_pt=24, color=GRAY_500,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    return s


def build_content_stub(prs, sec, page_num, title, note):
    """内容页 stub · layout[3] 仅标题页 · v0.1 占位，待 build_sec 填充"""
    s = prs.slides.add_slide(prs.slide_layouts[3])
    _fill_ph(s, 0, f"{sec} · {title}", size_pt=28, bold=True, color=NAVY)
    _add_textbox(s, Inches(0.55), Inches(2.0), Inches(12.2), Inches(3.5),
                 f"📝 待 build_sec 填充\n\n本页大纲：{note}",
                 size_pt=18, color=GRAY_500, align=PP_ALIGN.LEFT)
    _add_textbox(s, Inches(11.0), Inches(7.0), Inches(2.0), Inches(0.4),
                 f"slide {page_num}",
                 size_pt=10, color=GRAY_300, align=PP_ALIGN.RIGHT)
    return s


# ============================================================
# v1.1 教学吸收 builder · 3 类页面模板
# ============================================================

def build_takeaway_page(prs, chapter_label, key_points, hint=""):
    """章末金句页（v1.1 · 7 张）· layout[4] 空白
    设计：左侧大标签 "本章你已学会" + 右侧 3 行金句卡片 + 底部行动 hint

    Args:
      chapter_label: 如 "§0 / 开场垫层"
      key_points: 3 个金句 list ["明信片如何串起 14 同事", ...]
      hint: 可选 1 行行动提示
    """
    from pptx.enum.shapes import MSO_SHAPE as _MS
    s = prs.slides.add_slide(prs.slide_layouts[4])
    # 左侧大标签（位于上 1/3）
    _add_textbox(s, Inches(0.55), Inches(0.6), Inches(8.0), Inches(0.6),
                 chapter_label, size_pt=22, color=GRAY_500)
    _add_textbox(s, Inches(0.55), Inches(1.1), Inches(12.2), Inches(1.4),
                 "本章你已学会 3 件事", size_pt=48, bold=True, color=NAVY,
                 align=PP_ALIGN.LEFT, anchor=MSO_ANCHOR.TOP)
    # 装饰横线
    deco = s.shapes.add_shape(_MS.RECTANGLE, Inches(0.55), Inches(2.5),
                               Inches(1.5), Inches(0.06))
    deco.fill.solid(); deco.fill.fore_color.rgb = CORAL
    deco.line.fill.background()
    # 3 行金句（轮换 4 主色）
    colors = [CORAL, GOLD, GREEN]
    for i, point in enumerate(key_points[:3]):
        y = Inches(3.0 + i * 1.1)
        c = colors[i]
        # 大数字 ① ② ③
        _add_textbox(s, Inches(0.55), y, Inches(1.0), Inches(0.9),
                     f"①②③"[i], size_pt=60, bold=True, color=c)
        # 金句
        _add_textbox(s, Inches(1.8), y + Inches(0.15), Inches(11.0), Inches(0.9),
                     point, size_pt=22, bold=True, color=NAVY, anchor=MSO_ANCHOR.MIDDLE)
    # 底部行动 hint
    if hint:
        _add_textbox(s, Inches(0.55), Inches(6.6), Inches(12.2), Inches(0.5),
                     f"{hint}", size_pt=15, color=CORAL, bold=True,
                     align=PP_ALIGN.CENTER)
    return s


def build_prediction_page(prs, chapter_label, question, options=None, reveal_hint=""):
    """章首预测页（v1.1 · 4 张）· layout[4] 空白
    设计：大字问题 + 3 选项 + "30 秒思考 + 邻座对答案" 互动提示

    Args:
      chapter_label: 如 "/01 场景 A · 明信片"
      question: 问题大字
      options: 3 个选项 list（可选）
      reveal_hint: 揭示提示如 "30 秒思考 → 邻座对一下 → 翻页揭示"
    """
    from pptx.enum.shapes import MSO_SHAPE as _MS
    s = prs.slides.add_slide(prs.slide_layouts[4])
    # 章节小标签
    _add_textbox(s, Inches(0.55), Inches(0.6), Inches(12.2), Inches(0.5),
                 chapter_label, size_pt=18, color=GRAY_500, align=PP_ALIGN.CENTER)
    # 大字 "现在请你猜"
    _add_textbox(s, Inches(0.55), Inches(1.2), Inches(12.2), Inches(0.7),
                 "现在请你猜：", size_pt=24, bold=True, color=CORAL,
                 align=PP_ALIGN.CENTER)
    # 问题大字（字号 40→34，让中文行更易单行装下）
    _add_textbox(s, Inches(0.55), Inches(2.1), Inches(12.2), Inches(1.7),
                 question, size_pt=34, bold=True, color=NAVY,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    # 3 选项（卡 4.0→4.1 in 宽，1.6→1.85 in 高，文字 15→14pt，避免单字 orphan）
    if options:
        opt_colors = [CORAL, GOLD, GREEN]
        for i, opt in enumerate(options[:3]):
            x = Inches(0.55 + i * 4.15)
            c = opt_colors[i]
            _add_card(s, x, Inches(4.0), Inches(4.05), Inches(1.85),
                      fill=WHITE, border=c, accent=c)
            _add_textbox(s, x + Inches(0.2), Inches(4.15), Inches(3.65), Inches(0.4),
                         f"选项 {chr(ord('A')+i)}", size_pt=16, bold=True, color=c)
            _add_textbox(s, x + Inches(0.2), Inches(4.6), Inches(3.65), Inches(1.15),
                         opt, size_pt=14, color=NAVY, anchor=MSO_ANCHOR.TOP)
    # 互动提示
    _add_textbox(s, Inches(0.55), Inches(6.0), Inches(12.2), Inches(0.5),
                 "⏱ 30 秒思考 · 邻座对一下你的答案 · 翻页揭示",
                 size_pt=16, color=GRAY_700, align=PP_ALIGN.CENTER, bold=True)
    if reveal_hint:
        _add_textbox(s, Inches(0.55), Inches(6.5), Inches(12.2), Inches(0.4),
                     reveal_hint, size_pt=13, color=GRAY_500, align=PP_ALIGN.CENTER)
    return s


def build_checkin_page(prs, step_done, total_steps, action_hint, fail_safe=""):
    """Lab check-in 页（v1.1 · 2 张）· layout[4] 空白
    设计：完成进度 + "举手投票" + 卡住救火建议
    """
    from pptx.enum.shapes import MSO_SHAPE as _MS
    s = prs.slides.add_slide(prs.slide_layouts[4])
    # 大字"check in"
    _add_textbox(s, Inches(0.55), Inches(0.6), Inches(12.2), Inches(0.7),
                 "Lab Check-in", size_pt=24, bold=True, color=CORAL,
                 align=PP_ALIGN.CENTER)
    # 进度条
    pct = int(step_done / total_steps * 100)
    bar_bg = s.shapes.add_shape(_MS.RECTANGLE,
                                 Inches(2.0), Inches(1.6), Inches(9.33), Inches(0.4))
    bar_bg.fill.solid(); bar_bg.fill.fore_color.rgb = GRAY_300
    bar_bg.line.fill.background()
    bar_fg = s.shapes.add_shape(_MS.RECTANGLE,
                                 Inches(2.0), Inches(1.6),
                                 Inches(9.33 * step_done / total_steps), Inches(0.4))
    bar_fg.fill.solid(); bar_fg.fill.fore_color.rgb = GREEN
    bar_fg.line.fill.background()
    _add_textbox(s, Inches(0.55), Inches(2.1), Inches(12.2), Inches(0.4),
                 f"第 {step_done} / {total_steps} 步完成（{pct}%）",
                 size_pt=14, color=GRAY_700, align=PP_ALIGN.CENTER)
    # 互动行
    _add_textbox(s, Inches(0.55), Inches(3.0), Inches(12.2), Inches(1.0),
                 action_hint, size_pt=28, bold=True, color=NAVY,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    # 救火建议
    _add_card(s, Inches(2.0), Inches(4.6), Inches(9.33), Inches(2.0),
              fill=CORAL_LIGHT, border=CORAL)
    _add_textbox(s, Inches(2.3), Inches(4.75), Inches(8.8), Inches(0.4),
                 "卡住了？", size_pt=20, bold=True, color=CORAL)
    _add_textbox(s, Inches(2.3), Inches(5.25), Inches(8.8), Inches(1.3),
                 fail_safe if fail_safe else "举手 · 讲师会过来 · 不要硬刚 5 分钟以上",
                 size_pt=15, color=NAVY)
    return s


def build_thanks(prs):
    """slide 107 · 末尾 Thanks · layout[5] 末尾幻灯片
    v0.3: 主标题更大 / 加金句 / URL 独占一行 / 加 QR placeholder
    """
    s = prs.slides.add_slide(prs.slide_layouts[5])
    # 主大字 Thanks
    _fill_ph(s, 0, "Thanks", size_pt=72, bold=True, color=NAVY)
    # 金句 + URL（一行 placeholder）
    _fill_ph(s, 18,
             "14 同事 + 41 图 + 138 张 deck = 你的下个 AI 项目\n"
             "反馈：github.com/org/agf/issues",
             size_pt=18, color=GRAY_700)
    _fill_ph(s, 10, "讲师：content-writer + product-lead · 5 道盲测题 + 8 份样例素材 + 41 张 draw.io 图全部开源",
             size_pt=13, color=GRAY_500)
    # QR 占位（用 chip 模拟，等真 QR 时换 image）
    from pptx.enum.shapes import MSO_SHAPE as _MS
    qr = s.shapes.add_shape(_MS.RECTANGLE,
                             Inches(10.5), Inches(0.5),
                             Inches(2.0), Inches(2.0))
    qr.fill.solid(); qr.fill.fore_color.rgb = WHITE
    qr.line.color.rgb = NAVY
    qr.line.width = Pt(2)
    _add_textbox(s, Inches(10.5), Inches(0.5), Inches(2.0), Inches(2.0),
                 "[ QR 码 ]\nrepo 入口",
                 size_pt=14, color=GRAY_500,
                 align=PP_ALIGN.CENTER, anchor=MSO_ANCHOR.MIDDLE)
    return s


# ============================================================
# 全局 footer · slide → chapter label + 页码（v0.3）
# 设计：仅在内容页加，跳过封面 / Thanks。封面=1，Thanks=107。
# ============================================================

# 章节主色（v0.3 · section header 差异化 + footer 章节 chip 用色）
CHAPTER_COLORS = {
    "封面":   CORAL,
    "目录":   NAVY,
    "§0":     CORAL,
    "§1.5":   BLUE_ACCENT,  # v1.4 新增 · §1 AI 工具基础
    "§1":     GOLD,         # §2 Git 基础
    "§2":     CORAL,        # §3 场景 A
    "§3":     GOLD,         # §4 场景 B
    "§4":     GREEN,        # §5 场景 C
    "§5":     NAVY,         # §6 场景 D
    "§6":     BLUE_ACCENT,  # §7 Lab
    "§7":     CORAL,        # §8 拓展
    "§8":     NAVY,         # §9 收尾
    "休息":   GRAY_500,
    "Thanks": CORAL,
}


def _get_chapter_for_slide(slide_num):
    """Slide 编号 → 章节标识 (label, color)。v1.4: 137 张（§0 -3、+ §1 AI 工具 12 张）。

    v1.4 slide 分布:
    1: 封面
    2: 主目录（active 01 高亮）
    3-14: §0 开场垫层 (12 张 · 去掉 v1.2 加的 3 张 AI · 保留理论根源时间线)
    15-26: §1 AI 工具基础 (12 张 · 新章节 · 含 H1/H2/H3 三张架构图)
    27-40: §2 Git 基础 (14 张 · 原 §1)
    41: 休息 1
    42: chapter divider · 02 场景剧本即将开始
    43-59: §3 场景 A · Happy Path (17 张 · 原 §2)
    60-74: §4 场景 B · 机制兜底 (15 张 · 原 §3)
    75: 午休
    76-90: §5 场景 C · 失败回路 (15 张 · 原 §4)
    91-103: §6 场景 D · UAT + Retro (13 张 · 原 §5)
    104: 休息 2
    105: chapter divider · 03 综合 Lab 即将开始
    106-119: §7 综合 Lab (14 张 · 原 §6)
    120: chapter divider · 04 拓展取舍即将开始
    121-132: §8 拓展 · 设计取舍 (12 张 · v1.4 +1 张 AI-Native 业界对标)
    133: chapter divider · 05 收尾即将开始
    134-137: §9 收尾 (4 张 · 原 §8)
    138: Thanks
    """
    if slide_num == 1:
        return ("封面", CHAPTER_COLORS["封面"])
    if slide_num == 2:
        return ("目录", CHAPTER_COLORS["目录"])
    if 3 <= slide_num <= 14:
        return ("§0 · 开场垫层", CHAPTER_COLORS["§0"])
    if 15 <= slide_num <= 26:
        return ("§1 · AI 工具基础", CHAPTER_COLORS["§1.5"])
    if 27 <= slide_num <= 40:
        return ("§2 · Git 基础", CHAPTER_COLORS["§1"])
    if slide_num == 41:
        return ("休息 1", CHAPTER_COLORS["休息"])
    if 42 <= slide_num <= 59:
        return ("§3 · 场景 A · Happy Path", CHAPTER_COLORS["§2"])
    if 60 <= slide_num <= 74:
        return ("§4 · 场景 B · 机制兜底", CHAPTER_COLORS["§3"])
    if slide_num == 75:
        return ("午休", CHAPTER_COLORS["休息"])
    if 76 <= slide_num <= 90:
        return ("§5 · 场景 C · 失败回路", CHAPTER_COLORS["§4"])
    if 91 <= slide_num <= 103:
        return ("§6 · 场景 D · UAT + Retro", CHAPTER_COLORS["§5"])
    if slide_num == 104:
        return ("休息 2", CHAPTER_COLORS["休息"])
    if 105 <= slide_num <= 119:
        return ("§7 · 综合 Lab", CHAPTER_COLORS["§6"])
    if 120 <= slide_num <= 132:
        return ("§8 · 拓展 · 设计取舍", CHAPTER_COLORS["§7"])
    if 133 <= slide_num <= 137:
        return ("§9 · 收尾", CHAPTER_COLORS["§8"])
    if slide_num == 138:
        return ("Thanks", CHAPTER_COLORS["Thanks"])
    return ("AGF", NAVY)


def _add_footer(slide, slide_num, total=138):
    """给一张 slide 加 footer：左下章节 chip + 右下页码。
    封面（1）/ Thanks（138）跳过。
    """
    if slide_num == 1 or slide_num == total:
        return
    label, color = _get_chapter_for_slide(slide_num)
    # 左下 章节 chip：4px 左竖线 + 章节文字
    _add_textbox(slide, Inches(0.45), Inches(7.15), Inches(0.05), Inches(0.22),
                 "", size_pt=1)  # 占位，下面用 shape 画竖线
    from pptx.enum.shapes import MSO_SHAPE as _MS
    bar = slide.shapes.add_shape(_MS.RECTANGLE,
                                  Inches(0.45), Inches(7.15),
                                  Inches(0.04), Inches(0.22))
    bar.fill.solid(); bar.fill.fore_color.rgb = color
    bar.line.fill.background()
    _add_textbox(slide, Inches(0.55), Inches(7.13), Inches(6.0), Inches(0.3),
                 label, size_pt=9, color=GRAY_500)
    # 右下 页码
    _add_textbox(slide, Inches(11.5), Inches(7.13), Inches(1.5), Inches(0.3),
                 f"p {slide_num:03d} / {total}",
                 size_pt=9, color=GRAY_500, align=PP_ALIGN.RIGHT)


def apply_footer_to_all(prs):
    """post-process：给所有 slide 加 footer（跳过封面 + Thanks）。"""
    for i, slide in enumerate(prs.slides, start=1):
        _add_footer(slide, i, total=len(prs.slides))


# ============================================================
# 主入口
# ============================================================

def _build_section(prs, pages, start_slide, sec_label, *, use_section_header=False,
                   sh_num=None, sh_title=None, sh_subtitle=None,
                   ci_num_label=None, ci_title=None, ci_subtitle=None):
    """构建章节：扉页（section_header 或 chapter_intro）+ 内容 stub。"""
    if use_section_header:
        build_section_header(prs, sh_num, sh_title, sh_subtitle)
    else:
        build_chapter_intro(prs, ci_num_label, ci_title, ci_subtitle)
    # 跳过 pages[0]（章节扉页占位），剩余作为内容页
    for offset, (title, note) in enumerate(pages[1:]):
        build_content_stub(prs, sec_label, start_slide + 1 + offset, title, note)


def main():
    print(f"[build] Loading template: {TEMPLATE_PATH}")
    if not TEMPLATE_PATH.exists():
        sys.exit(f"Template not found: {TEMPLATE_PATH}")
    prs = Presentation(str(TEMPLATE_PATH))
    print(f"[build] Original template: {len(prs.slides)} sample slides → clearing...")
    T.clear_template_slides(prs)
    print(f"[build] After clear: {len(prs.slides)} slides")

    # slide 1: 封面
    build_cover(prs)
    # slide 2: CONTENTS
    build_contents(prs)
    # ====== §0 开场垫层（v1.4: chapter_intro + 9 原内容 + 1 理论根源时间线 + 1 章末金句 = 12 张）======
    # v1.4 改动：Vibe Coding / Claude Code 简介 / vs Codex 3 张移到新 §1 AI 工具基础章节
    build_chapter_intro(prs, "开场", "Agile / Scrum / TDD 垫层",
                        "把 80% 不熟敏捷的学员（P1+P2）拉到同一起跑线")
    import build_sec_M1  # noqa: E402
    build_sec_M1.build(prs)
    # §0 章末金句页
    build_takeaway_page(prs, "§0 · 开场垫层",
        ["软件交付的 3 个老大难都是同一根：「沟通成本指数爆炸」",
         "Agile / Scrum / TDD 是 30 年验证的解药，不是新东西",
         "AGF 把这三件套嵌进 hook + skill + DoD"],
        hint="若你回去只能跟同事说 1 件事 — 「先想'什么算做对了'再写代码」")

    # ====== §1 AI 工具基础（v1.4 新增 · chapter_intro + 1 预测 + 9 内容 + 1 章末金句 = 12 张）======
    build_chapter_intro(prs, "AI 工具", "AI 编程工具基础",
                        "Vibe Coding 不是玩具 · Claude Code 是本课程载体")
    # 章首预测
    build_prediction_page(prs, "/§1 AI 工具 · 即将开始",
        "AI 编程工具最大的坑是？",
        options=["AI 写得慢 · 不如手写快",
                 "AI 写得不准 · 经常出错",
                 "AI 写得太快 · 但难审 / 难维护 / 难协作"],
        reveal_hint="（揭示：C · Karpathy 把这种现象叫 Vibe Coding · @nijho.lt 案例 15h 清理）")
    import build_sec_M1_5  # noqa: E402
    build_sec_M1_5.build(prs)
    # §1 章末金句（v1.4 加业界 AI-Native 转型研究数据点）
    build_takeaway_page(prs, "§1 · AI 工具基础",
        ["Vibe Coding 是范式 · 不是工具（Karpathy 2025-02 提出）",
         "Claude Code 6 大能力（subagent / skill / hook / slash / Plan Mode / MCP）是本课程载体",
         "AGF = Vibe Coding 速度 + 机制兜底（业界对标见 §8 拓展取舍）"],
        hint="业界研究：70% 公司用 AI 但没改角色 · 普通 5-15% vs 头部 5-7× 产能差距 · "
             "本周试：装 Claude Code · 跑 `claude` · 输 1 行需求看它写代码")

    # ====== §2 Git 基础（v1.4: chapter_intro + 12 内容 + 1 章末金句 = 14 张 · 原 §1）======
    build_chapter_intro(prs, "Git 基础", "Git / GitHub 基础（含 Release 实战）",
                        "P1 从零 · P2 补 TDD 强相关 · P3 跳页阅读")
    import build_sec_M2  # noqa: E402
    build_sec_M2.build(prs)
    # §2 章末金句（v1.4: 原 §1 → §2）
    build_takeaway_page(prs, "§2 · Git 基础",
        ["Git = 你工作的「时光机文件夹」",
         "5 命令打天下：clone / status / add / commit / push",
         "TDD 顺序 test → feat → refactor，reviewer 看 git log 反推违规"],
        hint="今晚回去做的 3 件事：装 git ≥ 2.30 · 跑一遍 git log --oneline -10 · 试一次 worktree")

    # slide: 休息 1
    build_break_page(prs, "", "休息 1", "10 min · 茶水间见", diagram="B-break-coffee")

    # chapter divider 1/4：02 场景剧本即将开始
    build_contents(prs, active_idx=1)

    # ====== §3 场景 A（v1.4: 原 §2 · section + 1 预测 + 14 内容 + 1 金句 = 17 张）======
    build_section_header(prs, 1, "场景 A · 明信片 Happy Path",
                         "用旅游明信片串起 14 同事 + 派单 schema + progress 自报")
    build_prediction_page(prs, "/01 场景 A · 即将开始",
        "一个 feature 怎么从「一句话」\n变成 14 同事协作？",
        options=["PL 全权写代码 + 13 人 review",
                 "PL 写 PRD + 派单 + 14 同事各自实现",
                 "14 同事开会决定 + PL 总结"],
        reveal_hint="（揭示：选 B · 接下来 14 张图给你看完整流程）")
    import build_sec_M3  # noqa: E402
    build_sec_M3.build(prs)
    build_takeaway_page(prs, "§3 · 场景 A · Happy Path",
        ["PRD 是 PL 的「合同」· 5 条 AC 全部可量化",
         "派单 6 段 schema · 缺一段 hook 直接阻断",
         "AI 输出测 3 指标：准确率 / 相关性 / 风格符合度（雷达图包目标）"],
        hint="周一回去试：用 brainstorming skill 让 PL 帮你写第一份 PRD")

    # ====== §4 场景 B（v1.4: 原 §3 · 15 张）======
    build_section_header(prs, 2, "场景 B · 机制兜底",
                         "4 层 hook + skill 强制 + DoD checklist 三件套")
    build_prediction_page(prs, "/02 场景 B · 即将开始",
        "AI agent 不靠谱时，你靠什么兜底？",
        options=["全靠 reviewer 人审，agent 不许独立操作",
                 "靠 hook + skill + DoD 三层机制硬阻断",
                 "靠教育 agent 不要犯错（写 prompt 让它小心）"],
        reveal_hint="（揭示：B · 「新员工三道防线」不靠自觉靠机制）")
    import build_sec_M4  # noqa: E402
    build_sec_M4.build(prs)
    build_takeaway_page(prs, "§4 · 场景 B · 机制兜底",
        ["AI 同事 = 新员工 · 三道防线（红线 / 流程 / 事后审）",
         "撞硬阻断 → 找 PL 授权或换方案 · 不要绕路",
         "skill 不是 wiki · 而是「不走 skill 不行」的强制路径"],
        hint="关键洞察：AI 让 coding 快 · review 变瓶颈 → 必须先修 review 再加速 · "
             "AGF 4 层 hook + skill + DoD 命中（本周：装 git pre-commit hook · 1 行）")

    # slide: 午休
    build_break_page(prs, "", "午休", "30 min · 食堂见", diagram="B-break-lunch")

    # ====== §5 场景 C（v1.4: 原 §4 · 15 张）======
    build_section_header(prs, 3, "场景 C · 失败回路\n+ TDD 救场",
                         "任一阶段门失败回 S3，code-reviewer / qa 永远不动源码")
    build_prediction_page(prs, "/03 场景 C · 即将开始",
        "code review 发现 bug · 谁来改源码？",
        options=["reviewer 自己改快（看到 bug 顺手修）",
                 "qa 改（反正测出来的）",
                 "原 dev 改 · reviewer / qa 只写报告"],
        reveal_hint="（揭示：C · 避免「既当裁判又当球员」）")
    import build_sec_M5  # noqa: E402
    build_sec_M5.build(prs)
    build_takeaway_page(prs, "§5 · 场景 C · 失败回路 + TDD",
        ["6 阶段门任一失败回 ③ 实现 · 不跳级",
         "code-reviewer 双 verdict（代码 + SIT Audit 独立）",
         "Plan Mode 6 触发点 · ai-agent-dev 必先拿 PL 授权"],
        hint="下次写 PR：commit 顺序检查（test: 早于 feat:）· reviewer 看 git log 就能判违规")

    # ====== §6 场景 D（v1.4: 原 §5 · 13 张）======
    build_section_header(prs, 4, "场景 D · UAT + Retro",
                         "UAT 是业务签字 ≠ 技术 pass · Release Retro 是 MAJOR/MINOR 强制")
    # v1.1 新增：§5 章首预测页
    build_prediction_page(prs, "/04 场景 D · 即将开始",
        "UAT 是 qa 签字还是业务方签字？",
        options=["qa · 因为他写了 E2E / 跑了所有测试",
                 "业务方 · 因为他关心「能不能发朋友圈」",
                 "PL · 因为他是最终责任人"],
        reveal_hint="（揭示：B · 「分享出去会不会丢人」不是 latency 问题）")
    import build_sec_M6  # noqa: E402
    build_sec_M6.build(prs)
    build_takeaway_page(prs, "§6 · 场景 D · UAT + Retro",
        ["UAT 通过 = 业务方真签字 · 不是 qa 帮签",
         "MAJOR/MINOR 必走 retro · PATCH 跳过（避免「为复盘而复盘」）",
         "retro 的 Improve 必带 Owner + Due · 否则不算"],
        hint="下次 release：试一次 /agf-release-retro · 7 步流程跑完")

    # slide: 休息 2
    build_break_page(prs, "", "休息 2", "10 min · 准备 Lab 实战", diagram="B-break-coffee2")

    # chapter divider 2/4：03 综合 Lab 即将开始
    build_contents(prs, active_idx=2)

    # ====== §7 综合 Lab（v1.4: 原 §6 · 14 张）======
    build_chapter_intro(prs, "综合 Lab", "学员现场跑 AI feature",
                        "讲师辅助，不代跑 · P1 跟读 / P2 自装 / P3 另跑复杂场景")
    import build_sec_M7  # noqa: E402
    build_sec_M7.build_with_checkins(prs, build_checkin_page) if hasattr(
        build_sec_M7, 'build_with_checkins') else build_sec_M7.build(prs)

    # chapter divider 3/4：04 拓展取舍即将开始
    build_contents(prs, active_idx=3)

    # ====== §8 拓展（v1.4: 原 §7 · section + 9 内容 + 1 金句 = 11 张）======
    build_section_header(prs, 5, "拓展 · 设计取舍 + 迁移",
                         "心法嵌 enforcement vs 写 wiki · 4 件保留 + 3 件可替换")
    import build_sec_M8  # noqa: E402
    build_sec_M8.build(prs)
    build_takeaway_page(prs, "§8 · 拓展 · 设计取舍",
        ["4 件保留：4 层 hook · DoD 9 项 · AC 摘录派单 · progress 自报",
         "3 件可替换：后端栈 · LLM 厂商 · 同事数量裁剪",
         "迁移到自己栈 · 这 3 件无关技术栈 · 任何项目都能抄"],
        hint="本周：审你的项目 · 标 4 件保留你已经有了几件 / 3 件可替换你想换什么")

    # chapter divider 4/4：05 收尾 Q&A 即将开始
    build_contents(prs, active_idx=4)

    # ====== §9 收尾（v1.4: 原 §8 · chapter_intro + 3 内容 = 4 张）======
    build_chapter_intro(prs, "收尾", "6 小时回顾 + Q&A",
                        "回到 §0 三个老大难 · 学完后按画像下一步")
    import build_sec_M9  # noqa: E402
    build_sec_M9.build(prs)
    # slide last: Thanks
    build_thanks(prs)

    actual = len(prs.slides)
    print(f"[build] Total slides built: {actual} (v1.4.1 expected 138 · v1.4 137 + AI-Native 业界对标 1)")
    assert actual == 138, f"Slide count mismatch: {actual} != 138 (v1.4.1)"

    # post-process · 全局 footer（v0.3）
    apply_footer_to_all(prs)
    print(f"[build] ✓ Applied footer to {actual - 2} slides (skipped cover + Thanks)")

    # post-process · 讲师演讲词 speaker notes（v1.5）
    from speaker_notes import apply_speaker_notes
    n_applied, n_skipped = apply_speaker_notes(prs)
    print(f"[build] ✓ Applied speaker notes to {n_applied} slides (skipped {n_skipped} transition/未写章节)")

    OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True)
    prs.save(str(OUTPUT_PATH))
    print(f"[build] ✓ Saved: {OUTPUT_PATH}")
    print("\n下一步（渲染验证）：")
    print(f"  soffice --headless --convert-to pdf --outdir {OUTPUT_PATH.parent} {OUTPUT_PATH}")
    print(f"  pdftocairo -png -r 100 {OUTPUT_PATH.with_suffix('.pdf')} {OUTPUT_PATH.parent}/preview/p")


if __name__ == "__main__":
    main()
