#!/usr/bin/env python3
"""
compose.py — 把终端回放截图合成带标注的判读图（流水线第 ③ 阶段）。

配套使用：

  packages/cli/scripts/slash-toggle-capture.tsx   # ① 采集累计 ANSI 字节流 -> steps.json
  scripts/figure/replay-shots.mjs                 # ② xterm.js 回放 -> shot-*.png
  scripts/figure/compose.py                       # ③ 合成判读图 -> verdict-*.png   ← 本脚本

所有内容都靠命令行参数描述，不往脚本里写死任何路径 —— 这样同一份脚本既能画
「四步视口对照」，也能画「修复前/修复后 + 指标表」，不用每次手写一遍 PIL。

版块顺序：标题 → 指标表 → 截图网格 → 前后对照 → 结论条（缺省参数即跳过该版块）。

用法：
  py -3.12 scripts/figure/compose.py \
    --shots  packages/cli/.playwright-mcp/slash-toggle/fixb \
    --out    packages/cli/.playwright-mcp/slash-toggle/verdict.png \
    --title  "命令面板开合「溢出」验证 · 终端 120×30 · xterm.js 回放真实 ANSI 字节流" \
    --subtitle "（欢迎横幅的 8fps ASCII 动画已冻结，保证前后可逐字节比对）" \
    --panel  "initial|① 初始|空输入 · 欢迎横幅" \
    --panel  "slash-open|② 输入 /|命令面板弹出（文档 17→36 行）" \
    --panel  "deleted|③ 退格删除|面板关闭（与 ① 逐字节相同）" \
    --panel  "slash-open-2|④ 再输入 /|面板再次弹出（与 ② 逐字节相同）" \
    --conclusion "视口结论：不溢出、不错位。修复前/后 4 张截图逐字节完全相同（MD5 一致）；" \
    --conclusion "关闭后横幅完整复原，第 2 次弹出与第 1 次完全一致 —— 可见区没有任何叠加或残留。"

指标表 + 前后对照（--table-row 第 4 段可选 green/red/amber，给最后一格上色）：
    --table-row "指标|修复前|修复后|" \
    --table-row "终端原生回滚区行数|70 行|0 行|green" \
    --compare   "prefix|fixb|after-cycles-scrolltop|修复前：回滚区 0 行 —— 干净|修复后：回滚区 70 行 —— 残带堆叠"
"""

from __future__ import annotations

import argparse
import os
import sys

try:
    from PIL import Image, ImageDraw, ImageFont
except ImportError:  # pragma: no cover
    sys.exit(
        "[figure-compose] 缺少 Pillow。本机默认 python 没有，请用 `py -3.12` 运行，"
        "或先 `pip install pillow`。"
    )

# ── 调色板（与仓库既有判读图保持一致） ────────────────────────────────────────
BG = (245, 246, 248)
INK = (24, 26, 30)
SUB = (110, 116, 128)
LINE = (210, 214, 222)
HEADBG = (232, 234, 238)
RED = (220, 50, 47)
GREEN = (30, 150, 80)
AMBER = (191, 120, 0)
RED_BG, RED_INK = (253, 236, 235), (150, 26, 24)
GREEN_BG, GREEN_INK = (232, 246, 238), (18, 100, 55)
AMBER_BG, AMBER_INK = (253, 244, 226), (120, 76, 0)

ACCENTS = {
    "red": (RED, RED_BG, RED_INK),
    "green": (GREEN, GREEN_BG, GREEN_INK),
    "amber": (AMBER, AMBER_BG, AMBER_INK),
}

# 中文字体：Windows 用微软雅黑，Linux/macOS 兜底
FONT_CANDIDATES = [
    r"C:\Windows\Fonts\msyh.ttc",
    r"C:\Windows\Fonts\simhei.ttf",
    "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
    "/System/Library/Fonts/PingFang.ttc",
]


def pick_font(explicit: str | None) -> str | None:
    for path in ([explicit] if explicit else []) + FONT_CANDIDATES:
        if path and os.path.exists(path):
            return path
    print("[figure-compose] 警告：没找到中文字体，中文可能显示为方块（--font 可指定）", file=sys.stderr)
    return None


def font_at(path: str | None, size: int):
    if path is None:
        return ImageFont.load_default()
    return ImageFont.truetype(path, size)


def load_scaled(path: str, width: int) -> Image.Image:
    if not os.path.exists(path):
        sys.exit(f"[figure-compose] 找不到截图：{path}")
    im = Image.open(path).convert("RGB")
    return im.resize((width, round(im.height * width / im.width)), Image.LANCZOS)


def shot_path(directory: str, name: str) -> str:
    """把 step 名解析成 shot-<name>.png（允许 name 里带相对路径）。"""
    return os.path.join(directory, f"shot-{name}.png")


def split_spec(spec: str, parts: int, flag: str, min_parts: int) -> list[str]:
    """按 | 切分参数；不足 min_parts 段直接报错，多于 parts 段的并入最后一段。

    分隔符用 | 而不是 :，因为 Windows 路径本身就含冒号。
    """
    got = spec.split("|")
    if len(got) < min_parts:
        sys.exit(
            f"[figure-compose] {flag} 至少需要 {min_parts} 段（用 | 分隔），收到：{spec!r}"
        )
    # 多余的段并入最后一段，避免描述文字里的 | 被误切
    while len(got) > parts:
        got[parts - 1] = got[parts - 1] + "|" + got.pop()
    # 缺的尾段补空（可选参数）
    while len(got) < parts:
        got.append("")
    return got


def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
    ap = argparse.ArgumentParser(
        prog="compose.py",
        description="把 xterm.js 回放截图合成带标注的终端判读图",
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    ap.add_argument("--out", required=True, help="输出 PNG 路径")
    ap.add_argument("--title", required=True, help="标题（单行）")
    ap.add_argument("--subtitle", default=None, help="标题下的灰色小字")
    ap.add_argument("--shots", default=None, help="截图目录，--panel 从里面取 shot-<name>.png")

    ap.add_argument(
        "--panel",
        action="append",
        default=[],
        metavar="NAME|LABEL|DESC",
        help="截图网格里的一格，可重复；NAME 对应 shot-<NAME>.png",
    )
    ap.add_argument("--cols", type=int, default=2, help="网格列数（默认 2）")
    ap.add_argument("--cell-width", type=int, default=900, help="每格图片像素宽（默认 900）")

    ap.add_argument(
        "--table-row",
        action="append",
        default=[],
        metavar="CELL|CELL[|CELL][|green|red|amber]",
        help="指标表的一行，可重复；第 1 行当表头；末段为颜色时给最后一格上色",
    )
    ap.add_argument(
        "--compare",
        action="append",
        default=[],
        metavar="LEFT_DIR|RIGHT_DIR|STEP[|LEFT_CAP|RIGHT_CAP]",
        help="前后对照：两侧目录里同名 shot-<STEP>.png 并排，可重复",
    )
    ap.add_argument(
        "--conclusion",
        action="append",
        default=[],
        help="结论条的一行，可重复；默认绿色，--conclusion-red 改红",
    )
    ap.add_argument("--conclusion-red", action="store_true", help="结论条用红色（判为失败时）")

    ap.add_argument("--scale", type=float, default=1.0, help="整体字号/间距缩放（默认 1.0）")
    ap.add_argument("--font", default=None, help="中文字体文件路径")
    ap.add_argument("--bg", default=None, help="覆盖底色，形如 245,246,248")
    return ap.parse_args(argv)


def main(argv: list[str] | None = None) -> int:
    args = parse_args(argv)

    if not args.panel and not args.compare and not args.table_row:
        sys.exit("[figure-compose] 至少要给一个 --panel / --table-row / --compare，否则图是空的")

    global BG
    if args.bg:
        BG = tuple(int(v) for v in args.bg.split(","))

    s = args.scale
    pad = round(28 * s)
    label_h = round(44 * s)
    row_gap = round(22 * s)
    block_gap = round(26 * s)
    table_rh = round(44 * s)
    caption_h = round(34 * s)
    conc_line = round(32 * s)
    conc_pad = round(16 * s)

    fp = pick_font(args.font)
    f_title = font_at(fp, round(36 * s))
    f_step = font_at(fp, round(26 * s))
    f_tag = font_at(fp, round(21 * s))
    f_cell = font_at(fp, round(24 * s))

    # ── 解析参数 ──────────────────────────────────────────────────────────────
    panels = []
    for spec in args.panel:
        if not args.shots:
            sys.exit("[figure-compose] 用了 --panel 就必须给 --shots 目录")
        name, label, desc = split_spec(spec, 3, "--panel", 2)
        panels.append((label, desc, shot_path(args.shots, name)))

    table_rows = []
    for spec in args.table_row:
        cells = [c.strip() for c in spec.split("|")]
        accent = None
        if cells and cells[-1].lower() in ACCENTS:
            accent = ACCENTS[cells.pop().lower()]
        table_rows.append((cells, accent))

    compares = []
    for spec in args.compare:
        left_dir, right_dir, step, left_cap, right_cap = split_spec(spec, 5, "--compare", 3)
        compares.append(
            (
                shot_path(left_dir, step),
                shot_path(right_dir, step),
                left_cap,
                right_cap,
            )
        )

    # ── 画布宽度：由版块自然宽度决定 ──────────────────────────────────────────
    cell_w = round(args.cell_width * s)
    cols = max(1, args.cols)
    grid_w = pad * 2 + cols * cell_w + (cols - 1) * pad if panels else 0
    cmp_w = pad * 2 + 2 * cell_w + pad if compares else 0
    width = max(grid_w, cmp_w, round(900 * s))

    # 对照版块在最终宽度下重新均分两栏
    cmp_cell_w = (width - pad * 3) // 2

    # ── 预加载图片（高度要参与排版，所以先全读进来） ──────────────────────────
    panel_imgs = [load_scaled(p, cell_w) for _, _, p in panels]
    compare_imgs = [
        (load_scaled(l, cmp_cell_w), load_scaled(r, cmp_cell_w), lc, rc)
        for l, r, lc, rc in compares
    ]

    # ── 版块高度 ──────────────────────────────────────────────────────────────
    title_band = round(72 * s) + (round(34 * s) if args.subtitle else 0)

    table_h = table_rh * len(table_rows)

    grid_h = 0
    if panels:
        for r in range(0, len(panels), cols):
            chunk = panel_imgs[r : r + cols]
            grid_h += label_h + max(im.height for im in chunk) + row_gap

    compare_h = 0
    for left, right, _, _ in compare_imgs:
        compare_h += max(left.height, right.height) + caption_h + row_gap

    conc_h = 0
    if args.conclusion:
        conc_h = conc_pad * 2 + conc_line * len(args.conclusion)

    blocks = [h for h in (table_h, grid_h, compare_h) if h > 0]
    height = title_band + sum(blocks) + block_gap * max(0, len(blocks) - 1) + conc_h + pad

    canvas = Image.new("RGB", (width, height), BG)
    d = ImageDraw.Draw(canvas)

    # ── 标题 ──────────────────────────────────────────────────────────────────
    d.text((pad, round(20 * s)), args.title, font=f_title, fill=INK)
    if args.subtitle:
        d.text((pad, round(20 * s) + round(48 * s)), args.subtitle, font=f_tag, fill=SUB)

    y = title_band
    first_block = True

    def begin_block() -> None:
        nonlocal y, first_block
        if not first_block:
            y += block_gap
        first_block = False

    # ── 指标表 ────────────────────────────────────────────────────────────────
    if table_rows:
        begin_block()
        ncols = max(len(cells) for cells, _ in table_rows)
        col_step = (width - pad * 2 - 28) // max(1, ncols)
        col_x = [pad + 14 + i * col_step for i in range(ncols)]

        for r, (cells, accent) in enumerate(table_rows):
            ty = y + r * table_rh
            if r == 0:
                d.rectangle([pad, ty, width - pad, ty + table_rh], fill=HEADBG)
            d.line([pad, ty, width - pad, ty], fill=LINE, width=1)
            for i, cell in enumerate(cells):
                if i >= ncols:
                    break
                if r == 0:
                    fill = SUB
                elif accent and i == len(cells) - 1:
                    fill = accent[2]
                else:
                    fill = INK
                d.text((col_x[i], ty + round(10 * s)), cell, font=f_cell, fill=fill)
        d.line(
            [pad, y + table_rh * len(table_rows), width - pad, y + table_rh * len(table_rows)],
            fill=LINE,
            width=1,
        )
        y += table_h

    # ── 截图网格 ──────────────────────────────────────────────────────────────
    if panels:
        begin_block()
        row_y = y
        for r in range(0, len(panels), cols):
            chunk = panel_imgs[r : r + cols]
            row_h = max(im.height for im in chunk)
            for c, im in enumerate(chunk):
                label, desc, _ = panels[r + c]
                x = pad + c * (cell_w + pad)
                d.text((x, row_y), label, font=f_step, fill=INK)
                d.text((x + round(150 * s), row_y + round(5 * s)), desc, font=f_tag, fill=SUB)
                canvas.paste(im, (x, row_y + label_h))
                d.rectangle(
                    [x - 2, row_y + label_h - 2, x + cell_w + 1, row_y + label_h + im.height + 1],
                    outline=LINE,
                    width=2,
                )
            row_y += label_h + row_h + row_gap
        y = row_y

    # ── 前后对照 ──────────────────────────────────────────────────────────────
    if compare_imgs:
        begin_block()
        for left, right, left_cap, right_cap in compare_imgs:
            ih = max(left.height, right.height)
            for x, im, cap, accent in (
                (pad, left, left_cap, ACCENTS["red"]),
                (pad * 2 + cmp_cell_w, right, right_cap, ACCENTS["green"]),
            ):
                canvas.paste(im, (x, y))
                d.rectangle([x - 2, y - 2, x + cmp_cell_w + 1, y + im.height + 1], outline=accent[0], width=4)
                if cap:
                    d.text((x, y + im.height + round(10 * s)), cap, font=f_tag, fill=accent[2])
            y += ih + caption_h + row_gap

    # ── 结论条 ────────────────────────────────────────────────────────────────
    if args.conclusion:
        begin_block()
        stroke, fill_bg, fill_ink = ACCENTS["red" if args.conclusion_red else "green"]
        d.rectangle([pad, y, width - pad, y + conc_h], fill=fill_bg, outline=stroke, width=3)
        for i, line in enumerate(args.conclusion):
            d.text((pad + round(20 * s), y + conc_pad + i * conc_line), line, font=f_tag, fill=fill_ink)
        y += conc_h

    out_dir = os.path.dirname(os.path.abspath(args.out))
    os.makedirs(out_dir, exist_ok=True)
    canvas.save(args.out)
    print(f"[figure-compose] {args.out}  {canvas.size[0]}x{canvas.size[1]}")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())
