from __future__ import annotations

import argparse
import hashlib
import importlib.util
import json
import sys
from datetime import datetime
from pathlib import Path


PROJECT = Path(__file__).resolve().parents[1]
DEFAULT_OUTPUT_DIR = PROJECT / ".tmp" / "动作预览"

ACTIONS = [
    ("breathe", "轻呼吸"),
    ("blink", "眨眼"),
    ("happy", "开心"),
    ("wave", "挥手"),
    ("sleep", "睡觉"),
    ("nod", "点头"),
    ("shake", "摇头"),
    ("curious", "歪头看看"),
    ("listen", "认真听"),
    ("shy", "害羞"),
    ("stretch", "伸懒腰"),
    ("walk", "走两步"),
]
INTERNAL_ACTIONS = {
    "rest",
    "look",
    "glance",
    "nod",
    "shake",
    "curious",
    "listen",
    "talk",
    "typing",
    "mouse_watch",
    "edge_peek",
    "happy_soft",
    "settle_soft",
    "shy",
    "shy_soft",
    "shy_settle_soft",
    "wake_soft",
    "stretch",
    "drag",
    "wink",
}

REQUIRED_ANIMATED = {
    "happy",
    "breathe",
    "wave",
    "sleep",
    "blink",
    "nod",
    "shake",
    "curious",
    "listen",
    "shy",
    "stretch",
    "walk",
}
ANIMATED_STATUSES = {"有动画", "合成动画"}
LOW_POSTURE_ACTIONS = {"listen", "typing"}
ALLOWED_RANDOM_COMPANION_SEQUENCES = {"ack", "calm_breathe"}
CONNECTOR_ACTIONS = {"settle_soft", "shy_settle_soft", "wake_soft"}


def load_launch_pet():
    path = PROJECT / "launch_pet.py"
    spec = importlib.util.spec_from_file_location("launch_pet_preview", path)
    if spec is None or spec.loader is None:
        raise RuntimeError(f"无法加载 {path}")
    module = importlib.util.module_from_spec(spec)
    sys.modules[spec.name] = module
    spec.loader.exec_module(module)
    return module


def font(size: int):
    from PIL import ImageFont

    candidates = [
        "C:/Windows/Fonts/msyh.ttc",
        "C:/Windows/Fonts/simhei.ttf",
        "/System/Library/Fonts/PingFang.ttc",
    ]
    for candidate in candidates:
        try:
            if Path(candidate).exists():
                return ImageFont.truetype(candidate, size)
        except Exception:
            pass
    return ImageFont.load_default()


def image_hash(img) -> str:
    return hashlib.sha1(img.convert("RGBA").tobytes()).hexdigest()


def file_hash(path: Path) -> str:
    return hashlib.sha1(path.read_bytes()).hexdigest()


def path_for_json(path: Path) -> str:
    return path.resolve().as_posix()


def find_asset_paths(impl, action: str) -> list[Path]:
    aliases = getattr(impl, "_POSE_ALIASES", {}).get(action, [action])
    roots = [impl.bibi_path / "poses", impl.bibi_path]
    prefixes = ["", "bibi_", "pose_"]
    for root in roots:
        for name in aliases:
            for prefix in prefixes:
                paths = impl._pose_sequence_candidates(name, prefix, root)
                if paths:
                    return paths
    return []


def action_status(frame_count: int, unique_assets: int, unique_renders: int) -> str:
    if unique_renders > 1:
        if unique_assets <= 1:
            return "合成动画"
        return "有动画"
    if frame_count > 1 and unique_assets <= 1:
        return "重复帧已折叠"
    if frame_count > 1:
        return "待优化"
    return "静态"


def frame_anchor(img) -> dict[str, float | None]:
    alpha = img.getchannel("A")
    bbox = alpha.getbbox()
    if not bbox:
        return {
            "body_x": None,
            "bottom": None,
            "bbox_width": None,
            "bbox_height": None,
        }
    width, height = img.size
    # Long-leg sprites place the tied shirt above the old 58% boundary. Sample
    # the actual legs so asymmetric sleeves do not look like foot drift.
    y0 = max(0, min(height - 1, int(height * 0.72)))
    y1 = max(y0 + 1, min(height, int(height * 0.99)))
    band = alpha.crop((0, y0, width, y1)).point(lambda a: 255 if a >= 32 else 0)
    band_box = band.getbbox()
    if band_box:
        body_x = band_box[0] + (band_box[2] - band_box[0]) / 2.0
    else:
        body_x = bbox[0] + (bbox[2] - bbox[0]) / 2.0
    return {
        "body_x": float(body_x),
        "bottom": float(bbox[3]),
        "bbox_width": float(bbox[2] - bbox[0]),
        "bbox_height": float(bbox[3] - bbox[1]),
    }


def max_metric_delta(values: list[float | None]) -> float:
    numeric = [float(value) for value in values if value is not None]
    if len(numeric) <= 1:
        return 0.0
    return max(numeric) - min(numeric)


def motion_quality(frames: list) -> dict[str, float | str]:
    anchors = [frame_anchor(img) for img in frames]
    anchor_shift = max_metric_delta([item["body_x"] for item in anchors])
    bottom_shift = max_metric_delta([item["bottom"] for item in anchors])
    width_delta = max_metric_delta([item["bbox_width"] for item in anchors])
    height_delta = max_metric_delta([item["bbox_height"] for item in anchors])
    if anchor_shift <= 1.8 and bottom_shift <= 1.8:
        status = "平稳"
    elif anchor_shift <= 3.2 and bottom_shift <= 2.5:
        status = "轻微"
    else:
        status = "需检查"
    return {
        "quality": status,
        "anchor_shift_px": round(anchor_shift, 2),
        "bottom_shift_px": round(bottom_shift, 2),
        "bbox_width_delta_px": round(width_delta, 2),
        "bbox_height_delta_px": round(height_delta, 2),
    }


def visual_delta_percent(img_a, img_b) -> float:
    from PIL import ImageChops, ImageStat

    diff = ImageChops.difference(img_a.convert("RGBA"), img_b.convert("RGBA"))
    stat = ImageStat.Stat(diff)
    return sum(stat.mean) / (4 * 255) * 100


def dark_fringe_quality(frames: list) -> dict[str, float | int]:
    semi = 0
    dark = 0
    for img in frames:
        pix = img.convert("RGBA").load()
        width, height = img.size
        for y in range(height):
            for x in range(width):
                r, g, b, a = pix[x, y]
                if 1 <= a <= 254:
                    semi += 1
                    if a > 150 or (r + g + b) / 3.0 >= 80:
                        continue
                    has_clear_outside = False
                    has_bright_inside = False
                    for oy in (-1, 0, 1):
                        for ox in (-1, 0, 1):
                            if ox == 0 and oy == 0:
                                continue
                            nx, ny = x + ox, y + oy
                            if not (0 <= nx < width and 0 <= ny < height):
                                continue
                            nr, ng, nb, na = pix[nx, ny]
                            if na <= 4:
                                has_clear_outside = True
                            elif na >= 220 and (nr + ng + nb) / 3.0 >= 115:
                                has_bright_inside = True
                    if has_clear_outside and has_bright_inside:
                        dark += 1
    pct = dark / max(1, semi) * 100
    return {
        "semi_alpha_pixels": int(semi),
        "dark_fringe_pct": round(pct, 2),
    }


def build_summary(rows: list[dict]) -> dict:
    total = len(rows)
    animated = [row for row in rows if row["status"] in ANIMATED_STATUSES]
    stable = [row for row in rows if row["quality"] == "平稳"]
    required_missing = [
        row["action"]
        for row in rows
        if row["action"] in REQUIRED_ANIMATED and row["status"] not in ANIMATED_STATUSES
    ]
    unstable = [
        row["action"]
        for row in rows
        if row["quality"] != "平稳" or row["status"] == "待优化"
    ]
    dark_fringe_watch = [
        row["action"]
        for row in rows
        if float(row.get("dark_fringe_pct", 0.0)) > 20.0
    ]
    unstable = sorted(set(unstable + dark_fringe_watch))
    return {
        "total_actions": total,
        "animated_actions": len(animated),
        "static_actions": total - len(animated),
        "stable_actions": len(stable),
        "required_animated": sorted(REQUIRED_ANIMATED),
        "required_missing_animation": required_missing,
        "unstable_actions": unstable,
        "dark_fringe_watch": dark_fringe_watch,
        "max_dark_fringe_pct": max((float(row.get("dark_fringe_pct", 0.0)) for row in rows), default=0.0),
        "ok": not required_missing and not unstable,
    }


def build_transitions(renderer, steps: list[tuple], entry_action: str = "rest") -> list[dict]:
    transitions = []
    if steps:
        first_action = str(steps[0][0])
        entry_frame = renderer.render(entry_action, 1.6, 0).image.convert("RGBA")
        first_frame = renderer.render(first_action, 0.0, 0).image.convert("RGBA")
        transitions.append(
            {
                "from": entry_action,
                "to": first_action,
                "delta_pct": round(visual_delta_percent(entry_frame, first_frame), 2),
                "entry": True,
            }
        )
    for left, right in zip(steps, steps[1:]):
        left_action = str(left[0])
        right_action = str(right[0])
        left_seconds = float(left[1])
        sample_t = min(max(left_seconds * 0.85, 0.7), 2.2)
        left_frame = renderer.render(left_action, sample_t, 0).image.convert("RGBA")
        right_frame = renderer.render(right_action, 0.0, 0).image.convert("RGBA")
        transitions.append(
            {
                "from": left_action,
                "to": right_action,
                "delta_pct": round(visual_delta_percent(left_frame, right_frame), 2),
                "entry": False,
            }
        )
    return transitions


def transition_duration_seconds(launch_pet, from_action: str, to_action: str) -> float:
    helper = getattr(launch_pet, "_visual_transition_seconds", None)
    if callable(helper):
        try:
            return round(float(helper(from_action, to_action)), 2)
        except Exception:
            pass
    if "blink" in {from_action, to_action}:
        return 0.10
    return 0.58


def collect_transition_preview_rows(*groups: list[dict]) -> list[dict]:
    by_pair: dict[tuple[str, str], dict] = {}
    for rows in groups:
        for row in rows:
            source = str(row.get("sequence") or row.get("manual_action") or "")
            for transition in row.get("transitions", []):
                from_action = str(transition.get("from", ""))
                to_action = str(transition.get("to", ""))
                delta = float(transition.get("delta_pct", 0.0))
                if not from_action or not to_action or delta <= 0.05:
                    continue
                key = (from_action, to_action)
                existing = by_pair.get(key)
                if existing is None or delta > existing["delta_pct"]:
                    by_pair[key] = {
                        "from": from_action,
                        "to": to_action,
                        "delta_pct": round(delta, 2),
                        "sources": [source] if source else [],
                    }
                elif source and source not in existing["sources"]:
                    existing["sources"].append(source)
    return sorted(by_pair.values(), key=lambda item: item["delta_pct"], reverse=True)


def inspect_sequences(launch_pet, renderer, action_names: set[str]) -> tuple[list[dict], dict]:
    specs = launch_pet._command_sequence_specs()
    rows = []
    for name, spec in specs.items():
        steps = launch_pet._sequence_steps(spec)
        issues = []
        if not steps:
            issues.append("没有动作步骤")
        effective_step_count = len([step for step in steps if str(step[0]) not in CONNECTOR_ACTIONS])
        if name != "patrol" and effective_step_count > 5:
            issues.append("步骤过多，容易显得乱")
        if steps and steps[-1][0] != "rest":
            issues.append("结尾没有回到站姿")
        total_seconds = 0.0
        for step in steps:
            action = str(step[0])
            seconds = float(step[1])
            total_seconds += seconds
            if action not in action_names:
                issues.append(f"引用不存在的动作：{action}")
            if name != "patrol" and action in LOW_POSTURE_ACTIONS:
                issues.append(f"日常组合不应自动使用低头姿态：{action}")
            min_seconds = 0.35 if action == "blink" else 0.75
            if seconds < min_seconds:
                issues.append(f"{action} 时间太短：{seconds:.2f}s")
        transitions = build_transitions(renderer, steps)
        entry_delta = transitions[0]["delta_pct"] if transitions and transitions[0].get("entry") else 0.0
        max_delta = max((item["delta_pct"] for item in transitions), default=0.0)
        if max_delta <= 9.0:
            transition_status = "柔和"
        elif max_delta <= 13.0:
            transition_status = "可接受"
        else:
            transition_status = "偏大"
        if name != "patrol" and max_delta > 16.0:
            issues.append(f"切换跳变偏大：{max_delta:.1f}%")
        if name != "patrol" and total_seconds > 14.0:
            issues.append(f"总时长偏长：{total_seconds:.1f}s")
        rows.append(
            {
                "sequence": name,
                "label": str(spec.get("label", "")),
                "step_count": len(steps),
                "total_seconds": round(total_seconds, 2),
                "transition_status": transition_status,
                "max_transition_delta_pct": round(max_delta, 2),
                "entry_transition_delta_pct": round(entry_delta, 2),
                "transitions": transitions,
                "steps": [
                    {
                        "action": str(step[0]),
                        "seconds": float(step[1]),
                        **({"caption": str(step[2])} if len(step) >= 3 else {}),
                    }
                    for step in steps
                ],
                "issues": issues,
                "ok": not issues,
            }
        )
    issue_rows = [row for row in rows if not row["ok"]]
    return rows, {
        "total_sequences": len(rows),
        "ok_sequences": len(rows) - len(issue_rows),
        "issue_sequences": [row["sequence"] for row in issue_rows],
        "max_transition_delta_pct": max((row["max_transition_delta_pct"] for row in rows), default=0.0),
        "transition_watch_sequences": [
            row["sequence"]
            for row in rows
            if row["sequence"] != "patrol" and row["max_transition_delta_pct"] > 13.0
        ],
        "ok": not issue_rows,
    }


def inspect_random_pool(launch_pet) -> dict:
    specs = launch_pet._command_sequence_specs()
    pool = list(getattr(launch_pet, "_RANDOM_COMPANION_SEQUENCES", ()))
    unique_pool = sorted(set(pool))
    issues = []
    if not pool:
        issues.append("随机互动池为空")
    for name in unique_pool:
        if name not in specs:
            issues.append(f"随机互动引用不存在的组合：{name}")
        if name not in ALLOWED_RANDOM_COMPANION_SEQUENCES:
            issues.append(f"随机互动不应包含语境/幅度偏大的组合：{name}")
    for name in sorted(ALLOWED_RANDOM_COMPANION_SEQUENCES):
        if name not in unique_pool:
            issues.append(f"随机互动缺少稳定陪伴组合：{name}")
    return {
        "pool": pool,
        "unique_pool": unique_pool,
        "allowed": sorted(ALLOWED_RANDOM_COMPANION_SEQUENCES),
        "issues": issues,
        "ok": not issues,
    }


def inspect_manual_actions(launch_pet, renderer, action_names: set[str]) -> tuple[list[dict], dict]:
    specs = launch_pet._manual_action_sequence_specs()
    rows = []
    for name, spec in specs.items():
        steps = launch_pet._sequence_steps(spec)
        issues = []
        if not steps:
            issues.append("没有手动动作步骤")
        if steps and steps[-1][0] != "rest":
            issues.append("结尾没有回到站姿")
        effective_step_count = len([step for step in steps if str(step[0]) not in CONNECTOR_ACTIONS])
        if effective_step_count > 5:
            issues.append("手动动作步骤过多，容易显得拖")
        total_seconds = 0.0
        for step in steps:
            action = str(step[0])
            seconds = float(step[1])
            total_seconds += seconds
            if action not in action_names:
                issues.append(f"引用不存在的动作：{action}")
            if action in LOW_POSTURE_ACTIONS:
                issues.append(f"手动动作不应使用低头姿态：{action}")
            min_seconds = 0.35 if action == "blink" else 0.75
            if seconds < min_seconds:
                issues.append(f"{action} 时间太短：{seconds:.2f}s")
        transitions = build_transitions(renderer, steps)
        entry_delta = transitions[0]["delta_pct"] if transitions and transitions[0].get("entry") else 0.0
        max_delta = max((item["delta_pct"] for item in transitions), default=0.0)
        if max_delta <= 9.0:
            transition_status = "柔和"
        elif max_delta <= 14.0:
            transition_status = "可接受"
        else:
            transition_status = "偏大"
        if max_delta > 16.0:
            issues.append(f"手动动作切换跳变偏大：{max_delta:.1f}%")
        rows.append(
            {
                "manual_action": name,
                "label": str(spec.get("label", "")),
                "step_count": len(steps),
                "total_seconds": round(total_seconds, 2),
                "transition_status": transition_status,
                "max_transition_delta_pct": round(max_delta, 2),
                "entry_transition_delta_pct": round(entry_delta, 2),
                "transitions": transitions,
                "steps": [
                    {
                        "action": str(step[0]),
                        "seconds": float(step[1]),
                    }
                    for step in steps
                ],
                "issues": issues,
                "ok": not issues,
            }
        )
    issue_rows = [row for row in rows if not row["ok"]]
    return rows, {
        "total_manual_actions": len(rows),
        "ok_manual_actions": len(rows) - len(issue_rows),
        "issue_manual_actions": [row["manual_action"] for row in issue_rows],
        "max_manual_transition_delta_pct": max((row["max_transition_delta_pct"] for row in rows), default=0.0),
        "ok": not issue_rows,
    }


def inspect_reply_motions(launch_pet, renderer, action_names: set[str]) -> tuple[list[dict], dict]:
    specs = launch_pet._reply_motion_specs()
    rows = []
    for name, spec in specs.items():
        steps = launch_pet._sequence_steps(spec)
        issues = []
        if not steps:
            issues.append("没有回复动作步骤")
        total_seconds = 0.0
        for step in steps:
            action = str(step[0])
            seconds = float(step[1])
            total_seconds += seconds
            if action not in action_names:
                issues.append(f"引用不存在的动作：{action}")
            if action in LOW_POSTURE_ACTIONS:
                issues.append(f"回复动作不应自动使用低头姿态：{action}")
            if seconds < 0.65:
                issues.append(f"{action} 时间太短：{seconds:.2f}s")
        transitions = build_transitions(renderer, steps)
        entry_delta = transitions[0]["delta_pct"] if transitions and transitions[0].get("entry") else 0.0
        max_delta = max((item["delta_pct"] for item in transitions), default=0.0)
        if max_delta <= 9.0:
            transition_status = "柔和"
        elif max_delta <= 13.0:
            transition_status = "可接受"
        else:
            transition_status = "偏大"
        if max_delta > 16.0:
            issues.append(f"回复切换跳变偏大：{max_delta:.1f}%")
        rows.append(
            {
                "sequence": f"reply_{name}",
                "reaction": name,
                "label": str(spec.get("label", "")),
                "step_count": len(steps),
                "total_seconds": round(total_seconds, 2),
                "transition_status": transition_status,
                "max_transition_delta_pct": round(max_delta, 2),
                "entry_transition_delta_pct": round(entry_delta, 2),
                "transitions": transitions,
                "steps": [
                    {
                        "action": str(step[0]),
                        "seconds": float(step[1]),
                    }
                    for step in steps
                ],
                "issues": issues,
                "ok": not issues,
            }
        )
    issue_rows = [row for row in rows if not row["ok"]]
    return rows, {
        "total_reply_motions": len(rows),
        "ok_reply_motions": len(rows) - len(issue_rows),
        "issue_reply_motions": [row["sequence"] for row in issue_rows],
        "max_reply_transition_delta_pct": max((row["max_transition_delta_pct"] for row in rows), default=0.0),
        "ok": not issue_rows,
    }


def inspect_wait_motions(launch_pet, renderer, action_names: set[str]) -> tuple[list[dict], dict]:
    specs = launch_pet._wait_motion_specs()
    rows = []
    for name, spec in specs.items():
        steps = launch_pet._sequence_steps(spec)
        issues = []
        if not steps:
            issues.append("没有等待动作步骤")
        if steps and steps[0][0] != "look":
            issues.append("等待动作没有从看着开始")
        total_seconds = 0.0
        for step in steps:
            action = str(step[0])
            seconds = float(step[1])
            total_seconds += seconds
            if action not in action_names:
                issues.append(f"引用不存在的动作：{action}")
            if action in LOW_POSTURE_ACTIONS:
                issues.append(f"等待动作不应自动使用低头姿态：{action}")
            if seconds < 0.5:
                issues.append(f"{action} 时间太短：{seconds:.2f}s")
        transitions = build_transitions(renderer, steps)
        entry_delta = transitions[0]["delta_pct"] if transitions and transitions[0].get("entry") else 0.0
        max_delta = max((item["delta_pct"] for item in transitions), default=0.0)
        if max_delta <= 9.0:
            transition_status = "柔和"
        elif max_delta <= 13.0:
            transition_status = "可接受"
        else:
            transition_status = "偏大"
        if max_delta > 16.0:
            issues.append(f"等待切换跳变偏大：{max_delta:.1f}%")
        rows.append(
            {
                "sequence": f"wait_{name}",
                "reaction": name,
                "label": str(spec.get("label", "")),
                "step_count": len(steps),
                "total_seconds": round(total_seconds, 2),
                "transition_status": transition_status,
                "max_transition_delta_pct": round(max_delta, 2),
                "entry_transition_delta_pct": round(entry_delta, 2),
                "transitions": transitions,
                "steps": [
                    {
                        "action": str(step[0]),
                        "seconds": float(step[1]),
                    }
                    for step in steps
                ],
                "issues": issues,
                "ok": not issues,
            }
        )
    issue_rows = [row for row in rows if not row["ok"]]
    return rows, {
        "total_wait_motions": len(rows),
        "ok_wait_motions": len(rows) - len(issue_rows),
        "issue_wait_motions": [row["sequence"] for row in issue_rows],
        "max_wait_transition_delta_pct": max((row["max_transition_delta_pct"] for row in rows), default=0.0),
        "ok": not issue_rows,
    }


def make_preview(size: int, renderer_mode: str, output_dir: Path) -> tuple[Path, Path, Path, Path, Path, Path]:
    from PIL import Image, ImageDraw

    launch_pet = load_launch_pet()
    renderer = launch_pet.PetRenderer(
        renderer_mode,
        size,
        bibi_dir=str(PROJECT / "bibi"),
    )
    impl = renderer.impl
    output_dir.mkdir(parents=True, exist_ok=True)

    title_font = font(24)
    header_font = font(16)
    text_font = font(13)
    small_font = font(11)

    times = [0.0, 0.8, 1.6, 2.4]
    rendered: dict[str, list] = {}
    rows = []
    for action, label in ACTIONS:
        frames = [renderer.render(action, t, 0).image.convert("RGBA") for t in times]
        rendered[action] = frames
        assets = find_asset_paths(impl, action) if hasattr(impl, "bibi_path") else []
        unique_assets = len({file_hash(path) for path in assets}) if assets else 0
        unique_renders = len({image_hash(img) for img in frames})
        bbox_values = [img.getchannel("A").getbbox() for img in frames]
        quality = motion_quality(frames)
        dark_fringe = dark_fringe_quality(frames)
        rows.append(
            {
                "action": action,
                "label": label,
                "asset_files": [path_for_json(path) for path in assets],
                "asset_frame_count": len(assets),
                "unique_asset_frames": unique_assets,
                "unique_render_frames": unique_renders,
                "bbox_unique": len({str(bbox) for bbox in bbox_values}),
                "status": action_status(len(assets), unique_assets, unique_renders),
                **quality,
                **dark_fringe,
            }
        )
    summary = build_summary(rows)
    runtime_action_names = {action for action, _label in ACTIONS} | set(INTERNAL_ACTIONS)
    sequence_rows, sequence_summary = inspect_sequences(launch_pet, renderer, runtime_action_names)
    random_pool_summary = inspect_random_pool(launch_pet)
    manual_action_rows, manual_action_summary = inspect_manual_actions(launch_pet, renderer, runtime_action_names)
    reply_motion_rows, reply_motion_summary = inspect_reply_motions(launch_pet, renderer, runtime_action_names)
    wait_motion_rows, wait_motion_summary = inspect_wait_motions(launch_pet, renderer, runtime_action_names)
    transition_preview_rows = collect_transition_preview_rows(
        sequence_rows,
        manual_action_rows,
        reply_motion_rows,
        wait_motion_rows,
    )
    summary["sequence_total"] = sequence_summary["total_sequences"]
    summary["sequence_ok"] = sequence_summary["ok_sequences"]
    summary["sequence_issues"] = sequence_summary["issue_sequences"]
    summary["sequence_max_transition_delta_pct"] = sequence_summary["max_transition_delta_pct"]
    summary["sequence_transition_watch"] = sequence_summary["transition_watch_sequences"]
    summary["random_pool"] = random_pool_summary["pool"]
    summary["random_pool_allowed"] = random_pool_summary["allowed"]
    summary["random_pool_issues"] = random_pool_summary["issues"]
    summary["random_pool_ok"] = random_pool_summary["ok"]
    summary["manual_action_total"] = manual_action_summary["total_manual_actions"]
    summary["manual_action_ok"] = manual_action_summary["ok_manual_actions"]
    summary["manual_action_issues"] = manual_action_summary["issue_manual_actions"]
    summary["manual_action_max_transition_delta_pct"] = manual_action_summary["max_manual_transition_delta_pct"]
    summary["reply_motion_total"] = reply_motion_summary["total_reply_motions"]
    summary["reply_motion_ok"] = reply_motion_summary["ok_reply_motions"]
    summary["reply_motion_issues"] = reply_motion_summary["issue_reply_motions"]
    summary["reply_motion_max_transition_delta_pct"] = reply_motion_summary["max_reply_transition_delta_pct"]
    summary["wait_motion_total"] = wait_motion_summary["total_wait_motions"]
    summary["wait_motion_ok"] = wait_motion_summary["ok_wait_motions"]
    summary["wait_motion_issues"] = wait_motion_summary["issue_wait_motions"]
    summary["wait_motion_max_transition_delta_pct"] = wait_motion_summary["max_wait_transition_delta_pct"]
    summary["transition_preview_total"] = len(transition_preview_rows)
    summary["ok"] = bool(
        summary["ok"]
        and sequence_summary["ok"]
        and random_pool_summary["ok"]
        and manual_action_summary["ok"]
        and reply_motion_summary["ok"]
        and wait_motion_summary["ok"]
    )

    frame_w = max(max(img.width for img in frames) for frames in rendered.values())
    frame_h = max(max(img.height for img in frames) for frames in rendered.values())
    cell_w = max(560, frame_w * len(times) + 210)
    cell_h = max(190, frame_h + 54)
    cols = 2
    rows_per_col = (len(ACTIONS) + cols - 1) // cols
    margin = 24
    header_h = 72
    sheet_w = margin * 2 + cols * cell_w
    sheet_h = margin * 2 + header_h + rows_per_col * cell_h
    sheet = Image.new("RGB", (sheet_w, sheet_h), "#f6f5f1")
    draw = ImageDraw.Draw(sheet)

    draw.text((margin, margin), "桌宠动作预览 / 体检", fill="#222222", font=title_font)
    draw.text(
        (margin, margin + 34),
        f"生成时间：{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}    尺寸：{size}px    动画：{summary['animated_actions']}/{summary['total_actions']}    平稳：{summary['stable_actions']}/{summary['total_actions']}",
        fill="#555555",
        font=text_font,
    )

    status_colors = {
        "有动画": "#1f7a4d",
        "合成动画": "#1f6f9e",
        "静态": "#555555",
        "重复帧已折叠": "#6a6a6a",
        "待优化": "#8751a8",
    }

    for idx, row in enumerate(rows):
        col = idx // rows_per_col
        line = idx % rows_per_col
        x0 = margin + col * cell_w
        y0 = margin + header_h + line * cell_h
        x1 = x0 + cell_w - 12
        y1 = y0 + cell_h - 12
        draw.rounded_rectangle((x0, y0, x1, y1), radius=6, fill="#ffffff", outline="#d6d4cc")
        action = row["action"]
        title = f"{row['label']}  {action}"
        draw.text((x0 + 12, y0 + 10), title, fill="#222222", font=header_font)
        status = row["status"]
        draw.text((x0 + 12, y0 + 36), status, fill=status_colors.get(status, "#555555"), font=text_font)
        meta = f"素材帧 {row['asset_frame_count']} / 唯一素材 {row['unique_asset_frames']} / 渲染变化 {row['unique_render_frames']}"
        draw.text((x0 + 12, y0 + 58), meta, fill="#666666", font=small_font)
        quality_text = f"稳 {row['quality']}  锚{row['anchor_shift_px']}  底{row['bottom_shift_px']}  暗边{row['dark_fringe_pct']}%"
        quality_color = "#1f7a4d" if row["quality"] == "平稳" else ("#9a5b00" if row["quality"] == "轻微" else "#b33a3a")
        draw.text((x0 + 12, y0 + 76), quality_text, fill=quality_color, font=small_font)
        frame_x = x0 + 172
        frame_y = y0 + 14
        for n, img in enumerate(rendered[action]):
            tile = Image.new("RGBA", (frame_w + 10, frame_h + 10), (248, 248, 245, 255))
            px = (tile.width - img.width) // 2
            py = tile.height - img.height - 5
            tile.alpha_composite(img, (px, py))
            sheet.paste(tile.convert("RGB"), (frame_x + n * (frame_w + 14), frame_y))

    png_path = output_dir / "动作预览.png"
    gif_path = output_dir / "动作动图.gif"
    sequence_png_path = output_dir / "组合动作预览.png"
    chat_motion_png_path = output_dir / "聊天动作预览.png"
    transition_png_path = output_dir / "动作切换预览.png"
    json_path = output_dir / "动作体检.json"
    sheet.save(png_path)

    max_sequence_steps = 7
    seq_tile_w = frame_w + 18
    seq_tile_h = frame_h + 34
    seq_left_w = 250
    seq_gap = 14
    seq_cell_h = max(210, seq_tile_h + 42)
    seq_sheet_w = margin * 2 + seq_left_w + max_sequence_steps * (seq_tile_w + seq_gap) + 70
    preview_sequence_rows = sequence_rows
    seq_sheet_h = margin * 2 + header_h + len(preview_sequence_rows) * seq_cell_h
    seq_sheet = Image.new("RGB", (seq_sheet_w, seq_sheet_h), "#f6f5f1")
    seq_draw = ImageDraw.Draw(seq_sheet)
    seq_draw.text((margin, margin), "桌宠组合动作预览 / 体检", fill="#222222", font=title_font)
    seq_draw.text(
        (margin, margin + 34),
        f"生成时间：{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}    组合：{summary['sequence_ok']}/{summary['sequence_total']}    单动作平稳：{summary['stable_actions']}/{summary['total_actions']}",
        fill="#555555",
        font=text_font,
    )
    for idx, row in enumerate(preview_sequence_rows):
        y0 = margin + header_h + idx * seq_cell_h
        y1 = y0 + seq_cell_h - 12
        ok = bool(row["ok"])
        outline = "#d6d4cc" if ok else "#b33a3a"
        seq_draw.rounded_rectangle((margin, y0, seq_sheet_w - margin, y1), radius=6, fill="#ffffff", outline=outline)
        title = f"{row['label']}  {row['sequence']}"
        seq_draw.text((margin + 12, y0 + 12), title, fill="#222222", font=header_font)
        meta = (
            f"{row['step_count']} 步 / {row['total_seconds']} 秒 / "
            f"切换 {row['transition_status']} {row['max_transition_delta_pct']}%"
        )
        seq_draw.text((margin + 12, y0 + 42), meta, fill="#666666", font=text_font)
        if row["issues"]:
            seq_draw.text((margin + 12, y0 + 68), "；".join(row["issues"][:2]), fill="#b33a3a", font=small_font)
        else:
            seq_draw.text((margin + 12, y0 + 68), "通过：结尾回到站姿，步骤节奏正常", fill="#1f7a4d", font=small_font)
        frame_x = margin + seq_left_w
        shown_steps = row["steps"][:max_sequence_steps]
        for step_idx, step in enumerate(shown_steps):
            action = step["action"]
            seconds = float(step["seconds"])
            img = renderer.render(action, min(max(seconds * 0.45, 0.35), 1.2), 0).image.convert("RGBA")
            tile = Image.new("RGBA", (seq_tile_w, seq_tile_h), (248, 248, 245, 255))
            tx = (tile.width - img.width) // 2
            ty = tile.height - img.height - 22
            tile.alpha_composite(img, (tx, ty))
            px = frame_x + step_idx * (seq_tile_w + seq_gap)
            py = y0 + 16
            seq_sheet.paste(tile.convert("RGB"), (px, py))
            label = f"{step_idx + 1}. {action} {seconds:g}s"
            seq_draw.text((px + 6, y1 - 30), label[:22], fill="#555555", font=small_font)
        hidden_count = len(row["steps"]) - len(shown_steps)
        if hidden_count > 0:
            more_x = frame_x + len(shown_steps) * (seq_tile_w + seq_gap)
            seq_draw.text((more_x + 8, y0 + seq_cell_h // 2 - 10), f"+{hidden_count} 步", fill="#666666", font=header_font)
    seq_sheet.save(sequence_png_path)

    chat_rows = reply_motion_rows + wait_motion_rows
    chat_max_steps = 4
    chat_tile_w = frame_w + 18
    chat_tile_h = frame_h + 34
    chat_left_w = 250
    chat_gap = 14
    chat_cell_h = max(210, chat_tile_h + 42)
    chat_sheet_w = margin * 2 + chat_left_w + chat_max_steps * (chat_tile_w + chat_gap) + 70
    chat_sheet_h = margin * 2 + header_h + len(chat_rows) * chat_cell_h
    chat_sheet = Image.new("RGB", (chat_sheet_w, chat_sheet_h), "#f6f5f1")
    chat_draw = ImageDraw.Draw(chat_sheet)
    chat_draw.text((margin, margin), "桌宠聊天动作预览 / 体检", fill="#222222", font=title_font)
    chat_draw.text(
        (margin, margin + 34),
        f"生成时间：{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}    回复：{summary['reply_motion_ok']}/{summary['reply_motion_total']}    等待：{summary['wait_motion_ok']}/{summary['wait_motion_total']}    回复最大：{summary['reply_motion_max_transition_delta_pct']}%    等待最大：{summary['wait_motion_max_transition_delta_pct']}%",
        fill="#555555",
        font=text_font,
    )
    for idx, row in enumerate(chat_rows):
        y0 = margin + header_h + idx * chat_cell_h
        y1 = y0 + chat_cell_h - 12
        ok = bool(row["ok"])
        outline = "#d6d4cc" if ok else "#b33a3a"
        chat_draw.rounded_rectangle((margin, y0, chat_sheet_w - margin, y1), radius=6, fill="#ffffff", outline=outline)
        title = f"{row['label']}  {row['sequence']}"
        chat_draw.text((margin + 12, y0 + 12), title, fill="#222222", font=header_font)
        meta = (
            f"{row['step_count']} 步 / {row['total_seconds']} 秒 / "
            f"切换 {row['transition_status']} {row['max_transition_delta_pct']}%"
        )
        chat_draw.text((margin + 12, y0 + 42), meta, fill="#666666", font=text_font)
        if row["issues"]:
            chat_draw.text((margin + 12, y0 + 68), "；".join(row["issues"][:2]), fill="#b33a3a", font=small_font)
        else:
            chat_draw.text((margin + 12, y0 + 68), "通过：聊天动作步骤和切换正常", fill="#1f7a4d", font=small_font)
        frame_x = margin + chat_left_w
        shown_steps = row["steps"][:chat_max_steps]
        for step_idx, step in enumerate(shown_steps):
            action = step["action"]
            seconds = float(step["seconds"])
            img = renderer.render(action, min(max(seconds * 0.45, 0.35), 1.2), 0).image.convert("RGBA")
            tile = Image.new("RGBA", (chat_tile_w, chat_tile_h), (248, 248, 245, 255))
            tx = (tile.width - img.width) // 2
            ty = tile.height - img.height - 22
            tile.alpha_composite(img, (tx, ty))
            px = frame_x + step_idx * (chat_tile_w + chat_gap)
            py = y0 + 16
            chat_sheet.paste(tile.convert("RGB"), (px, py))
            label = f"{step_idx + 1}. {action} {seconds:g}s"
            chat_draw.text((px + 6, y1 - 30), label[:22], fill="#555555", font=small_font)
    chat_sheet.save(chat_motion_png_path)

    transition_rows_for_sheet = transition_preview_rows[:12]
    transition_alphas = [0.0, 0.25, 0.5, 0.75, 1.0]
    transition_tile_w = frame_w + 18
    transition_tile_h = frame_h + 34
    transition_left_w = 300
    transition_gap = 14
    transition_cell_h = max(210, transition_tile_h + 42)
    transition_sheet_w = margin * 2 + transition_left_w + len(transition_alphas) * (transition_tile_w + transition_gap) + 40
    transition_sheet_h = margin * 2 + header_h + max(1, len(transition_rows_for_sheet)) * transition_cell_h
    transition_sheet = Image.new("RGB", (transition_sheet_w, transition_sheet_h), "#f6f5f1")
    transition_draw = ImageDraw.Draw(transition_sheet)
    transition_draw.text((margin, margin), "桌宠动作切换预览 / 体检", fill="#222222", font=title_font)
    transition_draw.text(
        (margin, margin + 34),
        f"生成时间：{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}    展示最高跳变切换：{len(transition_rows_for_sheet)}/{len(transition_preview_rows)}",
        fill="#555555",
        font=text_font,
    )
    if not transition_rows_for_sheet:
        transition_draw.text((margin + 12, margin + header_h + 24), "没有需要展示的切换。", fill="#1f7a4d", font=text_font)
    for idx, row in enumerate(transition_rows_for_sheet):
        y0 = margin + header_h + idx * transition_cell_h
        y1 = y0 + transition_cell_h - 12
        transition_draw.rounded_rectangle((margin, y0, transition_sheet_w - margin, y1), radius=6, fill="#ffffff", outline="#d6d4cc")
        from_action = row["from"]
        to_action = row["to"]
        duration = transition_duration_seconds(launch_pet, from_action, to_action)
        masked = bool(getattr(launch_pet, "_use_masked_visual_transition", lambda *_args: False)(from_action, to_action))
        blend_mode = "无叠图" if duration <= 0 else ("局部蒙版" if masked else "整帧淡入")
        transition_draw.text((margin + 12, y0 + 12), f"{from_action} -> {to_action}", fill="#222222", font=header_font)
        transition_draw.text(
            (margin + 12, y0 + 42),
            f"跳变 {row['delta_pct']}% / 运行淡入 {duration:g}s / {blend_mode}",
            fill="#666666",
            font=text_font,
        )
        sources = "、".join(row.get("sources", [])[:3])
        if sources:
            transition_draw.text((margin + 12, y0 + 68), f"来自：{sources}", fill="#1f7a4d", font=small_font)
        from_img = renderer.render(from_action, 1.2, 0).image.convert("RGBA")
        to_img = renderer.render(to_action, 0.0, 0).image.convert("RGBA")
        if from_img.size != to_img.size:
            base_size = (max(from_img.width, to_img.width), max(from_img.height, to_img.height))
            normalized = []
            for img in (from_img, to_img):
                canvas = Image.new("RGBA", base_size, (1, 1, 1, 0))
                canvas.alpha_composite(img, ((base_size[0] - img.width) // 2, base_size[1] - img.height))
                normalized.append(canvas)
            from_img, to_img = normalized
        frame_x = margin + transition_left_w
        for alpha_idx, alpha in enumerate(transition_alphas):
            eased = alpha * alpha * (3.0 - 2.0 * alpha)
            if alpha <= 0.0:
                img = from_img
            elif duration <= 0:
                img = to_img
            elif alpha >= 1.0:
                img = to_img
            else:
                switch_helper = getattr(launch_pet, "_pose_switch_transition_images", None)
                switch_check = getattr(launch_pet, "_use_pose_switch_transition", None)
                if callable(switch_helper) and callable(switch_check) and switch_check(from_action, to_action):
                    img = switch_helper(from_img, to_img, eased, from_action, to_action)
                else:
                    blend_helper = getattr(launch_pet, "_blend_transition_images", None)
                    if callable(blend_helper):
                        img = blend_helper(from_img, to_img, eased, masked)
                    else:
                        img = Image.blend(from_img, to_img, eased)
            tile = Image.new("RGBA", (transition_tile_w, transition_tile_h), (248, 248, 245, 255))
            tx = (tile.width - img.width) // 2
            ty = tile.height - img.height - 22
            tile.alpha_composite(img, (tx, ty))
            px = frame_x + alpha_idx * (transition_tile_w + transition_gap)
            py = y0 + 16
            transition_sheet.paste(tile.convert("RGB"), (px, py))
            label = f"{int(alpha * 100)}%"
            transition_draw.text((px + 8, y1 - 30), label, fill="#555555", font=small_font)
    transition_sheet.save(transition_png_path)

    gif_w = max(420, frame_w + 230)
    gif_h = max(280, frame_h + 110)
    gif_frames = []
    gif_times = [i * 0.22 for i in range(14)]
    for idx, row in enumerate(rows, start=1):
        action = row["action"]
        label = row["label"]
        status = row["status"]
        for t in gif_times:
            frame = Image.new("RGB", (gif_w, gif_h), "#f6f5f1")
            fd = ImageDraw.Draw(frame)
            fd.text((18, 16), "动作动态预览", fill="#222222", font=title_font)
            fd.text((18, 52), f"{idx}/{len(rows)}  {label}  {action}", fill="#222222", font=header_font)
            fd.text((18, 80), status, fill=status_colors.get(status, "#555555"), font=text_font)
            fd.text((18, 108), "循环播放，用来观察动作是否生硬", fill="#666666", font=small_font)
            img = renderer.render(action, t, 0).image.convert("RGBA")
            tile = Image.new("RGBA", (frame_w + 34, frame_h + 34), (255, 255, 255, 255))
            tx = (tile.width - img.width) // 2
            ty = tile.height - img.height - 16
            tile.alpha_composite(img, (tx, ty))
            tile_rgb = tile.convert("RGB")
            paste_x = gif_w - tile_rgb.width - 22
            paste_y = gif_h - tile_rgb.height - 18
            frame.paste(tile_rgb, (paste_x, paste_y))
            gif_frames.append(frame)
    if gif_frames:
        gif_frames[0].save(
            gif_path,
            save_all=True,
            append_images=gif_frames[1:],
            duration=120,
            loop=0,
            optimize=True,
        )

    json_path.write_text(
        json.dumps(
            {
                "generated_at": datetime.now().isoformat(timespec="seconds"),
                "size": size,
                "renderer": renderer_mode,
                "preview_png": path_for_json(png_path),
                "preview_gif": path_for_json(gif_path),
                "sequence_preview_png": path_for_json(sequence_png_path),
                "chat_motion_preview_png": path_for_json(chat_motion_png_path),
                "transition_preview_png": path_for_json(transition_png_path),
                "summary": summary,
                "items": rows,
                "sequences": sequence_rows,
                "manual_actions": manual_action_rows,
                "reply_motions": reply_motion_rows,
                "wait_motions": wait_motion_rows,
                "transition_previews": transition_preview_rows,
            },
            ensure_ascii=False,
            indent=2,
        ),
        encoding="utf-8",
    )
    return png_path, gif_path, json_path, sequence_png_path, chat_motion_png_path, transition_png_path


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="生成桌宠动作预览图")
    parser.add_argument("--size", type=int, default=155)
    parser.add_argument("--renderer", default="puppet", choices=["puppet", "bibi", "source", "model_gf", "auto"])
    parser.add_argument("--output-dir", default=str(DEFAULT_OUTPUT_DIR))
    parser.add_argument("--check", action="store_true", help="打印动作体检摘要")
    parser.add_argument("--fail-on-unstable", action="store_true", help="关键动作缺动画或动作不平稳时返回失败")
    return parser.parse_args()


def main() -> int:
    args = parse_args()
    png_path, gif_path, json_path, sequence_png_path, chat_motion_png_path, transition_png_path = make_preview(args.size, args.renderer, Path(args.output_dir))
    print(f"动作预览图: {png_path}")
    print(f"动作动态图: {gif_path}")
    print(f"组合动作预览图: {sequence_png_path}")
    print(f"聊天动作预览图: {chat_motion_png_path}")
    print(f"动作切换预览图: {transition_png_path}")
    print(f"动作体检数据: {json_path}")
    data = json.loads(json_path.read_text(encoding="utf-8-sig"))
    summary = data.get("summary", {})
    if args.check or args.fail_on_unstable:
        print(
            "动作体检摘要: "
            f"动画 {summary.get('animated_actions', 0)}/{summary.get('total_actions', 0)}; "
            f"平稳 {summary.get('stable_actions', 0)}/{summary.get('total_actions', 0)}; "
            f"组合 {summary.get('sequence_ok', 0)}/{summary.get('sequence_total', 0)}; "
            f"手动 {summary.get('manual_action_ok', 0)}/{summary.get('manual_action_total', 0)}; "
            f"回复 {summary.get('reply_motion_ok', 0)}/{summary.get('reply_motion_total', 0)}; "
            f"等待 {summary.get('wait_motion_ok', 0)}/{summary.get('wait_motion_total', 0)}; "
            f"最大切换 {summary.get('sequence_max_transition_delta_pct', 0)}%; "
            f"手动最大 {summary.get('manual_action_max_transition_delta_pct', 0)}%; "
            f"回复最大 {summary.get('reply_motion_max_transition_delta_pct', 0)}%; "
            f"等待最大 {summary.get('wait_motion_max_transition_delta_pct', 0)}%"
        )
        missing = summary.get("required_missing_animation") or []
        unstable = summary.get("unstable_actions") or []
        sequence_issues = summary.get("sequence_issues") or []
        random_pool_issues = summary.get("random_pool_issues") or []
        manual_action_issues = summary.get("manual_action_issues") or []
        reply_motion_issues = summary.get("reply_motion_issues") or []
        wait_motion_issues = summary.get("wait_motion_issues") or []
        if missing:
            print("缺少动画:", ", ".join(missing))
        if unstable:
            print("需检查:", ", ".join(unstable))
        if sequence_issues:
            print("组合动作需检查:", ", ".join(sequence_issues))
        if random_pool_issues:
            print("随机互动需检查:", ", ".join(random_pool_issues))
        if manual_action_issues:
            print("手动动作需检查:", ", ".join(manual_action_issues))
        if reply_motion_issues:
            print("回复动作需检查:", ", ".join(reply_motion_issues))
        if wait_motion_issues:
            print("等待动作需检查:", ", ".join(wait_motion_issues))
    if args.fail_on_unstable and not summary.get("ok", False):
        return 2
    return 0


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