from __future__ import annotations

import argparse
from datetime import datetime
import hashlib
import json
import math
import random
import re
import shutil
from copy import deepcopy
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
import xml.etree.ElementTree as ET

from evaluate_dimension import (
    evaluate_readability,
    evaluate_structure,
    evaluate_visual,
    load_palette_from_assets,
    penalty_from_critiques,
)
from evaluate_schematic import evaluate_schematic
from extract_from_tex import apply_tex_hints, extract_research_terms, find_candidate_tex
from measure_schematic import measure_schematic
from render_schematic import drawio_install_hints, ensure_drawio_cli, render_artifacts
from schematic_writer import write_schematic_drawio
from spec_parser import default_schematic_spec, load_schematic_spec
from nano_banana_client import MAX_REFERENCE_IMAGES, nano_banana_generate_png, nano_banana_health_check
from png_compactor import compact_png, compacted_png_name
from utils import dump_yaml, fatal, info, is_safe_relative_path, load_yaml, skill_root, warn, write_text


def _cfg_snapshot(cfg: Dict[str, Any]) -> Dict[str, Any]:
    """
    Snapshot a small set of config values that are commonly auto-adjusted.
    Used for writing explainable “auto-fix deltas” into optimization_report.md.
    """
    out: Dict[str, Any] = {}

    def get(path: str, default: Any = None) -> Any:
        cur: Any = cfg
        for part in path.split("."):
            if not isinstance(cur, dict):
                return default
            cur = cur.get(part)
        return cur if cur is not None else default

    keys = [
        "renderer.canvas.width_px",
        "renderer.canvas.height_px",
        "renderer.drawio_border_px",
        "layout.title.enabled",
        "layout.auto.margin_x",
        "layout.auto.margin_y",
        "layout.auto.node_gap_x",
        "layout.auto.node_gap_y",
        "layout.auto.group_gap_x",
        "layout.auto.group_gap_y",
        "layout.font.node_label_size",
        "layout.font.edge_label_size",
    ]
    for k in keys:
        out[k] = get(k)
    return out


def _cfg_delta(before: Dict[str, Any], after: Dict[str, Any]) -> List[str]:
    a = _cfg_snapshot(before)
    b = _cfg_snapshot(after)
    out: List[str] = []
    for k in sorted(a.keys()):
        if a.get(k) != b.get(k):
            out.append(f"{k}: {a.get(k)!r} -> {b.get(k)!r}")
    return out


def _canvas_lock_enabled(canvas: Dict[str, Any]) -> bool:
    raw = canvas.get("lock_aspect_ratio", True)
    if isinstance(raw, bool):
        return raw
    if raw is None:
        return True
    return str(raw).strip().lower() not in {"0", "false", "off", "no"}


def _canvas_ratio_from_canvas(canvas: Dict[str, Any]) -> Optional[float]:
    try:
        width = int(canvas.get("width_px"))
        height = int(canvas.get("height_px"))
    except Exception:
        return None
    if width <= 0 or height <= 0:
        return None
    return float(width) / float(height)


def _round_ratio_dim(value: float) -> int:
    return max(1, int(round(float(value))))


def _lock_canvas_request(width_px: int, height_px: int, ratio: float) -> Tuple[int, int]:
    width = max(1, int(width_px))
    height = max(1, int(height_px))
    if ratio <= 0:
        return width, height

    keep_width_h = max(height, int(math.ceil(width / ratio)))
    keep_width = (int(math.ceil(keep_width_h * ratio)), keep_width_h)

    keep_height_w = max(width, int(math.ceil(height * ratio)))
    keep_height = (keep_height_w, int(math.ceil(keep_height_w / ratio)))

    return min((keep_width, keep_height), key=lambda item: (item[0] * item[1], item[0] + item[1]))


_NANO_BANANA_FONT_GUARD_MARKER = "## 字体与文字排版（系统强制保留）"


def _nano_banana_font_guard_lines(node_font: int, edge_font: int) -> List[str]:
    return [
        _NANO_BANANA_FONT_GUARD_MARKER,
        "",
        f"- 所有文字必须清晰可读，不溢出；节点文字建议字号≈{node_font}px，连线标签≈{edge_font}px（缩印后仍可读）。",
        "- 严禁在图内生成总标题/图题/caption；申请书中的图题会在版面阶段单独处理，这张图本身不需要也不允许顶部总标题。",
        "- 【重要】文字排版必须像打印稿：禁止任何文字扭曲/弯曲/透视变形/拉伸压缩/笔画融化；禁止旋转文字、禁止斜体/手写/艺术字。",
        "- 字体风格必须是标准无衬线印刷体（类似 思源黑体 / 微软雅黑 / Arial），字重正常；文字颜色用纯黑或深灰。",
        "- 所有文字放在水平的白色/浅色圆角标签框内，保留内边距；不要让文字直接压在线条/箭头/背景色块上。",
        "- 若节点/连线标签过长：优先拆成 2 行内短句（仍保持水平），不要通过缩到极小字号、压缩字距或扭曲字形来塞进框里。",
        "- 出图前请自检：每个标签内的汉字笔画清晰、无错位/断笔/粘连；若无法保证，宁可减少文字、扩大标签框或增加留白。",
        "- 若自定义 full prompt 与上述规则冲突，以本节规则为准；每一轮都必须保留本节全部约束。",
    ]


def _ensure_nano_banana_typography_guardrails(prompt: str, *, node_font: int, edge_font: int) -> str:
    text = str(prompt or "").strip()
    if _NANO_BANANA_FONT_GUARD_MARKER in text:
        return text.rstrip() + "\n"

    guard = "\n".join(_nano_banana_font_guard_lines(node_font, edge_font))
    output_anchor = "输出要求：只输出图片本身（不要输出解释文本）。"
    if output_anchor in text:
        head, tail = text.rsplit(output_anchor, 1)
        text = head.rstrip() + "\n\n" + guard + "\n\n" + output_anchor + tail
    elif text:
        text = text.rstrip() + "\n\n" + guard
    else:
        text = guard
    return text.rstrip() + "\n"


def _apply_canvas_ratio_lock_to_patch(
    base_cfg: Dict[str, Any],
    local_cfg: Dict[str, Any],
    *,
    locked_ratio: Optional[float] = None,
) -> Dict[str, Any]:
    out = deepcopy(local_cfg)
    renderer = out.get("renderer") if isinstance(out.get("renderer"), dict) else None
    canvas_patch = renderer.get("canvas") if isinstance(renderer, dict) and isinstance(renderer.get("canvas"), dict) else None
    if not isinstance(canvas_patch, dict):
        return out

    base_renderer = base_cfg.get("renderer") if isinstance(base_cfg.get("renderer"), dict) else {}
    base_canvas = base_renderer.get("canvas") if isinstance(base_renderer.get("canvas"), dict) else {}

    lock_enabled = _canvas_lock_enabled({**base_canvas, **canvas_patch})
    if not lock_enabled:
        return out

    ratio = locked_ratio if locked_ratio and locked_ratio > 0 else _canvas_ratio_from_canvas(base_canvas)
    if ratio is None or ratio <= 0:
        return out

    has_width = "width_px" in canvas_patch
    has_height = "height_px" in canvas_patch
    if has_width and not has_height:
        canvas_patch["height_px"] = _round_ratio_dim(int(canvas_patch["width_px"]) / ratio)
    elif has_height and not has_width:
        canvas_patch["width_px"] = _round_ratio_dim(int(canvas_patch["height_px"]) * ratio)
    elif has_width and has_height and locked_ratio is not None:
        width, height = _lock_canvas_request(int(canvas_patch["width_px"]), int(canvas_patch["height_px"]), ratio)
        canvas_patch["width_px"] = width
        canvas_patch["height_px"] = height
    return out


def _get_spec_canvas(spec_data: Dict[str, Any]) -> Optional[Tuple[int, int]]:
    root = spec_data.get("schematic", spec_data)
    if not isinstance(root, dict):
        return None
    canvas = root.get("canvas")
    if not isinstance(canvas, dict):
        return None
    try:
        width = int(canvas.get("width"))
        height = int(canvas.get("height"))
    except Exception:
        return None
    if width <= 0 or height <= 0:
        return None
    return width, height


def _set_spec_canvas(spec_data: Dict[str, Any], width: int, height: int) -> Dict[str, Any]:
    out = deepcopy(spec_data)
    root = out.setdefault("schematic", {}) if "schematic" in out or not {"title", "groups", "edges", "canvas"} & set(out.keys()) else out
    if not isinstance(root, dict):
        return out
    root["canvas"] = {"width": int(width), "height": int(height)}
    return out


def _preserve_locked_spec_canvas(previous_spec: Dict[str, Any], next_spec: Dict[str, Any]) -> Dict[str, Any]:
    prev_canvas = _get_spec_canvas(previous_spec)
    if prev_canvas is None:
        return next_spec
    prev_ratio = float(prev_canvas[0]) / float(prev_canvas[1])
    next_canvas = _get_spec_canvas(next_spec)
    if next_canvas is None:
        return _set_spec_canvas(next_spec, prev_canvas[0], prev_canvas[1])
    next_ratio = float(next_canvas[0]) / float(next_canvas[1]) if next_canvas[1] else 0.0
    if abs(next_ratio - prev_ratio) <= 1e-6:
        return next_spec
    width, height = _lock_canvas_request(next_canvas[0], next_canvas[1], prev_ratio)
    return _set_spec_canvas(next_spec, width, height)


def _locked_ratio_for_schematic(spec_data: Dict[str, Any], config: Dict[str, Any]) -> Optional[float]:
    spec_canvas = _get_spec_canvas(spec_data)
    if spec_canvas is not None and spec_canvas[1] > 0:
        return float(spec_canvas[0]) / float(spec_canvas[1])
    renderer = config.get("renderer") if isinstance(config.get("renderer"), dict) else {}
    canvas = renderer.get("canvas") if isinstance(renderer.get("canvas"), dict) else {}
    if not _canvas_lock_enabled(canvas):
        return None
    return _canvas_ratio_from_canvas(canvas)


def _wrap_label(s: str, *, max_chars: int) -> str:
    t = (s or "").strip()
    if not t:
        return t
    if "\n" in t or len(t) <= max_chars:
        return t
    max_chars = max(4, int(max_chars))

    # Prefer whitespace boundaries for English-ish labels.
    # NOTE: use r"\s" (not r"\\s") to detect actual whitespace.
    if re.search(r"\s", t):
        words = t.split()
        lines: List[str] = []
        cur: List[str] = []
        cur_len = 0
        for w in words:
            add = (1 if cur else 0) + len(w)
            if cur and (cur_len + add) > max_chars:
                lines.append(" ".join(cur))
                cur = [w]
                cur_len = len(w)
            else:
                cur.append(w)
                cur_len += add
        if cur:
            lines.append(" ".join(cur))
        return "\n".join(lines)

    # CJK-ish: hard wrap by characters.
    parts = [t[i : i + max_chars] for i in range(0, len(t), max_chars)]
    return "\n".join(parts)


def _truncate_label(s: str, *, max_chars: int) -> str:
    t = (s or "").strip()
    if not t:
        return t
    max_chars = max(6, int(max_chars))
    if len(t) <= max_chars:
        return t
    # Keep ASCII ellipsis to avoid introducing new Unicode in dumps.
    return t[: max(1, max_chars - 3)].rstrip() + "..."


def _apply_spec_variant(
    spec_dict: Dict[str, Any],
    *,
    mode: str,
    wrap_max_chars: int,
    truncate_max_chars: int,
) -> Tuple[Dict[str, Any], List[Dict[str, str]]]:
    """
    Safe spec-level variants that only touch labels (no ids, no edges).
    Return (new_spec, changes[]) where changes are small text diffs.
    """
    mode = str(mode or "none").strip().lower()
    if mode not in {"wrap", "truncate"}:
        return spec_dict, []

    root = spec_dict.get("schematic", spec_dict)
    if not isinstance(root, dict):
        return spec_dict, []

    out = deepcopy(spec_dict)
    root2 = out.get("schematic", out)
    if not isinstance(root2, dict):
        return spec_dict, []

    changes: List[Dict[str, str]] = []

    def transform(label: str) -> str:
        if mode == "wrap":
            return _wrap_label(label, max_chars=wrap_max_chars)
        return _truncate_label(label, max_chars=truncate_max_chars)

    def record(path: str, before: str, after: str) -> None:
        changes.append({"path": path, "before": before, "after": after})

    groups = root2.get("groups")
    if not isinstance(groups, list):
        return out, []

    def walk_nodes(children: Any, prefix: str) -> None:
        if not isinstance(children, list):
            return
        for i, node in enumerate(children):
            if not isinstance(node, dict):
                continue
            p = f"{prefix}.children[{i}]"
            lab = node.get("label")
            if isinstance(lab, str) and lab.strip():
                new_lab = transform(lab)
                if new_lab != lab:
                    node["label"] = new_lab
                    record(f"{p}.label", lab, new_lab)
            # Nested children (rare, but keep it safe).
            walk_nodes(node.get("children"), p)

    for gi, g in enumerate(groups):
        if not isinstance(g, dict):
            continue
        gp = f"schematic.groups[{gi}]"
        gl = g.get("label")
        if isinstance(gl, str) and gl.strip():
            new_gl = transform(gl)
            if new_gl != gl:
                g["label"] = new_gl
                record(f"{gp}.label", gl, new_gl)
        walk_nodes(g.get("children"), gp)

    return out, changes


def _make_run_dir(base_dir: Path, *, run_tag: Optional[str] = None) -> Path:
    def norm_tag(tag: Optional[str]) -> Optional[str]:
        if tag is None:
            return None
        s = str(tag).strip()
        if not s:
            return None
        if len(s) > 40:
            fatal("run_tag 过长（最多 40 个字符）：{!r}".format(s))
        # Keep it filesystem-safe and stable across platforms.
        if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", s):
            fatal("run_tag 不合法（仅允许字母/数字/下划线/连字符，且需以字母或数字开头）：{!r}".format(s))
        return s

    ts = datetime.now().strftime("%Y%m%d%H%M%S")
    tag = norm_tag(run_tag)
    suffix = f"__{tag}" if tag else ""
    base = base_dir / f"run_{ts}{suffix}"
    if not base.exists():
        base.mkdir(parents=True, exist_ok=False)
        return base

    # Very unlikely, but keep it deterministic and safe.
    for i in range(1, 1000):
        p = base_dir / f"run_{ts}{suffix}_{i:02d}"
        if not p.exists():
            p.mkdir(parents=True, exist_ok=False)
            return p
    fatal(f"无法创建 run 目录（疑似同秒并发/残留冲突）：{base_dir}")


def _resolve_output_dirs(out_dir: Path, config: Dict[str, Any]) -> Tuple[Path, Path, bool]:
    """
    Resolve output dirs.

    Returns:
        (work_dir, intermediate_dir, hide_intermediate)
    """
    output_cfg = config.get("output", {}) if isinstance(config.get("output", {}), dict) else {}
    hide = bool(output_cfg.get("hide_intermediate", True))
    intermediate_name = str(output_cfg.get("intermediate_dir", ".nsfc-schematic")).strip() or ".nsfc-schematic"
    if not is_safe_relative_path(intermediate_name):
        fatal(
            "output.intermediate_dir 必须是 output_dir 内的相对路径，且不得包含 `..` 或绝对/盘符路径："
            f"{intermediate_name!r}"
        )

    work_dir = out_dir
    if hide:
        intermediate_dir = work_dir / intermediate_name
        (intermediate_dir / "runs").mkdir(parents=True, exist_ok=True)
        (intermediate_dir / "legacy").mkdir(parents=True, exist_ok=True)
        _ensure_intermediate_gitignore(intermediate_dir)
    else:
        intermediate_dir = work_dir
    return work_dir, intermediate_dir, hide


def _ensure_intermediate_gitignore(intermediate_dir: Path) -> None:
    """
    Create a conservative .gitignore for intermediate dir.
    Don't overwrite if user already has one.
    """
    p = intermediate_dir / ".gitignore"
    if p.exists():
        return
    dirname = intermediate_dir.name or ".nsfc-schematic"
    p.write_text(
        f"# {dirname}/.gitignore\n"
        f"# 如需追踪版本历史，可删除此文件并 git add {dirname}/\n"
        "\n"
        "# 默认忽略规划/运行历史\n"
        "/planning/\n"
        "/runs/\n"
        "\n"
        "# 但保留配置\n"
        "!config_local.yaml\n",
        encoding="utf-8",
    )


def _report_filename(config: Dict[str, Any]) -> str:
    output_cfg = config.get("output", {}) if isinstance(config.get("output", {}), dict) else {}
    artifacts = output_cfg.get("artifacts", {}) if isinstance(output_cfg.get("artifacts", {}), dict) else {}
    v = artifacts.get("report") or output_cfg.get("report_filename") or "optimization_report.md"
    return str(v)


def _spec_latest_filename(config: Dict[str, Any]) -> str:
    output_cfg = config.get("output", {}) if isinstance(config.get("output", {}), dict) else {}
    artifacts = output_cfg.get("artifacts", {}) if isinstance(output_cfg.get("artifacts", {}), dict) else {}
    return str(artifacts.get("spec_latest") or "spec_latest.yaml")


def _deep_merge_dict(base: Dict[str, Any], patch: Dict[str, Any]) -> Dict[str, Any]:
    out: Dict[str, Any] = deepcopy(base)
    for k, v in (patch or {}).items():
        if isinstance(v, dict) and isinstance(out.get(k), dict):
            out[k] = _deep_merge_dict(out[k], v)  # type: ignore[arg-type]
        else:
            out[k] = deepcopy(v)
    return out


def _load_config_local(config_local_path: Path) -> Optional[Dict[str, Any]]:
    if not config_local_path.exists():
        return None
    if not config_local_path.is_file():
        fatal(f"config_local 不是文件：{config_local_path}")
    return load_yaml(config_local_path)


def _sanitize_config_local(local_cfg: Dict[str, Any]) -> Dict[str, Any]:
    """
    仅允许实例级白名单覆盖，避免污染全局配置或引入不安全字段。
    """
    if not isinstance(local_cfg, dict):
        fatal("config_local.yaml 根必须是 mapping")

    def as_int(v: Any, where: str, low: int, high: int) -> int:
        try:
            n = int(v)
        except Exception:
            fatal(f"config_local.{where} 必须是整数")
        if n < low or n > high:
            fatal(f"config_local.{where} 超出范围：{n}（允许 {low}..{high}）")
        return n

    def as_str(v: Any, where: str) -> str:
        if not isinstance(v, str) or not v.strip():
            fatal(f"config_local.{where} 必须是非空字符串")
        return v.strip()

    def as_bool(v: Any, where: str) -> bool:
        if not isinstance(v, bool):
            fatal(f"config_local.{where} 必须是布尔值 true/false")
        return bool(v)

    allowed_tops = {"renderer", "layout", "color_scheme", "evaluation"}
    bad = [k for k in local_cfg.keys() if k not in allowed_tops]
    if bad:
        fatal(
            "config_local.yaml 包含未允许的顶层字段（为安全起见已拒绝）："
            + ", ".join(bad[:20])
            + (" ..." if len(bad) > 20 else "")
        )

    out: Dict[str, Any] = {}

    renderer = local_cfg.get("renderer")
    if isinstance(renderer, dict):
        r_out: Dict[str, Any] = {}
        canvas = renderer.get("canvas")
        if isinstance(canvas, dict):
            c_out: Dict[str, Any] = {}
            if "width_px" in canvas:
                c_out["width_px"] = as_int(canvas.get("width_px"), "renderer.canvas.width_px", 1200, 12000)
            if "height_px" in canvas:
                c_out["height_px"] = as_int(canvas.get("height_px"), "renderer.canvas.height_px", 900, 12000)
            if "lock_aspect_ratio" in canvas:
                c_out["lock_aspect_ratio"] = as_bool(canvas.get("lock_aspect_ratio"), "renderer.canvas.lock_aspect_ratio")
            if c_out:
                r_out["canvas"] = c_out
        drawio = renderer.get("drawio")
        if isinstance(drawio, dict) and ("cli_path" in drawio):
            d_out: Dict[str, Any] = {}
            # Allow per-output-dir overrides for CLI path (useful on Windows / non-standard installs).
            d_out["cli_path"] = as_str(drawio.get("cli_path"), "renderer.drawio.cli_path")
            r_out["drawio"] = d_out
        if "internal_routing" in renderer:
            mode = as_str(renderer.get("internal_routing"), "renderer.internal_routing").strip().lower()
            if mode not in {"straight", "orthogonal"}:
                fatal("config_local.renderer.internal_routing 不合法（允许 straight|orthogonal）")
            r_out["internal_routing"] = mode
        stroke = renderer.get("stroke")
        if isinstance(stroke, dict):
            s_out: Dict[str, Any] = {}
            if "width_px" in stroke:
                s_out["width_px"] = as_int(stroke.get("width_px"), "renderer.stroke.width_px", 1, 20)
            if s_out:
                r_out["stroke"] = s_out
        if r_out:
            out["renderer"] = r_out

    layout = local_cfg.get("layout")
    if isinstance(layout, dict):
        l_out: Dict[str, Any] = {}
        if "direction" in layout:
            d = as_str(layout.get("direction"), "layout.direction")
            if d not in {"top-to-bottom", "left-to-right", "bottom-to-top"}:
                fatal("config_local.layout.direction 不合法（允许 top-to-bottom|left-to-right|bottom-to-top）")
            l_out["direction"] = d
        font = layout.get("font")
        if isinstance(font, dict):
            f_out: Dict[str, Any] = {}
            if "node_label_size" in font:
                f_out["node_label_size"] = as_int(font.get("node_label_size"), "layout.font.node_label_size", 14, 60)
            if "edge_label_size" in font:
                f_out["edge_label_size"] = as_int(font.get("edge_label_size"), "layout.font.edge_label_size", 14, 60)
            if f_out:
                l_out["font"] = f_out
        if "auto_edges" in layout:
            m = as_str(layout.get("auto_edges"), "layout.auto_edges").strip().lower()
            if m not in {"minimal", "off", "none"}:
                fatal("config_local.layout.auto_edges 不合法（允许 minimal|off|none）")
            l_out["auto_edges"] = "off" if m in {"off", "none"} else "minimal"
        canvas_fit = layout.get("canvas_fit")
        if isinstance(canvas_fit, dict):
            cf_out: Dict[str, Any] = {}
            if "center_content" in canvas_fit:
                cf_out["center_content"] = as_bool(canvas_fit.get("center_content"), "layout.canvas_fit.center_content")
            if cf_out:
                l_out["canvas_fit"] = cf_out
        auto = layout.get("auto")
        if isinstance(auto, dict):
            a_out: Dict[str, Any] = {}
            if "margin_x" in auto:
                a_out["margin_x"] = as_int(auto.get("margin_x"), "layout.auto.margin_x", 0, 800)
            if "margin_y" in auto:
                a_out["margin_y"] = as_int(auto.get("margin_y"), "layout.auto.margin_y", 0, 800)
            if "node_gap_x" in auto:
                a_out["node_gap_x"] = as_int(auto.get("node_gap_x"), "layout.auto.node_gap_x", 0, 300)
            if "node_gap_y" in auto:
                a_out["node_gap_y"] = as_int(auto.get("node_gap_y"), "layout.auto.node_gap_y", 0, 300)
            if "group_gap_x" in auto:
                a_out["group_gap_x"] = as_int(auto.get("group_gap_x"), "layout.auto.group_gap_x", 0, 800)
            if "group_gap_y" in auto:
                a_out["group_gap_y"] = as_int(auto.get("group_gap_y"), "layout.auto.group_gap_y", 0, 800)
            if "max_cols" in auto:
                a_out["max_cols"] = as_int(auto.get("max_cols"), "layout.auto.max_cols", 1, 8)
            if a_out:
                l_out["auto"] = a_out
        if l_out:
            out["layout"] = l_out

    cs = local_cfg.get("color_scheme")
    if isinstance(cs, dict) and "name" in cs:
        name = as_str(cs.get("name"), "color_scheme.name")
        if name not in {"academic-blue", "transformer-style", "nature-biomedical"}:
            fatal("config_local.color_scheme.name 仅允许 {academic-blue, transformer-style, nature-biomedical}。")
        out["color_scheme"] = {"name": name}

    ev = local_cfg.get("evaluation")
    if isinstance(ev, dict):
        e_out: Dict[str, Any] = {}
        if "stop_strategy" in ev:
            s = as_str(ev.get("stop_strategy"), "evaluation.stop_strategy")
            if s not in {"none", "plateau", "ai_critic"}:
                fatal("config_local.evaluation.stop_strategy 不合法（允许 none|plateau|ai_critic）")
            e_out["stop_strategy"] = s
        if "max_rounds" in ev:
            e_out["max_rounds"] = as_int(ev.get("max_rounds"), "evaluation.max_rounds", 1, 20)
        sv = ev.get("spec_variants")
        if isinstance(sv, dict):
            sv_out: Dict[str, Any] = {}
            if "mode" in sv:
                m = as_str(sv.get("mode"), "evaluation.spec_variants.mode").strip().lower()
                if m not in {"none", "wrap", "truncate", "candidates"}:
                    fatal("config_local.evaluation.spec_variants.mode 不合法（允许 none|wrap|truncate|candidates）")
                sv_out["mode"] = m
            if "wrap_max_chars" in sv:
                sv_out["wrap_max_chars"] = as_int(sv.get("wrap_max_chars"), "evaluation.spec_variants.wrap_max_chars", 4, 40)
            if "truncate_max_chars" in sv:
                sv_out["truncate_max_chars"] = as_int(
                    sv.get("truncate_max_chars"), "evaluation.spec_variants.truncate_max_chars", 6, 60
                )
            if "allow_in_ai_critic" in sv:
                sv_out["allow_in_ai_critic"] = bool(sv.get("allow_in_ai_critic", False))
            if sv_out:
                e_out["spec_variants"] = sv_out
        exploration = ev.get("exploration")
        if isinstance(exploration, dict):
            ex_out: Dict[str, Any] = {}
            if "enabled" in exploration:
                ex_out["enabled"] = as_bool(exploration.get("enabled"), "evaluation.exploration.enabled")
            if "candidates_per_round" in exploration:
                ex_out["candidates_per_round"] = as_int(
                    exploration.get("candidates_per_round"),
                    "evaluation.exploration.candidates_per_round",
                    1,
                    8,
                )
            if "seed" in exploration:
                ex_out["seed"] = as_int(exploration.get("seed"), "evaluation.exploration.seed", 0, 2_000_000_000)
            if ex_out:
                e_out["exploration"] = ex_out
        if e_out:
            out["evaluation"] = e_out

    return out


def _apply_config_local(base_cfg: Dict[str, Any], local_cfg: Optional[Dict[str, Any]]) -> Dict[str, Any]:
    if not local_cfg:
        return deepcopy(base_cfg)
    sanitized = _sanitize_config_local(local_cfg)
    sanitized = _apply_canvas_ratio_lock_to_patch(base_cfg, sanitized)
    return _deep_merge_dict(base_cfg, sanitized)


def _ai_root(intermediate_dir: Path) -> Path:
    p = intermediate_dir / "ai"
    p.mkdir(parents=True, exist_ok=True)
    return p


def _ai_active_run_file(ai_root: Path) -> Path:
    return ai_root / "ACTIVE_RUN.txt"


def _read_active_run(ai_root: Path) -> Optional[str]:
    p = _ai_active_run_file(ai_root)
    if not p.exists() or not p.is_file():
        return None
    s = p.read_text(encoding="utf-8").strip()
    return s or None


def _set_active_run(ai_root: Path, run_dir: Path) -> None:
    _ai_active_run_file(ai_root).write_text(run_dir.name + "\n", encoding="utf-8")


def _clear_active_run(ai_root: Path) -> None:
    p = _ai_active_run_file(ai_root)
    if p.exists():
        try:
            p.unlink()
        except Exception:
            pass


def _ai_run_root(ai_root: Path, run_dir: Path) -> Path:
    p = ai_root / run_dir.name
    p.mkdir(parents=True, exist_ok=True)
    return p


def _ai_response_path(ai_run_root: Path) -> Path:
    return ai_run_root / "ai_critic_response.yaml"


def _ai_request_path(ai_run_root: Path) -> Path:
    return ai_run_root / "ai_critic_request.md"


def _ai_pack_dir(ai_run_root: Path, round_idx: int) -> Path:
    return ai_run_root / f"ai_pack_round_{round_idx:02d}"


def _safe_move_to_dir(src: Path, dst_dir: Path) -> Optional[Path]:
    if not src.exists():
        return None
    dst_dir.mkdir(parents=True, exist_ok=True)
    dst = dst_dir / src.name
    if not dst.exists():
        shutil.move(str(src), str(dst))
        return dst
    # Avoid overwriting: append a numeric suffix.
    stem = src.stem
    suffix = src.suffix
    for i in range(1, 1000):
        cand = dst_dir / f"{stem}_{i:02d}{suffix}"
        if not cand.exists():
            shutil.move(str(src), str(cand))
            return cand
    fatal(f"无法移动到 legacy（目标冲突过多）：{src}")
    return None


def _cleanup_work_dir(work_dir: Path, intermediate_dir: Path, artifacts: Dict[str, Any], hide: bool) -> None:
    """
    Cleanup known intermediate residues from work_dir root.

    When hide_intermediate=true, only keep deliverables in work_dir root and
    move known intermediate files/dirs into intermediate_dir/legacy/.
    """
    if not hide:
        return

    legacy_root = intermediate_dir / "legacy"
    legacy_runs = legacy_root / "runs"
    legacy_files = legacy_root / "files"

    keep = {
        str(artifacts.get("drawio", "schematic.drawio")),
        str(artifacts.get("svg", "schematic.svg")),
        str(artifacts.get("png", "schematic.png")),
        str(artifacts.get("pdf", "schematic.pdf")),
    }
    known_intermediate = {
        "optimization_report.md",
        "spec.yaml",
        "spec_latest.yaml",
        "spec_seqccs.yaml",
        "config_used_best.yaml",
        "config_used_latest.yaml",
        "evaluation_best.json",
        "evaluation_latest.json",
        "config_local.yaml",
        "schematic.drawio.unescaped",
    }

    for p in work_dir.iterdir():
        if p.name.startswith("."):
            continue
        if p.name in keep:
            continue

        if p.is_dir() and p.name.startswith("run_"):
            _safe_move_to_dir(p, legacy_runs)
            continue

        if p.is_file():
            if p.name in known_intermediate:
                _safe_move_to_dir(p, legacy_files)
                continue
            if p.suffix == ".unescaped":
                _safe_move_to_dir(p, legacy_files)
                continue


def _prune_history_runs(intermediate_dir: Path, max_runs: int) -> None:
    if max_runs <= 0:
        return
    runs_dir = intermediate_dir / "runs"
    if not runs_dir.exists() or not runs_dir.is_dir():
        return

    runs = [p for p in runs_dir.iterdir() if p.is_dir() and p.name.startswith("run_")]
    # Names are timestamped; lexical sort is good enough and stable.
    runs.sort(key=lambda p: p.name)
    if len(runs) <= max_runs:
        return
    for p in runs[: len(runs) - max_runs]:
        shutil.rmtree(p, ignore_errors=True)


def _validate_drawio_xml(drawio_path: Path) -> None:
    try:
        ET.parse(drawio_path)
    except ET.ParseError as exc:
        line, col = getattr(exc, "position", (None, None))
        pos = f"（line={line}, col={col}）" if line is not None and col is not None else ""
        raise ValueError(
            "生成的 .drawio 不是合法 XML，已阻断渲染与评估。"
            f"{pos}\n"
            f"- file: {drawio_path}\n"
            "- 常见原因：label 中包含未转义的 `<br>`/`<`/`>`（例如把换行直接替换成 `<br>` 但未做 XML escape）。\n"
            "- 建议：检查 `schematic_writer.py` 的 XML 转义逻辑，或在 label 中用 `\\n` 并确保最终写入的是 `&lt;br&gt;`。"
        ) from exc


def _build_nano_banana_prompt(spec: Any, cfg_used: Dict[str, Any]) -> str:
    """
    Deterministic prompt builder for Nano Banana (Gemini image model).

    Notes:
    - This is a *default* prompt; the host AI can still refine it via parallel-vibe / ai_critic.
    - Keep it deterministic and avoid external dependencies.
    """
    canvas_w = int(getattr(spec, "canvas_width", 3200) or 3200)
    canvas_h = int(getattr(spec, "canvas_height", 2000) or 2000)
    direction = str((cfg_used.get("layout", {}) or {}).get("direction", "top-to-bottom") or "top-to-bottom")
    node_font = int(((cfg_used.get("layout", {}) or {}).get("font", {}) or {}).get("node_label_size", 26) or 26)
    edge_font = int(((cfg_used.get("layout", {}) or {}).get("font", {}) or {}).get("edge_label_size", 22) or 22)
    color_name = str((cfg_used.get("color_scheme", {}) or {}).get("name", "") or "").strip()

    lines: List[str] = []
    lines.append("你是一名科研插图设计师。请生成一张“NSFC 申请书可直接嵌入”的中文原理图/机制图。")
    lines.append("")
    lines.append("硬性要求：")
    lines.append("- 输出 1 张 PNG 图片（白底），视觉风格接近矢量图，线条清晰，适合打印/缩印。")
    lines.append("- 输出分辨率为 4K 级（长边>=3840px；按画布比例缩放，必要时以白底补边保持内容完整）。")
    lines.append(f"- 画布比例必须严格匹配 {canvas_w}:{canvas_h}，内容需居中且四周留白均衡。")
    lines.append(f"- 所有文字必须清晰可读，不溢出；节点文字建议字号≈{node_font}px，连线标签≈{edge_font}px（缩印后仍可读）。")
    lines.append("- 不要水印/签名/LOGO/背景纹理；不要 3D、不要拟物、不要照片风。")
    lines.append("")
    lines.append("布局要求：")
    lines.append(f"- 主方向：{direction}（尽量对齐/成列）。")
    lines.append("- 节点使用圆角矩形；分组使用淡色大框 + 分组标题；主链用粗箭头，辅助/验证用细箭头/虚线。")
    if color_name:
        lines.append(f"- 配色：{color_name}（学术风、低饱和，保证对比度）。")
    else:
        lines.append("- 配色：学术蓝/灰为主（低饱和），保证文字与背景对比度。")
    lines.append("- 严禁在图内绘制总标题/图题/caption；若需要图题，请留给标书正文或图注系统处理。")
    lines.append("")
    lines.append("图内容（必须覆盖以下分组、节点与连接关系；文字尽量短）：")
    lines.append("- 文字内容必须严格使用下文给出的分组/节点/连线标签原文，不要改写/翻译/增删前后缀。")

    id2label: Dict[str, str] = {}
    groups = getattr(spec, "groups", None)
    if isinstance(groups, list):
        for g in groups:
            gl = str(getattr(g, "label", "") or "").strip() or "（无标题分组）"
            lines.append(f"- 分组：{gl}")
            children = getattr(g, "children", None)
            if isinstance(children, list):
                for n in children:
                    nid = str(getattr(n, "id", "") or "").strip()
                    lab = str(getattr(n, "label", "") or "").strip()
                    if lab:
                        lines.append(f"  - 节点：{lab}")
                    if nid and lab:
                        id2label[nid] = lab

    edges = getattr(spec, "edges", None)
    if isinstance(edges, list) and edges:
        lines.append("")
        lines.append("连接关系（箭头方向必须正确）：")
        for e in edges:
            f_id = str(getattr(e, "source", "") or getattr(e, "from", "") or "").strip()
            t_id = str(getattr(e, "target", "") or getattr(e, "to", "") or "").strip()
            el = str(getattr(e, "label", "") or "").strip()
            f_show = id2label.get(f_id, f_id)
            t_show = id2label.get(t_id, t_id)
            if f_show and t_show and (f_show != f_id or t_show != t_id):
                pair = f"{f_show}（{f_id}） → {t_show}（{t_id}）"
            else:
                pair = f"{f_id} → {t_id}"
            if el:
                lines.append(f"- {pair}（{el}）")
            else:
                lines.append(f"- {pair}")

    lines.append("")
    lines.append("输出要求：只输出图片本身（不要输出解释文本）。")
    return _ensure_nano_banana_typography_guardrails("\n".join(lines), node_font=node_font, edge_font=edge_font)


def _load_input_spec(args: argparse.Namespace, config: Dict[str, Any], request_dir: Path) -> Dict[str, Any]:
    if args.spec_file:
        if not args.spec_file.exists() or not args.spec_file.is_file():
            fatal(f"spec_file 不存在或不是文件：{args.spec_file}")
        info(f"使用用户 spec: {args.spec_file}")
        return load_yaml(args.spec_file)

    spec = default_schematic_spec()

    tex_path: Optional[Path] = None
    if args.proposal_file:
        if not args.proposal_file.exists() or not args.proposal_file.is_file():
            fatal(f"proposal_file 不存在或不是文件：{args.proposal_file}")
        tex_path = args.proposal_file
    elif args.proposal_path:
        if not args.proposal_path.exists():
            fatal(f"proposal_path 不存在：{args.proposal_path}")
        tex_path = find_candidate_tex(args.proposal_path)

    if tex_path:
        eval_mode = str((config.get("evaluation", {}) or {}).get("evaluation_mode", "heuristic")).strip().lower()
        if eval_mode == "ai":
            try:
                from ai_extract_tex import AI_TEX_RESPONSE_JSON, consume_tex_extraction, prepare_tex_extraction_request

                req, resp = prepare_tex_extraction_request(tex_path, config=config, output_dir=request_dir)
                payload = consume_tex_extraction(resp)
                if payload and isinstance(payload.get("spec_draft"), dict) and payload.get("spec_draft"):
                    info(f"已检测到 AI TEX 提取响应：{resp.name}，将直接使用 spec_draft")
                    return payload["spec_draft"]
                info(f"AI TEX 提取协议已生成：{req.name} + {AI_TEX_RESPONSE_JSON}（未检测到有效响应，已降级为正则抽取）")
            except Exception as exc:
                warn(f"AI TEX 提取协议生成/消费失败，已降级为正则抽取（{exc}）")

        # Keep the extraction budget configurable (single source of truth: config.yaml:planning.extraction.max_terms).
        extraction_cfg = (config.get("planning", {}) or {}).get("extraction", {})
        extraction_cfg = extraction_cfg if isinstance(extraction_cfg, dict) else {}
        try:
            max_terms = int(extraction_cfg.get("max_terms", 8) or 8)
        except Exception:
            max_terms = 8
        max_terms = max(1, min(50, max_terms))
        terms = extract_research_terms(tex_path, max_terms=max_terms)
        spec = apply_tex_hints(spec, terms)
        root = spec.get("schematic", spec)
        if isinstance(root, dict):
            root["title"] = "NSFC 原理图（自动生成）"
        info(f"已从 TEX 抽取 {len(terms)} 个术语用于填充示例节点")
    else:
        info("未提供可用 TEX，使用默认示例 spec")

    return spec


def _apply_auto_fixes(cfg: Dict[str, Any], evaluation: Dict[str, Any]) -> Dict[str, Any]:
    out = deepcopy(cfg)
    counts = evaluation.get("counts", {}) if isinstance(evaluation.get("counts", {}), dict) else {}
    p0 = int(counts.get("p0", 0))
    p1 = int(counts.get("p1", 0))

    canvas = out["renderer"]["canvas"]
    layout_font = out["layout"]["font"]
    auto = out["layout"]["auto"]
    locked_ratio = _canvas_ratio_from_canvas(canvas) if _canvas_lock_enabled(canvas) else None

    defects = evaluation.get("defects", [])
    if not isinstance(defects, list):
        defects = []

    layout_mode = evaluation.get("layout_mode", {}) if isinstance(evaluation.get("layout_mode", {}), dict) else {}
    explicit_layout = bool(layout_mode.get("explicit_layout", False))
    explicit_ratio = float(layout_mode.get("explicit_layout_ratio", 0.0) or 0.0)
    # Semi-explicit layouts still behave closer to explicit ones; be conservative to avoid “越修越乱”.
    semi_explicit = explicit_layout or (explicit_ratio >= 0.35)

    # AI-mode: apply host AI suggestions (if provided) with strict safety guards.
    if str(evaluation.get("evaluation_source", "")).strip().lower() == "ai":
        thresholds = out.get("evaluation", {}).get("thresholds", {})
        thresholds = thresholds if isinstance(thresholds, dict) else {}
        min_font = int(thresholds.get("min_font_px", 18) or 18)
        min_edge_font = int(thresholds.get("min_edge_font_px", min_font) or min_font)

        allowed_gaps = {"node_gap_x", "node_gap_y", "group_gap_x", "group_gap_y"}
        allowed_fonts = {"node_label_size", "edge_label_size"}

        changed = False
        applied = 0
        for d in defects:
            if not isinstance(d, dict):
                continue
            sug = d.get("suggestion")
            if not isinstance(sug, dict):
                continue
            action = str(sug.get("action", "")).strip()
            if not action:
                continue

            if applied >= 6:
                break

            if action == "increase_canvas":
                try:
                    dw = int(sug.get("delta_w", sug.get("delta", 0)) or 0)
                    dh = int(sug.get("delta_h", sug.get("delta", 0)) or 0)
                except Exception:
                    continue
                if dw:
                    dw = max(40, min(300, dw))
                    canvas["width_px"] = int(canvas["width_px"]) + dw
                    changed = True
                    applied += 1
                if dh:
                    dh = max(40, min(300, dh))
                    canvas["height_px"] = int(canvas["height_px"]) + dh
                    changed = True
                    applied += 1
                if changed and locked_ratio is not None and locked_ratio > 0:
                    width, height = _lock_canvas_request(int(canvas["width_px"]), int(canvas["height_px"]), locked_ratio)
                    canvas["width_px"] = width
                    canvas["height_px"] = height
                continue

            if action == "increase_gap":
                param = str(sug.get("parameter", "")).strip()
                if param not in allowed_gaps:
                    continue
                try:
                    delta = int(sug.get("delta", 0) or 0)
                except Exception:
                    continue
                if not delta:
                    continue
                delta = max(2, min(20, delta))
                auto[param] = int(auto.get(param, 0)) + delta
                changed = True
                applied += 1
                continue

            if action == "increase_font":
                param = str(sug.get("parameter", "")).strip()
                if param not in allowed_fonts:
                    continue
                if semi_explicit and param == "node_label_size":
                    # 显式布局模式下避免节点整体膨胀。
                    continue
                try:
                    delta = int(sug.get("delta", 0) or 0)
                except Exception:
                    continue
                if not delta:
                    continue
                delta = max(1, min(4, delta))
                layout_font[param] = int(layout_font.get(param, 0) or 0) + delta
                changed = True
                applied += 1
                continue

        if changed:
            layout_font["node_label_size"] = max(min_font, int(layout_font.get("node_label_size", min_font) or min_font))
            layout_font["edge_label_size"] = max(
                min_edge_font, int(layout_font.get("edge_label_size", min_edge_font) or min_edge_font)
            )
            return out

    dims_p0 = {str(d.get("dimension", "")) for d in defects if isinstance(d, dict) and str(d.get("severity")) == "P0"}
    dims_p1 = {str(d.get("dimension", "")) for d in defects if isinstance(d, dict) and str(d.get("severity")) == "P1"}
    dims_any = {str(d.get("dimension", "")) for d in defects if isinstance(d, dict)}

    thresholds = out.get("evaluation", {}).get("thresholds", {})
    if not isinstance(thresholds, dict):
        thresholds = {}

    min_font = int(thresholds.get("min_font_px", 18))
    min_edge_font = int(thresholds.get("min_edge_font_px", min_font))
    print_scale_check = bool(thresholds.get("print_scale_check", False))

    # Readability repair: prioritize edge labels, avoid inflating node font on explicit layout.
    if "print_readability" in dims_any or "text_readability" in dims_any:
        if print_scale_check:
            scale = float(thresholds.get("print_scale_ratio", 0.5))
            min_after_scale = int(thresholds.get("print_scale_min_font", 10))
            suggest = int(math.ceil(min_after_scale / max(1e-9, scale)))
        else:
            suggest = int(thresholds.get("warn_edge_font_px", thresholds.get("warn_font_px", 24)) or 24)

        cur_edge = int(layout_font.get("edge_label_size", min_edge_font))
        if cur_edge < suggest:
            step = 2 if (suggest - cur_edge) > 2 else 1
            layout_font["edge_label_size"] = cur_edge + step

        # 仅当节点字号本身低于硬下限时修复；不再跟随 edge 一起暴涨。
        cur_node = int(layout_font.get("node_label_size", min_font))
        if cur_node < min_font:
            layout_font["node_label_size"] = min_font

    hard_layout_dims = {"canvas_overflow", "node_overlap", "edge_integrity", "edge_crossings", "edge_node_intersection"}
    if bool(hard_layout_dims & (dims_p0 | dims_p1)) or p0 > 0:
        # 显式布局下保守处理，优先微调而不是大幅放大。
        if semi_explicit:
            if locked_ratio is not None and locked_ratio > 0:
                width, height = _lock_canvas_request(int(canvas["width_px"]) + 60, int(canvas["height_px"]) + 60, locked_ratio)
                canvas["width_px"] = width
                canvas["height_px"] = height
            else:
                canvas["width_px"] = int(canvas["width_px"]) + 60
                canvas["height_px"] = int(canvas["height_px"]) + 60
            auto["group_gap_y"] = int(auto.get("group_gap_y", 80)) + 4
        else:
            if locked_ratio is not None and locked_ratio > 0:
                width, height = _lock_canvas_request(int(canvas["width_px"]) + 140, int(canvas["height_px"]) + 140, locked_ratio)
                canvas["width_px"] = width
                canvas["height_px"] = height
            else:
                canvas["width_px"] = int(canvas["width_px"]) + 140
                canvas["height_px"] = int(canvas["height_px"]) + 140
            auto["node_gap_x"] = int(auto.get("node_gap_x", 40)) + 8
            auto["node_gap_y"] = int(auto.get("node_gap_y", 28)) + 8
            auto["group_gap_y"] = int(auto.get("group_gap_y", 80)) + 8
        return out

    if {"edge_label_occlusion", "edge_node_proximity"} & dims_any:
        inc = 3 if semi_explicit else 6
        auto["node_gap_x"] = int(auto.get("node_gap_x", 40)) + inc
        auto["node_gap_y"] = int(auto.get("node_gap_y", 28)) + inc
        auto["group_gap_y"] = int(auto.get("group_gap_y", 80)) + inc
        cur_edge = int(layout_font.get("edge_label_size", min_edge_font))
        layout_font["edge_label_size"] = max(cur_edge, min_edge_font) + 1
        return out

    if "space_usage" in dims_any:
        # 对自动布局收紧边距，提升画布利用率。
        dec = 6 if semi_explicit else 10
        auto["margin_x"] = max(40, int(auto.get("margin_x", 110)) - dec)
        auto["margin_y"] = max(40, int(auto.get("margin_y", 120)) - dec)
        if int(out.get("renderer", {}).get("drawio_border_px", 4)) > 0:
            out["renderer"]["drawio_border_px"] = max(0, int(out["renderer"]["drawio_border_px"]) - 1)
        return out

    if "title_layout_overlap" in dims_any:
        out.setdefault("layout", {}).setdefault("title", {})["enabled"] = False
        return out

    if p1 > 0 and {"visual_balance", "overall_aesthetics", "text_overflow"} & dims_p1:
        if locked_ratio is not None and locked_ratio > 0:
            width, height = _lock_canvas_request(int(canvas["width_px"]), int(canvas["height_px"]) + 100, locked_ratio)
            canvas["width_px"] = width
            canvas["height_px"] = height
        else:
            canvas["height_px"] = int(canvas["height_px"]) + 100
        auto["group_gap_y"] = int(auto.get("group_gap_y", 80)) + 8
        return out

    # 轻微恢复字号，让通过后的图观感更好（不强制推高）。
    current = int(layout_font.get("node_label_size", min_font))
    target = int(out["layout"]["font"].get("node_label_size_target", current))
    if current < target and "text_overflow" not in dims_any and not semi_explicit:
        layout_font["node_label_size"] = current + 1

    layout_font["edge_label_size"] = max(min_edge_font, int(layout_font.get("edge_label_size", min_edge_font)))
    layout_font["node_label_size"] = max(min_font, int(layout_font.get("node_label_size", min_font)))
    return out


def _load_json_if_exists(path: Path) -> Optional[Dict[str, Any]]:
    if not path.exists() or not path.is_file():
        return None
    try:
        raw = path.read_text(encoding="utf-8")
    except Exception:
        return None
    try:
        data = json.loads(raw)
    except Exception:
        return None
    return data if isinstance(data, dict) else None


def _copy_if_exists(src: Path, dst: Path) -> None:
    if not src.exists() or not src.is_file():
        return
    dst.parent.mkdir(parents=True, exist_ok=True)
    dst.write_bytes(src.read_bytes())


def _sha256_file(path: Path) -> Optional[str]:
    if not path.exists() or not path.is_file():
        return None
    h = hashlib.sha256()
    with path.open("rb") as f:
        while True:
            chunk = f.read(1024 * 1024)
            if not chunk:
                break
            h.update(chunk)
    return h.hexdigest()


def _dedup_paths(paths: List[Path]) -> List[Path]:
    seen: set[str] = set()
    out: List[Path] = []
    for p in paths:
        try:
            key = str(p.resolve())
        except Exception:
            key = str(p)
        if key in seen:
            continue
        seen.add(key)
        out.append(p)
    return out


def _severity_rank(s: str) -> int:
    return {"P0": 0, "P1": 1, "P2": 2}.get(str(s).upper(), 9)


def _top_defects(defects: Any, *, limit: int = 6) -> List[Dict[str, str]]:
    if not isinstance(defects, list):
        return []
    items: List[Dict[str, str]] = []
    for d in defects:
        if not isinstance(d, dict):
            continue
        sev = str(d.get("severity", "")).strip().upper() or "P2"
        where = str(d.get("where", "")).strip() or "global"
        msg = str(d.get("message", "")).strip()
        dim = str(d.get("dimension", "")).strip() or "unknown"
        if not msg:
            continue
        items.append({"severity": sev, "where": where, "message": msg, "dimension": dim})
    items.sort(key=lambda x: (_severity_rank(x["severity"]), x["dimension"], x["where"], x["message"]))
    return items[: max(0, int(limit))]


def _write_ai_pack(
    ai_run_root: Path,
    *,
    round_idx: int,
    round_dir: Path,
    spec_dict: Dict[str, Any],
    cfg_used: Dict[str, Any],
    evaluation: Dict[str, Any],
    png_path: Optional[Path],
) -> Path:
    pack = _ai_pack_dir(ai_run_root, round_idx)
    pack.mkdir(parents=True, exist_ok=True)

    write_text(pack / "spec.yaml", dump_yaml(spec_dict))
    write_text(pack / "config_used.yaml", dump_yaml(cfg_used))
    (pack / "evaluation.json").write_text(json.dumps(evaluation, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")

    for name in ["layout_debug.json", "edge_debug.json", "measurements.json", "dimension_measurements.json"]:
        _copy_if_exists(round_dir / name, pack / name)
    if png_path is not None:
        _copy_if_exists(png_path, pack / "schematic.png")
        _copy_if_exists(png_path.parent / "nano_banana_prompt.md", pack / "nano_banana_prompt.md")
    return pack


def _write_ai_critic_request(
    ai_run_root: Path,
    *,
    round_idx: int,
    pack_dir: Path,
    response_path: Path,
    is_nano_banana: bool = False,
) -> None:
    lines: List[str] = []
    lines.append("# nsfc-schematic ai_critic request")
    lines.append("")
    lines.append(f"- based_on_round: {round_idx}")
    lines.append(f"- evidence_pack: `{pack_dir.as_posix()}`")
    lines.append(f"- write_response_to: `{response_path.as_posix()}`")
    lines.append("")
    if is_nano_banana:
        lines.extend(
            [
                "## 模式说明（Nano Banana + ai_critic）",
                "",
                "当前图片（`schematic.png`）由 Gemini 图片模型根据 prompt 直接生成。",
                "**请直接读取并分析 `schematic.png` 图片**，做出视觉评价；",
                "`evaluation.json/layout_debug.json/edge_debug.json` 的硬编码结果仅供参考，以你的读图判断为主。",
                "",
                "当前使用的 prompt：`nano_banana_prompt.md`（本轮传给 Gemini 的完整文本）。",
                "如需调整 Gemini 的绘图行为，可在响应中提供 `nano_banana_prompt` 字段。",
                "注意：无论使用 `full` 还是 `patch`，脚本都会自动补齐字体/文字排版硬约束；不要删除这类约束。",
                "",
            ]
        )
    lines.append("## 任务")
    lines.append("")
    lines.append("请基于 `schematic.png` 的读图批判，以及 `evaluation.json / layout_debug.json / edge_debug.json` 输出下一步行动。")
    lines.append("支持 spec 重写、config_local patch，或明确 stop。")
    lines.append("")
    lines.append("## 输出约束（必须满足）")
    lines.append("")
    cfg_used_path = pack_dir / "config_used.yaml"
    if cfg_used_path.exists():
        try:
            cfg_used = load_yaml(cfg_used_path)
            renderer = cfg_used.get("renderer", {}) if isinstance(cfg_used.get("renderer", {}), dict) else {}
            canvas = renderer.get("canvas", {}) if isinstance(renderer.get("canvas", {}), dict) else {}
            ratio = _canvas_ratio_from_canvas(canvas)
            if ratio is not None and ratio > 0:
                lines.append("## 当前画布锁定")
                lines.append("")
                lines.append(
                    f"- 当前画布：{int(canvas.get('width_px', 0) or 0)}x{int(canvas.get('height_px', 0) or 0)} px，比例锁定为 {ratio:.6f}。"
                )
                lines.append("- 除非用户明确要求改变比例，否则不得改变宽高比；如需放大画布，只能按同一比例同步放大宽和高。")
                lines.append("")
        except Exception:
            pass
    lines.append("请写入 YAML 到 `ai_critic_response.yaml`，结构如下：")
    lines.append("")
    lines.append("```yaml")
    lines.append("version: 1")
    lines.append("based_on_round: 1")
    if is_nano_banana:
        lines.append("action: both  # spec_only|config_only|both|nano_banana_prompt_only|stop")
    else:
        lines.append("action: both  # spec_only|config_only|both|stop")
    lines.append("reason: \"一句话说明本轮行动与停止/继续依据\"")
    if is_nano_banana:
        lines.append("# 仅 nano_banana：是否从本轮开始“锁定风格/延续风格”（下一轮会把上一轮图片作为参考图传入）")
        lines.append("# - TRUE：本轮整体风格（构图/线条/配色/质感）已足够好，后续只做内容与小修")
        lines.append("# - FALSE：本轮风格仍需大改（先别锁风格，以免把坏风格固化）")
        lines.append("# - 若用户明确表示“基于本 run/本结果继续优化”，通常应选择 TRUE（保持风格连续）。")
        lines.append("style_continuity: false")
    lines.append("# 可选：完整 spec（建议给全量）")
    lines.append("# spec:")
    lines.append("#   schematic:")
    lines.append("#     title: ...")
    lines.append("#     groups: ...")
    lines.append("# 可选：config_local patch（仅白名单字段）")
    lines.append("# config_local:")
    lines.append("#   layout:")
    lines.append("#     direction: top-to-bottom")
    lines.append("#     font:")
    lines.append("#       node_label_size: 28")
    lines.append("#   color_scheme:")
    lines.append("#     name: nature-biomedical")
    if is_nano_banana:
        lines.append("# 可选（仅 nano_banana）：配色建议（会自动拼到下一轮 prompt，建议写成可执行规则）")
        lines.append("# nano_banana_color_advice: |")
        lines.append("#   - 主色不超过 2-3 个；风险/对照用点缀色")
        lines.append("#   - 保证文本对比度（深色字 + 浅色填充）")
        lines.append("# 可选（仅 nano_banana）：提供下一轮传给 Gemini 的 prompt")
        lines.append("# nano_banana_prompt:")
        lines.append("#   mode: full    # patch=更稳（推荐）| full=全量替换（脚本仍会自动补齐字体硬约束）")
        lines.append("#   content: |")
        lines.append("#     你是一名科研申请书插图设计师...")
        lines.append("")
        lines.append("# 新增 action 值（仅 nano_banana）：")
        lines.append("# action: nano_banana_prompt_only  # 只更新 prompt，不改 spec/config_local")
    lines.append("```")
    lines.append("")
    lines.append("### 纠偏原则（必须遵守）")
    lines.append("")
    lines.append("- density 拥挤优先改 spec（缩短文案/合并节点），不要靠缩字号硬过阈值。")
    lines.append("- 只有 overflow 风险时才减字号；若字号偏小且无 overflow，应增字号。")
    lines.append("- 配色干扰优先改 kind 分配；不要靠黑白方案掩盖结构问题。")
    lines.append("- config_local.color_scheme.name 仅允许 {academic-blue, transformer-style, nature-biomedical}。")
    lines.append("- 若当前 run 已锁定用户指定比例，任何 `renderer.canvas.width_px/height_px` 或 `spec.schematic.canvas.width/height` 调整都必须保持相同宽高比；禁止只改一边。")
    lines.append("")
    write_text(_ai_request_path(ai_run_root), "\n".join(lines) + "\n")

    if not response_path.exists():
        write_text(
            response_path,
            dump_yaml(
                {
                    "version": 1,
                    "based_on_round": int(round_idx),
                    "action": "stop",
                    "reason": "",
                }
            ),
        )


def _ai_state_path(ai_run_root: Path) -> Path:
    return ai_run_root / "ai_state.yaml"


def _load_ai_state(ai_run_root: Path) -> Dict[str, Any]:
    p = _ai_state_path(ai_run_root)
    if not p.exists():
        return {"version": 1, "nano_banana": {}}
    try:
        d = load_yaml(p)
        if isinstance(d, dict):
            return d
    except Exception:
        pass
    return {"version": 1, "nano_banana": {}}


def _save_ai_state(ai_run_root: Path, state: Dict[str, Any]) -> None:
    try:
        write_text(_ai_state_path(ai_run_root), dump_yaml(state))
    except Exception:
        # State is a convenience feature; never break the main pipeline.
        pass


def _maybe_apply_ai_response(
    ai_run_root: Path,
    spec_data: Dict[str, Any],
    spec_latest_path: Path,
    base_config: Dict[str, Any],
) -> Tuple[Dict[str, Any], Optional[Dict[str, Any]], Optional[str], Optional[str], Optional[bool], Optional[str]]:
    """
    Returns: (updated_spec_data, config_local_patch, action, nano_banana_prompt_override, style_continuity, nano_banana_color_advice)
    """
    resp_path = _ai_response_path(ai_run_root)
    if not resp_path.exists():
        return spec_data, None, None, None, None, None

    resp = load_yaml(resp_path)
    if int(resp.get("version", 1) or 1) != 1:
        fatal(f"ai_critic_response.version 不支持：{resp.get('version')}")

    action = str(resp.get("action", "") or "").strip()
    valid_actions = {"spec_only", "config_only", "both", "nano_banana_prompt_only", "stop"}
    if action not in valid_actions:
        fatal("ai_critic_response.action 必须是 spec_only|config_only|both|nano_banana_prompt_only|stop")

    based = resp.get("based_on_round")
    try:
        based_i = int(based) if based is not None else 0
    except Exception:
        based_i = 0
    archive = ai_run_root / f"ai_critic_response_applied_round_{based_i:02d}.yaml"
    if not archive.exists():
        _copy_if_exists(resp_path, archive)
    try:
        resp_path.unlink()
    except Exception:
        pass

    style_continuity: Optional[bool] = None
    if "style_continuity" in resp:
        style_continuity = bool(resp.get("style_continuity", False))

    nano_banana_color_advice: Optional[str] = None
    if "nano_banana_color_advice" in resp:
        nano_banana_color_advice = str(resp.get("nano_banana_color_advice", "") or "").strip()

    nano_banana_prompt_override: Optional[str] = None
    nb_prompt_cfg = resp.get("nano_banana_prompt")
    if isinstance(nb_prompt_cfg, dict):
        nb_mode = str(nb_prompt_cfg.get("mode", "full") or "full").strip()
        nb_content = str(nb_prompt_cfg.get("content", "") or "").strip()
        if nb_content:
            if nb_mode == "full":
                nano_banana_prompt_override = nb_content
            elif nb_mode == "patch":
                nano_banana_prompt_override = f"__PATCH__\n{nb_content}"
            else:
                warn(f"nano_banana_prompt.mode 不支持：{nb_mode!r}，已忽略")

    # Persist nano_banana "iteration state" for the next round (style continuity & color hints).
    state = _load_ai_state(ai_run_root)
    nb = state.get("nano_banana") if isinstance(state.get("nano_banana"), dict) else {}
    nb_out: Dict[str, Any] = dict(nb)
    if style_continuity is not None:
        nb_out["style_continuity"] = bool(style_continuity)
    if nano_banana_color_advice is not None:
        nb_out["color_advice"] = nano_banana_color_advice
    if nano_banana_prompt_override is not None:
        nb_out["prompt_override"] = nano_banana_prompt_override
    state["version"] = 1
    state["nano_banana"] = nb_out
    _save_ai_state(ai_run_root, state)

    new_spec = resp.get("spec")
    if action in {"spec_only", "both", "stop"} and new_spec is not None:
        if not isinstance(new_spec, dict):
            fatal("ai_critic_response.spec 必须是 mapping（完整 spec）")
        new_spec = _preserve_locked_spec_canvas(spec_data, new_spec)
        load_schematic_spec(new_spec, base_config)
        spec_data = new_spec
        write_text(spec_latest_path, dump_yaml(spec_data))

    config_local_patch = resp.get("config_local")
    if action in {"config_only", "both"} and config_local_patch is not None:
        if not isinstance(config_local_patch, dict):
            fatal("ai_critic_response.config_local 必须是 mapping")
        return spec_data, config_local_patch, action, nano_banana_prompt_override, style_continuity, nano_banana_color_advice

    return spec_data, None, action, nano_banana_prompt_override, style_continuity, nano_banana_color_advice


def _apply_exploration(
    cfg: Dict[str, Any],
    round_idx: int,
    exploration: Dict[str, Any],
    *,
    cand_idx: int = 0,
) -> Dict[str, Any]:
    if not bool(exploration.get("enabled", False)):
        return deepcopy(cfg)

    seed = int(exploration.get("seed", 1337))
    # Make branching reproducible: (round, candidate) -> deterministic jitter.
    rng = random.Random(seed + round_idx * 100 + int(cand_idx))
    out = deepcopy(cfg)

    auto = out.get("layout", {}).get("auto", {})
    jitter_px = exploration.get("jitter_px", {})
    if not isinstance(jitter_px, dict):
        jitter_px = {}

    # Reasonable lower bounds to avoid degenerate layouts.
    mins = {
        "margin_x": 40,
        "margin_y": 40,
        "node_gap_x": 10,
        "node_gap_y": 10,
        "group_gap_x": 40,
        "group_gap_y": 40,
    }

    for k, delta in jitter_px.items():
        if k not in auto:
            continue
        try:
            d = int(delta)
        except Exception:
            continue
        base = int(auto.get(k, 0))
        new_v = base + rng.randint(-d, d)
        auto[k] = max(int(mins.get(k, 0)), new_v)

    opts = exploration.get("max_cols_options")
    if isinstance(opts, list) and opts:
        parsed = []
        for v in opts:
            try:
                vi = int(v)
            except Exception:
                continue
            if vi > 0:
                parsed.append(vi)
        if parsed:
            auto["max_cols"] = rng.choice(parsed)

    return out


def main() -> None:
    p = argparse.ArgumentParser(description="Generate NSFC schematic artifacts from spec or TEX.")
    p.add_argument("--proposal-path", type=Path, default=None)
    p.add_argument("--proposal-file", type=Path, default=None)
    p.add_argument("--spec-file", type=Path, default=None)
    p.add_argument("--output-dir", type=Path, required=False, default=None)
    p.add_argument("--config", type=Path, required=False, default=None)
    p.add_argument("--rounds", type=int, default=None)
    p.add_argument(
        "--renderer",
        type=str,
        default="drawio",
        help="渲染后端：drawio（默认）| nano_banana（Gemini 图片模型，仅输出 PNG）",
    )
    p.add_argument(
        "--dotenv",
        type=Path,
        default=None,
        help="可选：显式指定 .env 路径（仅 nano_banana 模式使用；默认从 CWD 向上搜索）",
    )
    p.add_argument(
        "--style-ref",
        type=Path,
        action="append",
        default=[],
        help="可选：风格参考图片（可多次提供；仅 nano_banana 模式生效；支持 png/jpg/jpeg/webp）",
    )
    p.add_argument(
        "--run-tag",
        type=str,
        default=None,
        help="可选：为本次 run_ 目录附加可读标签（例如 thread_1 / canvas / readability）。",
    )
    args = p.parse_args()

    style_ref_images = [Path(p) for p in (args.style_ref or []) if p is not None]

    root = skill_root()
    config_path = args.config if args.config is not None else (root / "config.yaml")
    if not config_path.exists() or not config_path.is_file():
        fatal(f"config 不存在或不是文件：{config_path}")
    base_config = load_yaml(config_path)

    out_dir = args.output_dir if args.output_dir is not None else Path(base_config["output"]["dirname"])
    if args.output_dir is None:
        info(f"未指定 output_dir，使用默认输出目录：{out_dir}")
    if out_dir.exists() and not out_dir.is_dir():
        fatal(f"output_dir 不是目录：{out_dir}")
    out_dir.mkdir(parents=True, exist_ok=True)

    artifacts = base_config.get("output", {}).get("artifacts", {})
    if not isinstance(artifacts, dict):
        fatal("config.yaml:output.artifacts 必须是 dict")
    for k in ("drawio", "svg", "png", "pdf", "spec", "spec_latest", "report", "config_best", "evaluation_best"):
        v = artifacts.get(k)
        if v is None:
            continue
        if not isinstance(v, str):
            fatal(f"config.yaml:output.artifacts.{k} 必须是字符串（相对路径）")
        if not is_safe_relative_path(v):
            fatal(f"config.yaml:output.artifacts.{k} 必须是安全的相对路径（不得包含 `..` 或绝对/盘符路径）：{v!r}")

    work_dir, intermediate_dir, hide_intermediate = _resolve_output_dirs(out_dir, base_config)

    # Instance-local overrides (safe whitelist).
    config_local_path = intermediate_dir / "config_local.yaml"
    local_cfg = _load_config_local(config_local_path)
    config = _apply_config_local(base_config, local_cfg)
    cfg_round_base = deepcopy(config)

    renderer_backend = str(args.renderer or "drawio").strip().lower()
    if renderer_backend not in {"drawio", "nano_banana"}:
        fatal(f"--renderer 不支持：{renderer_backend!r}（仅支持 drawio / nano_banana）")

    if style_ref_images and renderer_backend != "nano_banana":
        warn("检测到 --style-ref，但当前 renderer 不是 nano_banana；该参数仅在 nano_banana 模式生效，已忽略。")
        style_ref_images = []
    if renderer_backend == "nano_banana" and style_ref_images:
        for p in style_ref_images:
            if not p.exists() or not p.is_file():
                fatal(f"--style-ref 参考图片不存在或不是文件：{p}")

    # Internal hint for downstream evaluators/measurements (do not persist to config.yaml).
    config = deepcopy(config)
    config.setdefault("renderer", {})["backend"] = renderer_backend
    cfg_round_base = deepcopy(config)

    gemini_cfg = None
    if renderer_backend == "nano_banana":
        # Nano Banana (Gemini image model) only supports PNG output.
        config = deepcopy(config)
        config.setdefault("renderer", {}).setdefault("svg", {})
        config.setdefault("renderer", {}).setdefault("pdf", {})
        config.setdefault("renderer", {}).setdefault("png", {})
        config["renderer"]["svg"]["enabled"] = False
        config["renderer"]["pdf"]["enabled"] = False
        config["renderer"]["png"]["enabled"] = True
        cfg_round_base = deepcopy(config)

        try:
            gemini_cfg = nano_banana_health_check(dotenv_path=args.dotenv, search_from=Path.cwd())
        except Exception as exc:
            fatal(
                "已选择 --renderer nano_banana，但 Nano Banana 连通性检查失败。\n"
                "请检查项目根目录 `.env` 中的 GEMINI_* 配置是否正确，并确认网络可用。\n"
                f"错误：{exc}"
            )

    max_rounds = int(config["evaluation"]["max_rounds"])
    rounds = int(args.rounds) if args.rounds is not None else max_rounds
    rounds = max(1, min(20, rounds))

    _cleanup_work_dir(work_dir, intermediate_dir, artifacts, hide_intermediate)

    stop_strategy_effective = str(config.get("evaluation", {}).get("stop_strategy", "none") or "none")
    ai_mode = stop_strategy_effective == "ai_critic"
    spec_variants_cfg = (
        config.get("evaluation", {}).get("spec_variants", {})
        if isinstance(config.get("evaluation", {}), dict)
        else {}
    )
    spec_variants_cfg = spec_variants_cfg if isinstance(spec_variants_cfg, dict) else {}
    spec_variants_mode = str(spec_variants_cfg.get("mode", "none") or "none").strip().lower()
    wrap_max_chars = int(spec_variants_cfg.get("wrap_max_chars", 12) or 12)
    truncate_max_chars = int(spec_variants_cfg.get("truncate_max_chars", 16) or 16)
    allow_in_ai_critic = bool(spec_variants_cfg.get("allow_in_ai_critic", False))
    if ai_mode and (not allow_in_ai_critic):
        spec_variants_mode = "none"

    # Keep per-run rounds isolated to avoid mixing historical round_* residues.
    run_base = (intermediate_dir / "runs") if hide_intermediate else intermediate_dir
    ai_run_root: Optional[Path] = None
    ai_locked_ratio: Optional[float] = None
    if ai_mode:
        ai_root = _ai_root(intermediate_dir)
        active = _read_active_run(ai_root)
        run_dir = (run_base / active) if active else _make_run_dir(run_base, run_tag=args.run_tag)
        run_dir.mkdir(parents=True, exist_ok=True)
        _set_active_run(ai_root, run_dir)
        ai_run_root = _ai_run_root(ai_root, run_dir)
        # ai_critic 闭环：每次运行只渲染一轮，等待宿主 AI 响应后继续。
        rounds = 1
    else:
        run_dir = _make_run_dir(run_base, run_tag=args.run_tag)

    spec_latest = intermediate_dir / _spec_latest_filename(config)
    input_spec_data = _load_input_spec(args, config=config, request_dir=run_dir)
    if ai_mode and spec_latest.exists():
        try:
            input_spec_data = load_yaml(spec_latest)
            info("ai_critic 模式：检测到 spec_latest.yaml，继续沿用上一轮最新 spec。")
        except Exception as exc:
            warn(f"spec_latest 读取失败，改用输入 spec：{exc}")

    write_text(spec_latest, dump_yaml(input_spec_data))
    write_text(run_dir / str(artifacts.get("spec", "spec.yaml")), dump_yaml(input_spec_data))
    ai_locked_ratio = _locked_ratio_for_schematic(input_spec_data, cfg_round_base) if ai_mode else None

    ai_action: Optional[str] = None
    ai_nb_prompt_override: Optional[str] = None
    ai_nb_style_continuity: Optional[bool] = None
    ai_nb_color_advice: Optional[str] = None
    if ai_mode and ai_run_root is not None:
        input_spec_data, config_local_patch, ai_action, ai_nb_prompt_override, ai_nb_style_continuity, ai_nb_color_advice = _maybe_apply_ai_response(
            ai_run_root,
            input_spec_data,
            spec_latest,
            base_config=config,
        )
        if config_local_patch is not None:
            base_local = local_cfg if isinstance(local_cfg, dict) else {}
            if ai_locked_ratio is not None and ai_locked_ratio > 0:
                config_local_patch = _apply_canvas_ratio_lock_to_patch(
                    cfg_round_base,
                    config_local_patch,
                    locked_ratio=ai_locked_ratio,
                )
            merged_local = _deep_merge_dict(base_local, config_local_patch)
            write_text(config_local_path, dump_yaml(merged_local))
            local_cfg = _load_config_local(config_local_path)
            config = _apply_config_local(base_config, local_cfg)
            # Keep renderer_backend constraints stable after AI responses.
            if renderer_backend == "nano_banana":
                config = deepcopy(config)
                config.setdefault("renderer", {}).setdefault("svg", {})
                config.setdefault("renderer", {}).setdefault("pdf", {})
                config.setdefault("renderer", {}).setdefault("png", {})
                config["renderer"]["svg"]["enabled"] = False
                config["renderer"]["pdf"]["enabled"] = False
                config["renderer"]["png"]["enabled"] = True

            # Re-attach internal backend hint after any config recomputation.
            config = deepcopy(config)
            config.setdefault("renderer", {})["backend"] = renderer_backend
            cfg_round_base = deepcopy(config)

    ai_nb_style_continuity_effective = False
    ai_nb_color_advice_effective = ""
    ai_nb_prompt_override_effective: Optional[str] = str(ai_nb_prompt_override).strip() if ai_nb_prompt_override else None
    if ai_mode and ai_run_root is not None:
        st = _load_ai_state(ai_run_root)
        nb = st.get("nano_banana") if isinstance(st.get("nano_banana"), dict) else {}
        ai_nb_style_continuity_effective = bool((nb or {}).get("style_continuity", False))
        ai_nb_color_advice_effective = str((nb or {}).get("color_advice", "") or "").strip()
        if not ai_nb_prompt_override_effective:
            prompt_override = str((nb or {}).get("prompt_override", "") or "").strip()
            ai_nb_prompt_override_effective = prompt_override or None

    report_path = run_dir / _report_filename(config)
    if renderer_backend == "drawio":
        drawio_cmd = ensure_drawio_cli(config)
        drawio_mode = "drawio_cli" if drawio_cmd else "internal_fallback"
    else:
        drawio_cmd = None
        drawio_mode = "nano_banana"

    report_lines: List[str] = [
        "# 原理图优化记录",
        "",
        f"- run_dir: {run_dir.name}",
        f"- work_dir: {work_dir}",
        f"- rounds: {rounds}",
        f"- spec_latest: {spec_latest}",
        f"- renderer_backend: {renderer_backend}",
        f"- renderer_mode: {drawio_mode}",
        f"- spec_variants: {spec_variants_mode} (wrap_max_chars={wrap_max_chars}, truncate_max_chars={truncate_max_chars})",
        "",
    ]

    if renderer_backend == "drawio":
        if drawio_cmd:
            report_lines.extend([f"- drawio_cli: {drawio_cmd}", ""])
        else:
            report_lines.extend(
                [
                    "## 重要提示：未检测到 draw.io CLI",
                    "",
                    "当前将退回到内部渲染兜底：可用于迭代与预览，但最终交付质量通常不如 draw.io CLI 导出。",
                    "建议安装 draw.io CLI（macOS/Windows/Linux 指引）：",
                    "",
                ]
            )
            report_lines.extend([f"- {line}" for line in drawio_install_hints()])
            report_lines.append("")
    else:
        if gemini_cfg is not None:
            dotenv = str(gemini_cfg.dotenv_path) if gemini_cfg.dotenv_path is not None else "(not found)"
            report_lines.extend(
                [
                    "## Nano Banana (Gemini)",
                    "",
                    f"- dotenv: {dotenv}",
                    f"- base_url: {gemini_cfg.base_url}",
                    f"- model: {gemini_cfg.model}",
                    "",
                ]
            )

    if ai_mode and ai_action == "stop":
        report_lines.extend(
            [
                "## AI Critic",
                "",
                "- action: stop（已收到宿主 AI 停止指令，本次仅导出历史最佳轮次）",
                "",
            ]
        )

    best_round = 0
    best_score = -1
    best_round_dir: Optional[Path] = None
    existing_round_dirs: List[Path] = []

    if ai_mode:
        existing_round_dirs = sorted(
            [p for p in run_dir.iterdir() if p.is_dir() and p.name.startswith("round_")],
            key=lambda p: p.name,
        )
        for rd in existing_round_dirs:
            ev = _load_json_if_exists(rd / "evaluation.json")
            if not isinstance(ev, dict):
                continue
            score = int(ev.get("score", 0) or 0)
            if score > best_score:
                best_score = score
                try:
                    best_round = int(rd.name.split("_")[-1])
                except Exception:
                    best_round = 0
                best_round_dir = rd

    start_round_idx = (len(existing_round_dirs) + 1) if ai_mode else 1

    early = config["evaluation"].get("early_stop", {})
    early_enabled = bool(early.get("enabled", False))
    min_rounds = int(early.get("min_rounds", 2))
    score_threshold = int(early.get("score_threshold", 85))
    allow_p1 = int(early.get("allow_p1", 1))
    pass_streak_need = int(early.get("consecutive_pass", 2))
    pass_streak = 0

    stop_strategy = str(config.get("evaluation", {}).get("stop_strategy", "none"))
    plateau = config.get("evaluation", {}).get("plateau", {})
    min_explore_rounds = int(plateau.get("min_explore_rounds", 4))
    same_image_rounds = int(plateau.get("same_image_rounds", 2))
    no_improve_rounds = int(plateau.get("no_improve_rounds", 3))
    min_delta = float(plateau.get("min_delta", 1))

    exploration = config.get("evaluation", {}).get("exploration", {})

    score_hist: List[int] = []
    hash_hist: List[Optional[str]] = []
    stop_reason: Optional[str] = None

    # Multi-dimensional critiques (evidence-first; optional via config).
    multi_cfg = config.get("evaluation", {}).get("multi_round_self_check", {})
    if not isinstance(multi_cfg, dict):
        multi_cfg = {}
    multi_enabled = bool(multi_cfg.get("enabled", False))
    critique_dims = multi_cfg.get("critique_dimensions", ["structure", "visual", "readability"])
    if not isinstance(critique_dims, list) or not critique_dims:
        critique_dims = ["structure", "visual", "readability"]
    critique_dims = [str(x).strip() for x in critique_dims if str(x).strip()]
    penalty_cfg = multi_cfg.get("penalty", {})
    if not isinstance(penalty_cfg, dict):
        penalty_cfg = {}

    palette: Optional[Dict[str, Any]] = None
    if multi_enabled and ("visual" in critique_dims):
        try:
            palette = load_palette_from_assets(config, skill_root_dir=root)
        except Exception as exc:
            warn(f"配色方案加载失败，已禁用 visual critique（{exc}）")
            palette = None

    if ai_action == "stop":
        rounds = 0

    for r in range(start_round_idx, start_round_idx + rounds):
        round_dir = run_dir / f"round_{r:02d}"
        round_dir.mkdir(parents=True, exist_ok=True)

        # Limited candidate branching: try N small config variations per round and pick the best.
        exploration_cfg = exploration if isinstance(exploration, dict) else {}
        if renderer_backend == "nano_banana":
            # Avoid multiplicative image-generation calls in one round.
            # Prompt variation is expected to happen via host AI / parallel-vibe.
            cand_n = 1
        elif not bool(exploration_cfg.get("enabled", False)):
            cand_n = 1
        else:
            cand_n = int(exploration_cfg.get("candidates_per_round", 1) or 1)
            cand_n = max(1, min(5, cand_n))

        cand_root = round_dir / "_candidates"
        cand_root.mkdir(parents=True, exist_ok=True)

        cand_results: List[Dict[str, Any]] = []
        for ci in range(cand_n):
            cand_dir = cand_root / f"cand_{ci + 1:02d}"
            cand_dir.mkdir(parents=True, exist_ok=True)

            cfg_used = _apply_exploration(cfg_round_base, r, exploration_cfg, cand_idx=ci)
            spec_dict_raw = deepcopy(input_spec_data)
            # Optional spec variants (safe label-only transforms). Keep deterministic.
            cand_variant = spec_variants_mode
            if spec_variants_mode == "candidates":
                cand_variant = ["none", "wrap", "truncate"][min(2, int(ci))]
            if cand_variant in {"wrap", "truncate"}:
                spec_dict_raw, spec_changes = _apply_spec_variant(
                    spec_dict_raw,
                    mode=cand_variant,
                    wrap_max_chars=wrap_max_chars,
                    truncate_max_chars=truncate_max_chars,
                )
            else:
                spec_changes = []

            spec = load_schematic_spec(spec_dict_raw, cfg_used)
            cfg_used = deepcopy(cfg_used)
            cfg_used.setdefault("renderer", {}).setdefault("canvas", {})
            cfg_used["renderer"]["canvas"]["width_px"] = int(spec.canvas_width)
            cfg_used["renderer"]["canvas"]["height_px"] = int(spec.canvas_height)

            cand_spec = cand_dir / str(artifacts.get("spec", "spec.yaml"))
            cand_cfg = cand_dir / "config_used.yaml"
            write_text(cand_spec, dump_yaml(spec.to_dict()))
            write_text(cand_cfg, dump_yaml(cfg_used))

            png_path: Optional[Path] = None
            if renderer_backend == "drawio":
                drawio_path = cand_dir / str(artifacts.get("drawio", "schematic.drawio"))
                try:
                    write_schematic_drawio(spec, cfg_used, drawio_path)
                    _validate_drawio_xml(drawio_path)
                except Exception as exc:
                    warn(f"Round {r} cand {ci + 1} preflight 失败：{exc}")
                    # Record as a failed candidate (but don't abort the whole run).
                    ev = {
                        "score": 0,
                        "counts": {"p0": 1, "p1": 0, "p2": 0},
                        "defects": [],
                        "preflight_error": str(exc),
                    }
                    (cand_dir / "evaluation.json").write_text(
                        json.dumps(ev, ensure_ascii=False, indent=2) + "\n",
                        encoding="utf-8",
                    )
                    cand_results.append({"idx": ci, "dir": cand_dir, "spec": spec, "cfg_used": cfg_used, "evaluation": ev})
                    continue

                try:
                    rendered = render_artifacts(spec, cfg_used, drawio_path, cand_dir)
                    rp = rendered.get("png")
                    png_path = rp if isinstance(rp, Path) else None
                except Exception as exc:
                    warn(f"Round {r} cand {ci + 1} 渲染失败：{exc}")
                    png_path = None
            else:
                if gemini_cfg is None:
                    fatal("内部错误：nano_banana 模式下 gemini_cfg 为空（health_check 未执行）。")
                out_png = cand_dir / str(artifacts.get("png", "schematic.png"))
                try:
                    if ai_mode and ai_nb_prompt_override_effective is not None:
                        if ai_nb_prompt_override_effective.startswith("__PATCH__\n"):
                            base_prompt = _build_nano_banana_prompt(spec, cfg_used)
                            patch_content = ai_nb_prompt_override_effective[len("__PATCH__\n") :]
                            prompt = base_prompt.rstrip("\n") + "\n\n## AI 补充指令\n\n" + patch_content + "\n"
                            info("nano_banana: 使用 patch 模式 prompt（确定性 prompt + AI 补充指令）")
                        else:
                            prompt = ai_nb_prompt_override_effective
                            info("nano_banana: 使用 AI 提供的 full prompt")
                    else:
                        prompt = _build_nano_banana_prompt(spec, cfg_used)
                    font_cfg = (cfg_used.get("layout", {}) or {}).get("font", {}) or {}
                    prompt = _ensure_nano_banana_typography_guardrails(
                        prompt,
                        node_font=int(font_cfg.get("node_label_size", 26) or 26),
                        edge_font=int(font_cfg.get("edge_label_size", 22) or 22),
                    )

                    reference_images: List[Path] = []
                    previous_ref: Optional[Path] = None
                    if ai_mode and ai_nb_style_continuity_effective and r >= 2:
                        prev_round = run_dir / f"round_{(r - 1):02d}"
                        prev_cands = prev_round / "_candidates"
                        if prev_cands.exists():
                            cand_dirs = sorted([p for p in prev_cands.iterdir() if p.is_dir() and p.name.startswith("cand_")])
                            for cd in cand_dirs:
                                pp = cd / str(artifacts.get("png", "schematic.png"))
                                if pp.exists():
                                    previous_ref = pp
                                    reference_images.append(pp)
                                    break
                        if reference_images:
                            prompt = (
                                prompt.rstrip("\n")
                                + "\n\n## 风格延续（参考图片已提供）\n"
                                + "- 请尽量沿用参考图片中的整体视觉风格（配色、线条粗细、圆角/箭头风格、阴影/扁平质感）。\n"
                                + "- 优先只改动布局与节点文字，不要在风格上做大幅改动。\n"
                                + "\n"
                            )

                    if style_ref_images:
                        reference_images.extend(style_ref_images)
                        prompt = (
                            prompt.rstrip("\n")
                            + "\n\n## 风格参考（用户提供图片已提供）\n"
                            + "- 参考图片用于对齐整体视觉风格（配色、线条粗细、圆角/箭头风格、阴影/扁平质感）。\n"
                            + "- 不要照抄内容结构；请用本次 spec 的内容与结构，仅复用其风格语言。\n"
                            + "\n"
                        )

                    reference_images = _dedup_paths(reference_images)
                    if len(reference_images) > MAX_REFERENCE_IMAGES:
                        warn(f"参考图片数量过多（{len(reference_images)}），仅取前 {MAX_REFERENCE_IMAGES} 张。")
                        reference_images = reference_images[:MAX_REFERENCE_IMAGES]

                    if ai_mode and ai_nb_color_advice_effective:
                        prompt = (
                            prompt.rstrip("\n")
                            + "\n\n## 配色建议（来自宿主 AI）\n"
                            + ai_nb_color_advice_effective.strip()
                            + "\n\n"
                        )

                    write_text(cand_dir / "nano_banana_prompt.md", prompt)
                    if reference_images:
                        lines: List[str] = []
                        lines.append(f"# reference_images: {len(reference_images)}")
                        lines.append("# format: kind<TAB>basename<TAB>bytes=<n><TAB>sha256=<hex>")
                        for p in reference_images:
                            kind = "previous" if (previous_ref is not None and p == previous_ref) else "style_ref"
                            size_b = int(p.stat().st_size) if p.exists() else 0
                            h = _sha256_file(p) or ""
                            lines.append(f"{kind}\t{p.name}\tbytes={size_b}\tsha256={h}")
                        write_text(cand_dir / "nano_banana_reference_images.txt", "\n".join(lines) + "\n")
                    nano_banana_generate_png(
                        cfg=gemini_cfg,
                        prompt=prompt,
                        output_png=out_png,
                        canvas_w=int(spec.canvas_width),
                        canvas_h=int(spec.canvas_height),
                        reference_images=reference_images if reference_images else None,
                        debug_dir=cand_dir,
                    )
                    png_path = out_png
                except Exception as exc:
                    warn(f"Round {r} cand {ci + 1} Nano Banana 生成失败：{exc}")
                    png_path = None

            # evaluation_mode:
            # - heuristic (default): deterministic evaluator
            # - ai: emit offline protocol files (request/response); consume AI response if provided, else fallback
            evaluation_mode = str((cfg_used.get("evaluation", {}) or {}).get("evaluation_mode", "heuristic")).strip().lower()
            protocol_dir = cand_dir if evaluation_mode == "ai" else None
            evaluation = evaluate_schematic(
                spec,
                cfg_used,
                png_path=png_path if isinstance(png_path, Path) else None,
                protocol_dir=protocol_dir,
            )

            try:
                measurements = measure_schematic(spec, cfg_used, png_path=png_path if isinstance(png_path, Path) else None)
                (cand_dir / "measurements.json").write_text(
                    json.dumps(measurements, ensure_ascii=False, indent=2) + "\n",
                    encoding="utf-8",
                )
            except Exception as exc:
                warn(f"Round {r} cand {ci + 1} measurements 生成失败（已忽略）：{exc}")

            # Multi-dimensional critiques (optional) -> penalty -> score_total.
            critiques: Dict[str, Dict[str, Any]] = {}
            critique_files: Dict[str, str] = {}
            if multi_enabled:
                try:
                    ai_used = str(evaluation.get("evaluation_source", "")).strip().lower() == "ai"
                    if ai_used:
                        # AI 主评估已覆盖多维度判定；避免二次扣分导致口径混乱。
                        critiques = {}
                        critique_files = {}
                    if ("structure" in critique_dims) and ("structure" not in critiques) and (not ai_used):
                        payload = evaluate_structure(spec, cfg_used)
                        critiques["structure"] = payload
                        critique_files["structure"] = "critique_structure.json"
                        (cand_dir / "critique_structure.json").write_text(
                            json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
                        )
                    if ("visual" in critique_dims) and ("visual" not in critiques) and palette is not None and (not ai_used):
                        payload = evaluate_visual(spec, cfg_used, palette=palette)
                        critiques["visual"] = payload
                        critique_files["visual"] = "critique_visual.json"
                        (cand_dir / "critique_visual.json").write_text(
                            json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
                        )
                    if ("readability" in critique_dims) and ("readability" not in critiques) and (not ai_used):
                        payload = evaluate_readability(
                            spec, cfg_used, png_path=png_path if isinstance(png_path, Path) else None
                        )
                        critiques["readability"] = payload
                        critique_files["readability"] = "critique_readability.json"
                        (cand_dir / "critique_readability.json").write_text(
                            json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
                        )
                except Exception as exc:
                    warn(f"Round {r} cand {ci + 1} critique 失败（已忽略）：{exc}")

            if critiques:
                try:
                    (cand_dir / "dimension_measurements.json").write_text(
                        json.dumps({"dimensions": critiques}, ensure_ascii=False, indent=2) + "\n",
                        encoding="utf-8",
                    )
                except Exception:
                    pass

            base_score = int(evaluation.get("score", 0))
            penalty, penalty_summary = (0, {})
            if critiques:
                penalty, penalty_summary = penalty_from_critiques(critiques, penalty_cfg=penalty_cfg)
            total_score = max(0, int(base_score) - int(penalty))

            evaluation = deepcopy(evaluation)
            evaluation["score_base"] = base_score
            evaluation["score_penalty"] = penalty
            evaluation["score_total"] = total_score
            evaluation["score"] = total_score
            evaluation["critique_files"] = critique_files
            evaluation["critique_summary"] = penalty_summary

            (cand_dir / "evaluation.json").write_text(
                json.dumps(evaluation, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
            )
            cand_results.append(
                {
                    "idx": ci,
                    "dir": cand_dir,
                    "spec_dict_raw": spec_dict_raw,
                    "spec_variant": cand_variant,
                    "spec_changes": spec_changes,
                    "spec": spec,
                    "cfg_used": cfg_used,
                    "png_path": png_path if isinstance(png_path, Path) else None,
                    "evaluation": evaluation,
                }
            )

        def _cand_key(item: Dict[str, Any]) -> Tuple[int, int, int, int]:
            ev = item.get("evaluation", {}) if isinstance(item.get("evaluation", {}), dict) else {}
            score = int(ev.get("score", 0) or 0)
            c = ev.get("counts", {}) if isinstance(ev.get("counts", {}), dict) else {}
            p0 = int(c.get("p0", 0) or 0)
            p1 = int(c.get("p1", 0) or 0)
            p2 = int(c.get("p2", 0) or 0)
            return (score, -p0, -p1, -p2)

        best_cand = max(cand_results, key=_cand_key)
        cand_dir = best_cand["dir"]
        best_spec_dict_raw = best_cand.get("spec_dict_raw", input_spec_data)
        best_spec_variant = str(best_cand.get("spec_variant", "none") or "none")
        best_spec_changes = best_cand.get("spec_changes", []) if isinstance(best_cand.get("spec_changes", []), list) else []
        spec = best_cand["spec"]
        cfg_used = best_cand["cfg_used"]
        evaluation = best_cand["evaluation"]

        # Promote best candidate artifacts into round_dir root for stable downstream exports.
        for name in (
            str(artifacts.get("spec", "spec.yaml")),
            "config_used.yaml",
            "evaluation.json",
            str(artifacts.get("drawio", "schematic.drawio")),
            str(artifacts.get("svg", "schematic.svg")),
            str(artifacts.get("png", "schematic.png")),
            str(artifacts.get("pdf", "schematic.pdf")),
            "layout_debug.json",
            "edge_debug.json",
            "measurements.json",
            "dimension_measurements.json",
            "critique_structure.json",
            "critique_visual.json",
            "critique_readability.json",
        ):
            _copy_if_exists(cand_dir / name, round_dir / name)

        promoted_png = round_dir / str(artifacts.get("png", "schematic.png"))
        png_path = promoted_png if promoted_png.exists() else None

        score = int(evaluation.get("score", 0))
        counts = evaluation.get("counts", {}) if isinstance(evaluation.get("counts", {}), dict) else {}
        p0 = int(counts.get("p0", 0))
        p1 = int(counts.get("p1", 0))

        if score > best_score:
            best_score = score
            best_round = r
            best_round_dir = round_dir

        png_hash = _sha256_file(png_path) if isinstance(png_path, Path) else None
        score_hist.append(score)
        hash_hist.append(png_hash)

        if p0 == 0 and p1 <= allow_p1 and score >= score_threshold:
            pass_streak += 1
        else:
            pass_streak = 0

        base_score = int(evaluation.get("score_base", score) or score)
        penalty = int(evaluation.get("score_penalty", 0) or 0)
        top = _top_defects(evaluation.get("defects", []), limit=6)
        dims_any = sorted(
            {
                str(d.get("dimension", "")).strip()
                for d in (evaluation.get("defects", []) if isinstance(evaluation.get("defects", []), list) else [])
                if isinstance(d, dict) and str(d.get("dimension", "")).strip()
            }
        )
        layout_mode = evaluation.get("layout_mode", {}) if isinstance(evaluation.get("layout_mode", {}), dict) else {}
        explicit_ratio = float(layout_mode.get("explicit_layout_ratio", 0.0) or 0.0)
        report_lines.extend([f"## Round {r}", ""])
        if multi_enabled:
            report_lines.append(f"- score_total: {score} (base={base_score}, penalty={penalty})")
        else:
            report_lines.append(f"- score: {score}")
        report_lines.extend(
            [
                f"- defects: P0={p0} P1={p1} P2={counts.get('p2', 0)}",
                f"- trigger_dimensions: {', '.join(dims_any) if dims_any else 'N/A'}",
                f"- png_sha256: {png_hash or 'N/A'}",
                f"- selected_candidate: `{cand_dir.relative_to(round_dir)}`",
                (
                    f"- spec_variant: {best_spec_variant} (changes={len(best_spec_changes)})"
                    if best_spec_variant != "none"
                    else "- spec_variant: none"
                ),
                (
                    f"- explicit_layout_ratio: {explicit_ratio:.2f}（显式布局较多：自动修复会更保守；如仍不收敛，建议启用 ai_critic 做结构性调整）"
                    if explicit_ratio >= 0.35
                    else f"- explicit_layout_ratio: {explicit_ratio:.2f}"
                ),
                f"- output: `{round_dir.name}/`",
                "",
            ]
        )
        if top:
            report_lines.append("Top defects:")
            for d in top:
                report_lines.append(f"- [{d['severity']}] [{d['dimension']}] {d['message']} ({d['where']})")
            report_lines.append("")

        # If a spec variant actually changed labels, write evidence and use it as the next-round base spec.
        if best_spec_variant != "none" and best_spec_changes and (not ai_mode):
            (round_dir / "spec_variant.json").write_text(
                json.dumps(
                    {"mode": best_spec_variant, "changes": best_spec_changes[:50]},
                    ensure_ascii=False,
                    indent=2,
                )
                + "\n",
                encoding="utf-8",
            )
            # Make the change “stick” for subsequent rounds and for reproduction.
            input_spec_data = best_spec_dict_raw if isinstance(best_spec_dict_raw, dict) else input_spec_data
            write_text(spec_latest, dump_yaml(input_spec_data))
            report_lines.extend([f"- spec_variant_evidence: `{round_dir.name}/spec_variant.json`", ""])

        if ai_mode and ai_run_root is not None:
            pack = _write_ai_pack(
                ai_run_root,
                round_idx=r,
                round_dir=round_dir,
                spec_dict=spec.to_dict(),
                cfg_used=cfg_used,
                evaluation=evaluation,
                png_path=png_path if isinstance(png_path, Path) else None,
            )
            _write_ai_critic_request(
                ai_run_root,
                round_idx=r,
                pack_dir=pack,
                response_path=_ai_response_path(ai_run_root),
                is_nano_banana=(renderer_backend == "nano_banana"),
            )
            report_lines.extend(
                [
                    "## Stop (ai_critic)",
                    "",
                    f"- reason: waiting for `{_ai_response_path(ai_run_root).as_posix()}`",
                    f"- request: `{_ai_request_path(ai_run_root).as_posix()}`",
                    "",
                ]
            )
            break

        cfg_before = cfg_round_base
        cfg_round_base = _apply_auto_fixes(cfg_round_base, evaluation)
        auto_fix_delta = _cfg_delta(cfg_before, cfg_round_base)
        if auto_fix_delta:
            report_lines.append("Auto fixes (next round):")
            report_lines.extend([f"- {x}" for x in auto_fix_delta])
            report_lines.append("")
        else:
            report_lines.extend(["Auto fixes (next round):", "- (none)", ""])

        if early_enabled and r >= min_rounds and pass_streak >= pass_streak_need:
            report_lines.extend(
                [
                    "## Early Stop",
                    "",
                    (
                        f"- reason: 连续 {pass_streak} 轮满足 "
                        f"P0=0 且 P1<={allow_p1} 且 score>={score_threshold}"
                    ),
                    "",
                ]
            )
            break

        if early_enabled:
            continue

        # New stop strategy (default: plateau; no fixed score thresholds).
        if stop_strategy == "none":
            continue

        if stop_strategy != "plateau":
            # Unknown value: behave like "none" to avoid surprise.
            continue

        if r < min_explore_rounds:
            continue

        same_image_hit = False
        if same_image_rounds >= 2:
            recent = hash_hist[-same_image_rounds:]
            if recent and recent[-1] is not None and len(set(recent)) == 1:
                same_image_hit = True

        no_improve_hit = False
        if no_improve_rounds >= 2 and len(score_hist) >= (no_improve_rounds + 1):
            recent_scores = score_hist[-(no_improve_rounds + 1):]
            if (max(recent_scores) - min(recent_scores)) < float(min_delta):
                no_improve_hit = True

        if same_image_hit or no_improve_hit:
            parts = []
            if same_image_hit:
                parts.append(f"连续 {same_image_rounds} 轮 PNG 哈希不变")
            if no_improve_hit:
                parts.append(f"连续 {no_improve_rounds} 轮分数提升 < {min_delta}")
            stop_reason = "；".join(parts) if parts else "plateau"

            report_lines.extend(
                [
                    "## Stop (plateau)",
                    "",
                    f"- strategy: {stop_strategy}",
                    f"- reason: {stop_reason}",
                    "",
                ]
            )
            break

    write_text(report_path, "\n".join(report_lines) + "\n")

    if best_round_dir is None:
        warn("没有可导出的 best round")
        # Still surface the latest report/meta for debugging.
        _copy_if_exists(report_path, intermediate_dir / _report_filename(config))
        if ai_mode and ai_action == "stop":
            _clear_active_run(_ai_root(intermediate_dir))
        info(f"完成（无导出）：{run_dir}")
        return

    for key in ("drawio", "svg", "png", "pdf"):
        name = artifacts.get(key)
        if isinstance(name, str):
            _copy_if_exists(best_round_dir / name, run_dir / name)
            _copy_if_exists(best_round_dir / name, work_dir / name)

    compacted_png: Optional[str] = None
    if renderer_backend == "nano_banana":
        png_name = artifacts.get("png", "schematic.png")
        if isinstance(png_name, str):
            compacted_png = compacted_png_name(png_name)
            try:
                src = work_dir / png_name
                dst = work_dir / compacted_png
                res = compact_png(src, dst, target_long_edge_px=2400)
                _copy_if_exists(dst, run_dir / compacted_png)
                info(
                    "已生成 compacted PNG："
                    f"{dst.name} ({res.src_bytes/1024/1024:.2f}MB -> {res.dst_bytes/1024/1024:.2f}MB, "
                    f"{res.src_size[0]}x{res.src_size[1]} -> {res.dst_size[0]}x{res.dst_size[1]}, {res.used_mode})"
                )
            except Exception as exc:
                compacted_png = None
                warn(f"生成 compacted PNG 失败（已忽略）：{exc}")

    config_best_name = str(artifacts.get("config_best", "config_used_best.yaml"))
    evaluation_best_name = str(artifacts.get("evaluation_best", "evaluation_best.json"))
    _copy_if_exists(best_round_dir / "config_used.yaml", run_dir / config_best_name)
    _copy_if_exists(best_round_dir / "evaluation.json", run_dir / evaluation_best_name)
    _copy_if_exists(best_round_dir / "config_used.yaml", intermediate_dir / config_best_name)
    _copy_if_exists(best_round_dir / "evaluation.json", intermediate_dir / evaluation_best_name)

    with report_path.open("a", encoding="utf-8") as f:
        f.write("\n## Final\n\n")
        f.write(f"- best_round: {best_round} (score {best_score})\n")
        drawio_name = str(artifacts.get("drawio", "schematic.drawio"))
        svg_name = str(artifacts.get("svg", "schematic.svg"))
        png_name = str(artifacts.get("png", "schematic.png"))
        pdf_enabled = bool(((config.get("renderer", {}) or {}).get("pdf", {}) or {}).get("enabled", False))
        pdf_name = str(artifacts.get("pdf", "schematic.pdf"))
        if renderer_backend == "nano_banana":
            exported = [png_name] + ([compacted_png] if compacted_png else [])
        else:
            exported = [drawio_name, svg_name, png_name] + ([pdf_name] if pdf_enabled else [])
        f.write("- exported: " + ", ".join(f"`{x}`" for x in exported) + "\n")
        f.write(
            "- exported_meta: "
            f"`{config_best_name}`, `{evaluation_best_name}`"
            "\n"
        )

    # Convenience: keep the latest report at intermediate_dir root.
    _copy_if_exists(report_path, intermediate_dir / _report_filename(config))

    if ai_mode and ai_action == "stop":
        _clear_active_run(_ai_root(intermediate_dir))

    if hide_intermediate:
        max_runs = int(config.get("output", {}).get("max_history_runs", 10) or 0)
        _prune_history_runs(intermediate_dir, max_runs=max_runs)

    info(f"完成：{run_dir}（交付文件位于 {work_dir}；中间产物位于 {intermediate_dir}）")


if __name__ == "__main__":
    main()
