"""experiment_candidate_floor_plan_render_slice — W14 dry-run.

Consumes a prior W12 floor_plan_generation_slice run and assembles, per
candidate floor-plan id, the payload preview that the production
floor_plan_render stage would receive immediately before calling gpt-image-2.
W14 is deterministic, makes no image API call, makes no DB write, and does
not modify production code.

W14 makes no derived_from chain traversal: the W12 candidate_floor_plans
already carries the prompt + numbered elements verbatim; W14 just mirrors the
api_call_shape and surfaces a run-local expected_output_png_path. Actual
floor-plan image generation is left to W14b/W15 with separate approval.

CLI:
  --derive-candidate-fp-from <W12_run_dir>   (required)
  --output-root <path>                        (defaults to scripts_output)
  --diag-print-imports
"""
from __future__ import annotations

import argparse
import json
import sys
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional

_REPO_ROOT = Path(__file__).resolve().parents[2]
_SCRIPTS_DIR = _REPO_ROOT / "backend" / "scripts"
if str(_SCRIPTS_DIR) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS_DIR))

from experiment_background_pipeline_slice import (  # type: ignore
    KST,
    PLAN_VERSION,
    W6_IMAGE_BACKEND,
    _check_image_imports_present,
    _check_production_diff_empty,
    _maybe_print_imports,
)

W14_STAGE = "w14_candidate_floor_plan_render_slice"
W14_IMAGE_BACKEND = W6_IMAGE_BACKEND  # carry-only; this stage makes no image call

_DEFAULT_OUTPUT_ROOT = (
    _REPO_ROOT / "scripts_output" / "candidate_floor_plan_render_slice_experiment"
)


def _run_id() -> str:
    import secrets

    return datetime.now(KST).strftime("%Y%m%d_%H%M") + "_" + secrets.token_hex(3)


def _parse_args(argv):
    p = argparse.ArgumentParser(
        description="W14 candidate_floor_plan_render_slice — payload preview only, no image API call"
    )
    p.add_argument(
        "--derive-candidate-fp-from",
        required=True,
        help="Path to a prior W12 success run dir (containing floor_plan_prompt_candidate.json + "
             "per_bg_render_reference_instruction.json + run_meta.json).",
    )
    p.add_argument("--output-root", default=str(_DEFAULT_OUTPUT_ROOT))
    p.add_argument("--diag-print-imports", action="store_true")
    return p.parse_args(argv)


# ─────────────────────────────────────────────────────────────────────────────
# W12 artifact loader
# ─────────────────────────────────────────────────────────────────────────────

def _load_w12_artifacts(prev_run_dir: Path) -> dict:
    required = {
        "candidate": "floor_plan_prompt_candidate.json",
        "per_bg": "per_bg_render_reference_instruction.json",
        "run_meta": "run_meta.json",
    }
    out: Dict[str, Any] = {}
    missing: List[str] = []
    for key, fname in required.items():
        p = prev_run_dir / fname
        if not p.exists():
            missing.append(fname)
            continue
        out[key] = json.loads(p.read_text())
    # W14D: optional `source_topology_brief.json` (produced by W15+). Its
    # absence is not a failure — W14 stays backward compatible with the
    # original W12c run directory shape.
    topo_path = prev_run_dir / "source_topology_brief.json"
    if topo_path.exists():
        try:
            out["topology_brief"] = json.loads(topo_path.read_text())
        except Exception:  # noqa: BLE001
            out["topology_brief"] = None
    else:
        out["topology_brief"] = None
    out["_missing"] = missing
    out["_prev_run_id"] = prev_run_dir.name
    return out


# ─────────────────────────────────────────────────────────────────────────────
# Payload assembly
# ─────────────────────────────────────────────────────────────────────────────

def _api_call_shape() -> Dict[str, Any]:
    """Literal gpt-image-2 generate request shape. No real call is made."""
    return {
        "client_method": "images.generate",
        "model": W14_IMAGE_BACKEND,
        "size": "1024x1024",
        "quality": "high",
        "n": 1,
    }


def _build_numbered_marker_contract_appendix(
    numbered_elements: List[dict],
) -> str:
    """Deterministic appendix that pins the exact set of candidate marker
    numbers (and labels/categories/position hints as drawing guidance) for
    the image model. All scenario-specific text comes from W12 candidate
    data — the static template carries only generic phrasing.
    """
    lines: List[str] = [
        "Numbered marker contract: draw circular markers with EXACTLY the "
        "listed marker numbers below. Preserve the exact non-consecutive "
        "marker numbers listed below. Do not renumber, do not omit listed "
        "markers, and do not invent additional marker numbers."
    ]
    for entry in numbered_elements or []:
        if not isinstance(entry, dict):
            continue
        n = entry.get("number")
        if not isinstance(n, int):
            continue
        label = entry.get("label") or ""
        category = entry.get("category") or ""
        position_hint = entry.get("position_hint") or ""
        lines.append(f"#{n} {label} — {category} — {position_hint}")
    lines.append(
        "For plot_device entries, draw only an abstract floor-plan "
        "marker/zone cue, not a literal entity instance or action."
    )
    lines.append(
        "Do not render category names or position hints as diagram text; "
        "use them only as drawing guidance."
    )
    return "\n".join(lines)


def _assemble_candidate_diagram_prompt(base_prompt: str, appendix: str) -> str:
    base = (base_prompt or "").rstrip()
    if not appendix:
        return base
    return base + "\n\n" + appendix


def _build_topology_rendering_contract_appendix(
    fp_id: str, topology_brief: Optional[dict],
) -> str:
    """W14D: deterministic appendix that pins scale, open/enclosed unit
    layout, and furniture-as-marker policy. Returns empty string when the
    optional W15+ `source_topology_brief.json` is missing or when the
    target fp_id is absent — keeps W14 backward compatible with the
    original W12c shape.

    Field sources used (exact JSON, no regex/substring):
      topology_brief["source_topology_by_fp"][fp_id]["spatial_units"][*]
        .unit_id / .unit_label / .is_enclosed_room
      topology_brief["source_topology_by_fp"][fp_id]["relationships"][*]
        .from_unit / .to_unit / .relation_kind
    """
    if not topology_brief or not isinstance(topology_brief, dict):
        return ""
    by_fp = (topology_brief.get("source_topology_by_fp") or {})
    topo = by_fp.get(fp_id)
    if not isinstance(topo, dict):
        return ""

    units = topo.get("spatial_units") or []
    rels = topo.get("relationships") or []

    enclosed_lines: List[str] = []
    non_enclosed_lines: List[str] = []
    label_by_id: Dict[str, str] = {}
    for u in units:
        if not isinstance(u, dict):
            continue
        uid = u.get("unit_id") or ""
        if not uid:
            continue
        label = u.get("unit_label") or uid
        label_by_id[uid] = label
        line = f"- {uid} ({label})"
        if u.get("is_enclosed_room") is True:
            enclosed_lines.append(line)
        else:
            non_enclosed_lines.append(line)

    def _rel_label(rel_kind: str) -> str:
        return rel_kind.replace("_", " ")

    open_conn_lines: List[str] = []
    door_lines: List[str] = []
    window_lines: List[str] = []
    other_rel_lines: List[str] = []
    for r in rels:
        if not isinstance(r, dict):
            continue
        a = r.get("from_unit") or ""
        b = r.get("to_unit") or ""
        kind = r.get("relation_kind") or "unknown"
        if not a or not b:
            continue
        a_label = label_by_id.get(a) or a
        b_label = label_by_id.get(b) or b
        line = f"- {a} ({a_label}) <-> {b} ({b_label}) :: {_rel_label(kind)}"
        if kind == "open_connection":
            open_conn_lines.append(line)
        elif kind == "door_between":
            door_lines.append(line)
        elif kind == "window_to_exterior":
            window_lines.append(line)
        else:
            other_rel_lines.append(line)

    out: List[str] = ["Topology rendering contract:"]
    out.append(
        "- Scale preservation: preserve the base prompt's stated scale. "
        "Do not upscale compact / small units into spacious apartment "
        "proportions. Keep clearances tight and utilitarian, matching the "
        "described unit type."
    )
    if enclosed_lines:
        out.append(
            "- Full-wall enclosed units (must be drawn as separately "
            "enclosed rooms with full walls):"
        )
        out.extend("  " + line for line in enclosed_lines)
    if non_enclosed_lines:
        out.append(
            "- Open / non-enclosed units (zones inside the larger plan, "
            "no full separating walls around them):"
        )
        out.extend("  " + line for line in non_enclosed_lines)
    if open_conn_lines:
        out.append(
            "- Open-connection pairs: these units must remain visually "
            "open to each other. Do NOT add a full separating wall, a "
            "doorway opening, or a corridor between them. They share "
            "continuous floor area:"
        )
        out.extend("  " + line for line in open_conn_lines)
    if door_lines:
        out.append(
            "- Door / wall-boundary pairs: separated by a wall with a "
            "door opening (single door swing arc):"
        )
        out.extend("  " + line for line in door_lines)
    if window_lines:
        out.append("- Window-to-exterior pairs:")
        out.extend("  " + line for line in window_lines)
    if other_rel_lines:
        out.append("- Other relationships:")
        out.extend("  " + line for line in other_rel_lines)
    out.append(
        "- Furniture / fixture markers must be drawn as symbols INSIDE "
        "their referenced unit. They must not create room boundaries, "
        "walls, corridors, or partition dividers unless the element's "
        "position_hint explicitly says divider or partition."
    )
    out.append(
        "- Wall-positioned furniture or fixtures must sit on (or against) "
        "the hinted wall inside the unit. They must not float in the "
        "middle of the unit as a room divider."
    )
    out.append(
        "- Fixture compression rule: small fixtures (wall-mounted screens, "
        "devices, appliances, single-tile fixtures) must be drawn as small "
        "compact symbols, NOT as long horizontal panels or bars that "
        "stretch across open space. If a fixture could read as a divider "
        "between open-connection units, reduce its geometry to a tiny "
        "wall icon paired with its marker number — do not extrude it into "
        "a partition shape."
    )
    out.append(
        "- Compact open-zone discipline: open zones whose scale is marked "
        "compact / nook / wall-run must remain visually small. Do not "
        "scale a compact open zone into a full-sized room. The base "
        "prompt's overall unit scale is authoritative."
    )
    placement_audit = (
        topology_brief.get("element_placement_and_unit_scale_audit") or {}
    )
    unit_constraints = placement_audit.get("unit_scale_constraints") or []
    if unit_constraints:
        out.append(
            "- Unit scale constraints from the source-topology audit "
            "(these override any spacious default):"
        )
        for c in unit_constraints:
            if not isinstance(c, dict):
                continue
            uid = c.get("unit_id") or ""
            if not uid:
                continue
            out.append(
                "  - "
                f"{uid}: enclosure={c.get('enclosure_mode') or ''}; "
                f"scale={c.get('relative_scale_hint') or ''}; "
                f"open_connection={c.get('open_connection_behavior') or ''}; "
                f"notes={c.get('render_notes') or ''}"
            )
    element_constraints = []
    for c in (placement_audit.get("element_placement_constraints") or []):
        if not isinstance(c, dict) or (c.get("fp_id") or "") != fp_id:
            continue
        category = str(c.get("category") or "").lower()
        mode = str(c.get("placement_mode") or "")
        has_non_default_guard = (
            bool(c.get("avoid_open_connection_boundary"))
            or bool(c.get("must_not_form_boundary"))
            or bool(c.get("conflict_note"))
        )
        if (
            category in {"area", "opening"}
            and mode in {"area_label_only", "opening_marker"}
            and not has_non_default_guard
        ):
            continue
        element_constraints.append(c)
    if element_constraints:
        out.append(
            "- Element placement constraints from the source-topology "
            "audit. If a marker line above gives a looser position, this "
            "placement constraint wins:"
        )
        out.append(
            "  - For any element where avoid_open_connection_boundary=True, "
            "do not use the open connection edge as the element's wall, "
            "backing, divider, or implied boundary. If a direction or wall "
            "label is ambiguous, choose a safe perimeter inside the same "
            "unit and keep the element visibly non-dividing."
        )
        for c in sorted(element_constraints,
                        key=lambda x: x.get("number") if isinstance(x.get("number"), int) else 10**9):
            n = c.get("number")
            if not isinstance(n, int):
                continue
            out.append(
                "  - "
                f"#{n} unit={c.get('unit_id_pointer') or ''}; "
                f"mode={c.get('placement_mode') or ''}; "
                f"anchor={c.get('anchor_surface_hint') or ''}; "
                f"avoid_open_connection_boundary={bool(c.get('avoid_open_connection_boundary'))}; "
                f"must_not_form_boundary={bool(c.get('must_not_form_boundary'))}; "
                f"revised_position={c.get('revised_position_hint') or ''}; "
                f"conflict={c.get('conflict_note') or ''}"
            )
    return "\n".join(out)


def _build_must_show_marker_summary(numbered_elements: List[dict]) -> str:
    """W14G: highest-priority summary placed at the very TOP of the
    assembled prompt. Every numbered element MUST appear as a visible
    circular marker in the rendered diagram. For categories whose
    depiction is ambiguous (plot_device, wall-surface markers), the
    rendered output may safely reduce to "marker circle only", but the
    marker number itself must never be omitted.

    Generic phrasing only — no scenario-specific lexicon. Derived
    purely from the candidate_numbered_elements list already vetted by
    the marker contract appendix.
    """
    rows: List[str] = []
    for entry in numbered_elements or []:
        if not isinstance(entry, dict):
            continue
        n = entry.get("number")
        if not isinstance(n, int):
            continue
        category = entry.get("category") or ""
        rows.append(f"  - #{n} ({category})")
    if not rows:
        return ""
    out: List[str] = [
        "Must-show markers (HIGHEST PRIORITY, read BEFORE anything else): "
        "every numbered marker listed below MUST appear in the rendered "
        "diagram as a small circular marker that visibly shows the "
        "number. Marker visibility takes priority over object/fixture "
        "depiction. If geometry for an element is unclear or risks "
        "looking like a divider, an abstract wall-surface or floor-"
        "surface marker circle (number-only, no extra object shape) is "
        "an acceptable rendering. The marker number itself must never "
        "be omitted, replaced, or hidden under a larger graphic:"
    ]
    out.extend(rows)
    return "\n".join(out)


def _build_do_not_divider_elements_summary(
    fp_id: str, topology_brief: Optional[dict],
) -> str:
    """W14F: high-salience summary placed at the TOP of the assembled
    prompt. The base prompt + topology appendix can get long; the image
    model loses the "do not draw as a partition" guidance for individual
    fixtures. This helper extracts every element whose
    `must_not_form_boundary` is true (from the W15e placement audit)
    and presents them as a short list before the rest of the assembled
    prompt. Generic phrasing only.

    Uses ONLY exact JSON fields from
    `topology_brief.element_placement_and_unit_scale_audit
        .element_placement_constraints[*]` — no regex / substring.
    Returns empty string when the audit or matching items are missing.
    """
    if not topology_brief or not isinstance(topology_brief, dict):
        return ""
    audit = topology_brief.get("element_placement_and_unit_scale_audit")
    if not isinstance(audit, dict):
        return ""
    constraints = audit.get("element_placement_constraints") or []
    if not isinstance(constraints, list):
        return ""
    rows: List[str] = []
    for c in constraints:
        if not isinstance(c, dict):
            continue
        if c.get("fp_id") != fp_id:
            continue
        if c.get("must_not_form_boundary") is not True:
            continue
        n = c.get("number")
        if not isinstance(n, int):
            continue
        unit = c.get("unit_id_pointer") or ""
        category = c.get("category") or ""
        placement_mode = c.get("placement_mode") or ""
        anchor = c.get("anchor_surface_hint") or ""
        rows.append(
            f"  - #{n} ({category}; unit={unit}; placement={placement_mode}; "
            f"anchor={anchor})"
        )
    if not rows:
        return ""
    out: List[str] = [
        "Do-not-divider elements (HIGH PRIORITY, read first): the "
        "following numbered elements MUST be drawn as compact symbols "
        "inside their stated unit and MUST NOT be drawn as long panels, "
        "bars, full walls, corridors, or partition dividers — even if "
        "the rest of the assembled prompt is long. If geometry is "
        "ambiguous, reduce to a tiny wall icon plus the marker number "
        "only. Do not let any of these elements become a boundary "
        "between two open-connection units:"
    ]
    out.extend(rows)
    return "\n".join(out)


def _build_candidate_payloads(
    *, candidates: Dict[str, dict], run_dir: Path,
    topology_brief: Optional[dict] = None,
) -> Dict[str, dict]:
    payloads: Dict[str, dict] = {}
    png_dir = run_dir / "png"
    for fp_id, fp in (candidates or {}).items():
        if not isinstance(fp, dict):
            continue
        expected_output_png_path = str(png_dir / f"{fp_id}.png")
        numbered = list(fp.get("candidate_numbered_elements") or [])
        base_prompt = fp.get("candidate_diagram_t2i_prompt") or ""
        must_show_summary = _build_must_show_marker_summary(numbered)
        do_not_divider_summary = _build_do_not_divider_elements_summary(
            fp_id, topology_brief,
        )
        topology_appendix = _build_topology_rendering_contract_appendix(
            fp_id, topology_brief,
        )
        marker_appendix = _build_numbered_marker_contract_appendix(numbered)
        # W14G order: must-show-marker summary (HIGHEST PRIORITY, TOP) ->
        # do-not-divider summary -> base prompt -> topology contract ->
        # marker contract.
        assembled = ""
        if must_show_summary:
            assembled = must_show_summary.rstrip()
        if do_not_divider_summary:
            sep = "\n\n" if assembled else ""
            assembled = assembled + sep + do_not_divider_summary.rstrip()
        sep = "\n\n" if assembled else ""
        assembled = assembled + sep + (base_prompt or "").rstrip()
        assembled = _assemble_candidate_diagram_prompt(assembled, topology_appendix)
        assembled = _assemble_candidate_diagram_prompt(assembled, marker_appendix)
        payloads[fp_id] = {
            "fp_id": fp_id,
            "group_id_pointer": fp.get("group_id_pointer") or "",
            "candidate_diagram_t2i_prompt": base_prompt,
            "must_show_marker_summary": must_show_summary,
            "do_not_divider_elements_summary": do_not_divider_summary,
            "topology_rendering_contract_appendix": topology_appendix,
            "numbered_marker_contract_appendix": marker_appendix,
            "assembled_candidate_diagram_prompt": assembled,
            "candidate_key_elements": list(fp.get("candidate_key_elements") or []),
            "candidate_numbered_elements": numbered,
            "expected_image_model": W14_IMAGE_BACKEND,
            "api_method_preview": "images.generate",
            "api_call_shape": _api_call_shape(),
            "expected_output_kind": "candidate_floor_plan_png",
            "expected_output_png_path": expected_output_png_path,
        }
    return payloads


# ─────────────────────────────────────────────────────────────────────────────
# Compatibility report — 5 structural invariants
# ─────────────────────────────────────────────────────────────────────────────

_RENUMBER_OMIT_INVENT_PROHIBITION_TOKENS = (
    "renumber", "omit", "invent", "non-consecutive",
)
_DIAGRAM_TEXT_SUPPRESSION_TOKENS = (
    "category names or position hints",
    "drawing guidance",
)


def _build_w14_compatibility_report(
    *, candidates: Dict[str, dict],
    per_bg: Dict[str, dict],
    payloads: Dict[str, dict],
    production_diff_empty: bool,
    db_write_count: int,
    image_import_seen: bool,
    missing_inputs: List[str],
    prev_run_id: str,
    stage_status: str,
    topology_brief: Optional[dict] = None,
) -> dict:
    inv: Dict[str, Dict[str, Any]] = {}

    # 1. W14 inputs present + every candidate fp has required fields. (bg→fp
    # coverage and expected_image_model parity are re-checked downstream in
    # W14b — keeping the W14 invariant set lean per Codex W14C spec.)
    parsable_fps: List[str] = []
    missing_fields: Dict[str, List[str]] = {}
    for fp_id, fp in (candidates or {}).items():
        gaps: List[str] = []
        if not isinstance(fp, dict):
            missing_fields[fp_id] = ["not_a_dict"]
            continue
        if not fp.get("candidate_diagram_t2i_prompt"):
            gaps.append("candidate_diagram_t2i_prompt")
        ne = fp.get("candidate_numbered_elements")
        if not isinstance(ne, list) or len(ne) == 0:
            gaps.append("candidate_numbered_elements")
        if gaps:
            missing_fields[fp_id] = gaps
        else:
            parsable_fps.append(fp_id)
    inv["w14_inputs_present"] = {
        "pass": bool(parsable_fps) and not missing_fields and not missing_inputs,
        "detail": {
            "candidate_fp_count": len(candidates or {}),
            "parsable_fps": sorted(parsable_fps),
            "missing_fields_by_fp": missing_fields,
            "missing_inputs": list(missing_inputs),
            "stage_status": stage_status,
            "prev_run_id": prev_run_id,
            "downstream_check": (
                "bg_to_fp_references_resolvable and "
                "expected_image_model_is_gpt_image_2 are exercised in W14b"
            ),
        },
    }

    # 2. Every candidate marker number must appear in the assembled prompt.
    coverage_failures: Dict[str, List[int]] = {}
    for fp_id, p in (payloads or {}).items():
        assembled = p.get("assembled_candidate_diagram_prompt") or ""
        missing_nums: List[int] = []
        for entry in p.get("candidate_numbered_elements") or []:
            n = entry.get("number") if isinstance(entry, dict) else None
            if not isinstance(n, int):
                continue
            if f"#{n}" not in assembled:
                missing_nums.append(n)
        if missing_nums:
            coverage_failures[fp_id] = missing_nums
    inv["marker_contract_includes_all_candidate_numbers"] = {
        "pass": not coverage_failures,
        "detail": {
            "missing_numbers_by_fp": coverage_failures,
            "payload_fp_set": sorted((payloads or {}).keys()),
        },
    }

    # 3. Literal prohibition keywords are present in every appendix.
    prohibition_failures: Dict[str, List[str]] = {}
    for fp_id, p in (payloads or {}).items():
        appendix = (p.get("numbered_marker_contract_appendix") or "").lower()
        missing_kw = [
            kw for kw in _RENUMBER_OMIT_INVENT_PROHIBITION_TOKENS
            if kw not in appendix
        ]
        if missing_kw:
            prohibition_failures[fp_id] = missing_kw
    inv["renumber_omit_invent_prohibitions_present"] = {
        "pass": not prohibition_failures,
        "detail": {
            "missing_keywords_by_fp": prohibition_failures,
            "required_keywords": list(_RENUMBER_OMIT_INVENT_PROHIBITION_TOKENS),
        },
    }

    # 4. Diagram-text suppression clause present in every appendix.
    suppression_failures: Dict[str, List[str]] = {}
    for fp_id, p in (payloads or {}).items():
        appendix = (p.get("numbered_marker_contract_appendix") or "").lower()
        missing_tokens = [
            t for t in _DIAGRAM_TEXT_SUPPRESSION_TOKENS if t not in appendix
        ]
        if missing_tokens:
            suppression_failures[fp_id] = missing_tokens
    inv["diagram_text_suppression_clause_present"] = {
        "pass": not suppression_failures,
        "detail": {
            "missing_tokens_by_fp": suppression_failures,
            "required_tokens": list(_DIAGRAM_TEXT_SUPPRESSION_TOKENS),
        },
    }

    # 5. Combined production / DB / image-API guard. (Original two W14
    # invariants 4 and 5 collapse into this single guard; per-bg
    # bg_to_fp_references_resolvable and model parity now live in W14b.)
    # W14D: when an optional topology brief is provided, every fp that has
    # a `source_topology_by_fp[fp_id]` entry must surface a non-empty
    # `topology_rendering_contract_appendix` carrying the scale/open-zone
    # /furniture contract markers. Backward compatible: missing brief ->
    # auto-PASS with a skip note. Light structural check only — no
    # semantic interpretation of the appendix text beyond literal token
    # presence (these are the generic phrase fragments the helper itself
    # emits, not scenario-specific lexicon).
    topo_by_fp = (
        (topology_brief or {}).get("source_topology_by_fp")
        if isinstance(topology_brief, dict) else None
    ) or {}
    topology_contract_failures: List[dict] = []
    expected_phrases = (
        "Topology rendering contract:",
        "Scale preservation",
        "Furniture / fixture markers",
    )
    for fp_id, p in (payloads or {}).items():
        if fp_id not in topo_by_fp:
            continue
        appendix = p.get("topology_rendering_contract_appendix") or ""
        assembled = p.get("assembled_candidate_diagram_prompt") or ""
        missing_phrases = [ph for ph in expected_phrases if ph not in appendix]
        appendix_in_assembled = bool(appendix) and appendix in assembled
        if not appendix or missing_phrases or not appendix_in_assembled:
            topology_contract_failures.append({
                "fp_id": fp_id,
                "appendix_empty": not appendix,
                "missing_expected_phrases": missing_phrases,
                "appendix_in_assembled": appendix_in_assembled,
            })
    inv["topology_contract_present_when_topology_brief_provided"] = {
        "pass": not topology_contract_failures,
        "detail": {
            "topology_brief_present": bool(topo_by_fp),
            "failures": topology_contract_failures[:20],
            "failure_count": len(topology_contract_failures),
        },
    }

    inv["production_diff_zero_db_write_zero_image_api_call_zero"] = {
        "pass": (
            production_diff_empty
            and db_write_count == 0
            and not image_import_seen
        ),
        "detail": {
            "production_diff_empty": production_diff_empty,
            "db_write_count": db_write_count,
            "image_import_seen": image_import_seen,
            "image_generation_count": 0,
        },
    }

    all_pass = all(v["pass"] for v in inv.values())
    return {"invariants": inv, "all_pass": all_pass}


# ─────────────────────────────────────────────────────────────────────────────
# HTML render
# ─────────────────────────────────────────────────────────────────────────────

def _render_w14_html(run_meta: dict, payloads: Dict[str, dict], report: dict,
                     run_dir: Path) -> None:
    def esc(x):
        return (str(x).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;"))

    inv = (report or {}).get("invariants", {}) or {}
    inv_rows = "".join(
        f"<tr><td>{esc(k)}</td>"
        f"<td class=\"{'pass' if v['pass'] else 'fail'}\">{'PASS' if v['pass'] else 'FAIL'}</td>"
        f"<td><pre>{esc(json.dumps(v.get('detail'), ensure_ascii=False))[:400]}</pre></td></tr>"
        for k, v in inv.items()
    )

    fp_rows = ""
    for fp_id, p in payloads.items():
        numbered = p.get("candidate_numbered_elements") or []
        count = len(numbered)
        preview_labels = ", ".join(
            f"#{e.get('number')} {esc(e.get('label') or '')}"
            for e in numbered[:4]
        )
        if count > 4:
            preview_labels += f", … (+{count - 4} more)"
        shape = p.get("api_call_shape") or {}
        shape_cell = (
            f"method={esc(shape.get('client_method'))} "
            f"model={esc(shape.get('model'))} "
            f"size={esc(shape.get('size'))} "
            f"quality={esc(shape.get('quality'))} "
            f"n={esc(shape.get('n'))}"
        )
        topology_appendix_preview = esc(p.get("topology_rendering_contract_appendix") or "")
        appendix_preview = esc(p.get("numbered_marker_contract_appendix") or "")
        assembled_preview = esc(p.get("assembled_candidate_diagram_prompt") or "")
        fp_rows += (
            f"<tr><td>{esc(fp_id)}</td>"
            f"<td>{esc(p.get('group_id_pointer'))}</td>"
            f"<td>{esc(p.get('api_method_preview'))}</td>"
            f"<td>{esc(p.get('expected_image_model'))}</td>"
            f"<td><pre>{esc(p.get('expected_output_png_path') or '')}</pre></td>"
            f"<td>{count}<br><small>{preview_labels}</small></td>"
            f"<td><pre>{esc(p.get('candidate_diagram_t2i_prompt') or '')[:1200]}</pre></td>"
            f"<td><pre>{topology_appendix_preview[:2400]}</pre></td>"
            f"<td><pre>{appendix_preview[:2000]}</pre></td>"
            f"<td><pre>{assembled_preview[:4000]}</pre></td>"
            f"<td><small>{shape_cell}</small></td></tr>"
        )

    html = f"""<!doctype html><html><head><meta charset=\"utf-8\">
<title>W14 candidate_floor_plan_render_slice {esc(run_meta.get('run_id'))}</title>
<style>body{{font-family:sans-serif;margin:1.5em}}
table{{border-collapse:collapse;margin:0.5em 0}} td,th{{border:1px solid #ccc;padding:4px 8px;vertical-align:top}}
.pass{{color:#080}} .fail{{color:#b00}}
pre{{white-space:pre-wrap;font-size:0.85em;max-width:72ch}}
section{{margin:1.5em 0}}</style></head>
<body>
<h1>W14 — candidate_floor_plan_render_slice {esc(run_meta.get('run_id'))}</h1>
<p>stage: <b>{esc(run_meta.get('stage'))}</b>
| run_status: <b>{esc(run_meta.get('run_status'))}</b>
| exit_code: {esc(run_meta.get('exit_code'))}
| derived_from(W12): {esc(run_meta.get('derived_from'))}
| image_generation_count: <b>{esc(run_meta.get('image_generation_count'))}</b>
| image_generation_backend: <b>{esc(run_meta.get('image_generation_backend'))}</b></p>

<section><h2>1. Candidate floor-plan render payload preview</h2>
<table><tr>
<th>fp_id</th><th>group_id_pointer</th><th>api_method_preview</th>
<th>expected_image_model</th><th>expected_output_png_path</th>
<th>numbered_elements_count</th><th>candidate_diagram_t2i_prompt</th>
<th>topology_rendering_contract_appendix</th>
<th>numbered_marker_contract_appendix</th>
<th>assembled_candidate_diagram_prompt</th>
<th>api_call_shape</th>
</tr>{fp_rows}</table></section>

<section><h2>2. Invariants</h2>
<table><tr><th>invariant</th><th>status</th><th>detail</th></tr>{inv_rows}</table></section>

<details><summary>raw run_meta.json</summary>
<pre>{esc(json.dumps(run_meta, ensure_ascii=False, indent=2))}</pre></details>
<details><summary>raw payloads.json</summary>
<pre>{esc(json.dumps(payloads, ensure_ascii=False, indent=2))[:200000]}</pre></details>
</body></html>"""
    (run_dir / "index.html").write_text(html)


# ─────────────────────────────────────────────────────────────────────────────
# main
# ─────────────────────────────────────────────────────────────────────────────

def main(argv=None) -> int:
    args = _parse_args(argv)
    run_id = _run_id()
    out_root = Path(args.output_root)
    run_dir = out_root / run_id
    run_dir.mkdir(parents=True, exist_ok=True)

    prev_run_dir = Path(args.derive_candidate_fp_from)
    if not prev_run_dir.is_absolute():
        prev_run_dir = Path.cwd() / prev_run_dir

    artifacts = _load_w12_artifacts(prev_run_dir)
    missing = list(artifacts.get("_missing", []))
    outputs: List[str] = []
    failed: List[str] = []
    run_status = "succeeded"
    exit_code = 0
    stage_status = "generated"

    run_meta: Dict[str, Any] = {
        "run_id": run_id,
        "stage": W14_STAGE,
        "plan_version": PLAN_VERSION,
        "generated_at": datetime.now(KST).isoformat(),
        "image_generation_count": 0,
        "image_generation_backend": W14_IMAGE_BACKEND,
        "args": vars(args),
        "derived_from": prev_run_dir.name,
        "outputs": outputs,
        "run_status": run_status,
        "exit_code": exit_code,
        "failed_invariants": failed,
    }

    if missing:
        failed.append("w12_inputs_missing")
        run_meta["run_status"] = "validation_failed"
        run_meta["exit_code"] = 1
        run_meta["failed_invariants"] = failed
        (run_dir / "run_meta.json").write_text(
            json.dumps(run_meta, ensure_ascii=False, indent=2)
        )
        return 1

    w12_candidates = (artifacts["candidate"].get("candidate_floor_plans") or {})
    w12_per_bg = (artifacts["per_bg"].get("per_bg_render_reference_instructions") or {})
    topology_brief = artifacts.get("topology_brief")

    payloads = _build_candidate_payloads(
        candidates=w12_candidates, run_dir=run_dir,
        topology_brief=topology_brief,
    )
    (run_dir / "candidate_floor_plan_render_payloads.json").write_text(
        json.dumps({"payloads": payloads}, ensure_ascii=False, indent=2)
    )
    outputs.append("candidate_floor_plan_render_payloads.json")

    report = _build_w14_compatibility_report(
        candidates=w12_candidates, per_bg=w12_per_bg, payloads=payloads,
        production_diff_empty=_check_production_diff_empty(),
        db_write_count=0,
        image_import_seen=_check_image_imports_present(),
        missing_inputs=missing,
        prev_run_id=prev_run_dir.name,
        stage_status=stage_status,
        topology_brief=topology_brief,
    )
    (run_dir / "candidate_floor_plan_render_compatibility_report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2)
    )
    outputs.append("candidate_floor_plan_render_compatibility_report.json")

    for name, v in report["invariants"].items():
        if not v["pass"] and name not in failed:
            failed.append(name)
    if failed:
        run_status = "validation_failed"
        exit_code = 1

    run_meta["run_status"] = run_status
    run_meta["exit_code"] = exit_code
    run_meta["failed_invariants"] = failed
    run_meta["outputs"] = outputs
    _render_w14_html(run_meta, payloads, report, run_dir)
    outputs.append("index.html")
    run_meta["outputs"] = outputs
    (run_dir / "run_meta.json").write_text(
        json.dumps(run_meta, ensure_ascii=False, indent=2)
    )
    _maybe_print_imports(args)
    return exit_code


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