"""experiment_floor_plan_generation_slice — W12 producer-side floor plan slice.

Read-only experiment. Production code is NOT modified. No DB writes. No image
API calls. gpt-image-2 stays the carry-only image backend identifier.

Flow:
  Stage A — DirectorSetLayoutBrief
            Carry W11 director_set_brief_plan.json verbatim (W11 is baseline /
            soft evidence, not authoritative SOT; the hard SOT remains the W3
            adapter + source_bundle).
  Stage B — FloorPlanPromptCandidate
            LLM rewrites a candidate floor-plan prompt per fp_id (diagram t2i,
            numbered_elements, camera_recommendations keyed on EXPERIMENT
            bg_ids). Production floor_plan_prompt JSON is shown as baseline /
            soft evidence only.
  Stage C — PerBgRenderReferenceInstruction
            LLM emits per experiment bg_id: use/ignore numbered_elements,
            camera_axis_used, prior_bg_ref_role, render_prompt_appendix and a
            final_prompt_assembly_preview (W13 will consume this, not this
            wave).

CLI:
  --derive-floor-plan-candidate-from <W11_run_dir>  (required)
  --generate                                        (optional — LLM call)
  --model gemini-3.5-flash                          (default)
  --output-root <path>                              (defaults to scripts_output)
"""
from __future__ import annotations

import argparse
import json
import os
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))

# Import read-only helpers from the sibling experiment script. We do NOT execute
# any of its stage main entries; we only borrow loaders + sentinels + constants.
from experiment_background_pipeline_slice import (  # type: ignore
    DEFAULT_PROJECT_ID,
    DEFAULT_EPISODE_ID,
    DEFAULT_MODEL,
    KST,
    PLAN_VERSION,
    W6_IMAGE_BACKEND,
    _check_image_imports_present,
    _check_production_diff_empty,
    _load_backend_env,
    _load_production_floor_plan_context,
    _load_w3_adapter_from_chain,
    _maybe_print_imports,
    _resolve_source_bundle_via_derived_from_chain,
)

W12_STAGE = "w12_floor_plan_generation_slice"
W12_IMAGE_BACKEND = W6_IMAGE_BACKEND  # carry-only; this stage makes no image call

_DEFAULT_OUTPUT_ROOT = (
    _REPO_ROOT / "scripts_output" / "floor_plan_generation_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="W12 floor_plan_generation_slice — producer-side floor-plan candidate + per-bg render-ref instruction"
    )
    p.add_argument(
        "--derive-floor-plan-candidate-from",
        required=True,
        help="Path to a prior W11 success run dir (containing director_set_brief_plan.json + run_meta.json with derived_from chain back to W3).",
    )
    p.add_argument("--output-root", default=str(_DEFAULT_OUTPUT_ROOT))
    p.add_argument("--generate", action="store_true",
                   help="actual LLM call (default: dry-run placeholder)")
    p.add_argument("--model", default=DEFAULT_MODEL)
    p.add_argument("--diag-print-imports", action="store_true")
    return p.parse_args(argv)


# ─────────────────────────────────────────────────────────────────────────────
# W11 artifact loader
# ─────────────────────────────────────────────────────────────────────────────

def _load_w11_artifacts(prev_run_dir: Path) -> dict:
    required = {
        "brief": "director_set_brief_plan.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())
    out["_missing"] = missing
    out["_prev_run_id"] = prev_run_dir.name
    return out


# ─────────────────────────────────────────────────────────────────────────────
# LLM schema (Stage B + Stage C in one call)
# ─────────────────────────────────────────────────────────────────────────────

LLM_W12_SCHEMA = {
    "type": "object",
    "required": [
        "candidate_floor_plans",
        "per_bg_render_reference_instructions",
    ],
    "properties": {
        "candidate_floor_plans": {
            "type": "object",
            "additionalProperties": {
                "type": "object",
                "required": [
                    "fp_id", "group_id_pointer",
                    "candidate_diagram_t2i_prompt",
                    "candidate_key_elements",
                    "candidate_numbered_elements",
                    "candidate_camera_recommendations",
                    "reconciliation_notes_vs_production",
                ],
                "properties": {
                    "fp_id": {"type": "string"},
                    "group_id_pointer": {"type": "string"},
                    "candidate_diagram_t2i_prompt": {"type": "string"},
                    "candidate_key_elements": {
                        "type": "array", "items": {"type": "string"}
                    },
                    "candidate_numbered_elements": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "required": ["number", "label", "category",
                                         "position_hint", "zone_id_pointer"],
                            "properties": {
                                "number": {"type": "integer"},
                                "label": {"type": "string"},
                                "category": {"type": "string"},
                                "position_hint": {"type": "string"},
                                "zone_id_pointer": {"type": "string"},
                            },
                        },
                    },
                    "candidate_camera_recommendations": {
                        "type": "array",
                        "items": {
                            "type": "object",
                            "required": ["bg_id", "sub_location",
                                         "camera_position", "camera_height",
                                         "lens_hint", "framing_notes"],
                            "properties": {
                                "bg_id": {"type": "string"},
                                "sub_location": {"type": "string"},
                                "camera_position": {"type": "string"},
                                "camera_height": {"type": "string"},
                                "lens_hint": {"type": "string"},
                                "framing_notes": {"type": "string"},
                            },
                        },
                    },
                    "reconciliation_notes_vs_production": {"type": "string"},
                },
            },
        },
        "per_bg_render_reference_instructions": {
            "type": "object",
            "additionalProperties": {
                "type": "object",
                "required": [
                    "bg_id", "group_id", "fp_id", "applies_to_shots",
                    "floor_plan_ref_role",
                    "use_numbered_elements", "ignore_numbered_elements",
                    "camera_axis_used", "camera_axis_source",
                    "visible_zone_scope",
                    "prior_bg_ref_role",
                    "render_prompt_appendix",
                    "final_prompt_assembly_preview",
                ],
                "properties": {
                    "bg_id": {"type": "string"},
                    "group_id": {"type": "string"},
                    "fp_id": {"type": "string"},
                    "applies_to_shots": {"type": "array",
                                         "items": {"type": "string"}},
                    "floor_plan_ref_role": {"type": "string",
                                            "enum": ["layout_only", "not_used"]},
                    "use_numbered_elements": {"type": "array",
                                              "items": {"type": "integer"}},
                    "ignore_numbered_elements": {"type": "array",
                                                 "items": {"type": "integer"}},
                    "camera_axis_used": {"type": "string"},
                    "camera_axis_source": {
                        "type": "string",
                        "enum": ["w11_axis",
                                 "candidate_camera_recommendation"],
                    },
                    "visible_zone_scope": {"type": "array",
                                           "items": {"type": "string"}},
                    "prior_bg_ref_role": {
                        "type": "string",
                        "enum": ["none", "style_only",
                                 "same_room_state_transition",
                                 "weak_continuity"],
                    },
                    "render_prompt_appendix": {"type": "string"},
                    "final_prompt_assembly_preview": {"type": "string"},
                },
            },
        },
    },
}


W12_SYSTEM_PROMPT = (
    "You are a film-production set designer producing two layers for a "
    "rendering experiment: (B) a CANDIDATE floor-plan prompt per fp_id and "
    "(C) a per-bg render-reference instruction layer for the downstream "
    "image API. "
    "Hard structural evidence (authoritative IDs and facts): the experiment "
    "adapter background_catalog (bg_id, depends_on_fp, sub_location_label, "
    "state_label_raw, loc_id, applies_to_shots) and selected_shots. "
    "Strong soft evidence: the W11 director_set_brief.group_set_briefs "
    "(spatial_zones with supporting_numbered_elements, openings_and_"
    "transitions, persistent_fixtures, state_transition_zones, camera_axes, "
    "conflicts_and_assumptions, default_avoidance). Treat as your starting "
    "scaffolding but you may refine. "
    "Baseline/soft evidence: the production floor_plan_prompt JSON for the "
    "same project/episode (numbered_elements, camera_recommendations, "
    "t2i_prompt). Production bg_ids in camera_recommendations may NOT equal "
    "experiment bg_ids — do not copy them verbatim into "
    "candidate_camera_recommendations. "
    "Weak visual evidence: floor_plan_render PNG path/exists only. Do not "
    "rely on pixel positions in the PNG. "
    "STAGE B — coverage is mandatory. For EVERY fp_id that appears as a key "
    "of experiment_fp_to_bg_index, emit ONE candidate_floor_plans entry. Do "
    "not skip any fp_id, even when its bg list is short or the production "
    "fp has identical content. "
    "candidate_diagram_t2i_prompt is a single-paragraph flat schematic 2D "
    "floor-plan instruction (no 3D, no perspective, no rendering effects, "
    "no proper nouns). candidate_numbered_elements is the smallest SUFFICIENT "
    "set: one entry per zone/opening/persistent_fixture/state_transition_zone "
    "needed by any experiment bg using this fp; each entry has "
    "zone_id_pointer = an axis_key/zone_id present in the W11 group_set_briefs "
    "(or empty string when no zone match exists). SUFFICIENCY rule (HARD, "
    "enforced before you emit Stage B output): for every bg in "
    "experiment_bg_entries, read its w11_final_plate_prompt_text closely and "
    "list every concrete physical noun in it that names a fixture, opening, "
    "appliance, screen/display, light source described as a physical device, "
    "or plot device. Include nouns described as belonging to an ADJACENT "
    "or far-side zone whose physical presence the renderer must depict "
    "(an adjacent visible fixture, an opening into another zone, an "
    "architectural covering across the frame, a device emitting a physical "
    "effect from off-primary-zone, etc.). Every such enumerated "
    "physical noun MUST correspond to either an existing "
    "candidate_numbered_elements entry whose label clearly covers it, or to "
    "a candidate area entry. If no existing entry covers it, ADD a new "
    "candidate_numbered_elements entry for it: assign the next free integer "
    "number, write its label from the source noun, set zone_id_pointer to "
    "the most appropriate W11 zone_id or empty string when none applies, "
    "and pick category from area/opening/furniture/plot_device/fixture as "
    "best fits. Atmosphere words (light, mood, fog, haze, palette, glow as "
    "an effect noun, ambience) are NOT physical fixtures and are excluded. "
    "Do not rely on any single proper noun: the rule is generic over any "
    "physical-object noun phrase. Apply this verification to every bg before "
    "emitting Stage B output; you may not omit a fixture that is narrated "
    "in any current bg's final_plate_prompt_text. candidate_camera_recommendations is "
    "keyed on EXPERIMENT bg_ids only — for every experiment bg that consumes "
    "this fp, emit ONE entry whose bg_id is the experiment bg_id verbatim "
    "and whose camera_position uses number references (e.g. "
    "'near #1 looking toward #3'). reconciliation_notes_vs_production "
    "explains where you departed from production numbered_elements or "
    "camera_recommendations and why. "
    "STAGE C — coverage is mandatory. For EVERY bg_id present in "
    "experiment_bg_entries, emit ONE per_bg_render_reference_instructions "
    "entry. fp_id MUST equal the bg's fp_id_via_adapter verbatim. floor_plan_ref_role is 'layout_only' (use 'not_used' only "
    "when no candidate fp exists for the bg's adapter depends_on_fp). "
    "use_numbered_elements is a list of integer numbers chosen STRICTLY from "
    "the candidate_floor_plans[fp_id].candidate_numbered_elements.number set "
    "of the bg's adapter fp_id. The downstream renderer treats these as "
    "LAYOUT cues. ignore_numbered_elements contains ONLY numbers that must "
    "NOT be visible in this bg's rendered frame (e.g. another zone the "
    "camera does not look into, a plot device that does not exist in this "
    "state). It is also chosen STRICTLY from the same "
    "candidate_numbered_elements.number set authored in Stage B for this fp "
    "— do NOT cite production fp numbers that are not in your Stage B "
    "candidate. use_numbered_elements and ignore_numbered_elements do NOT "
    "need to partition the full candidate set: numbers that are neither "
    "primary subject nor forbidden (adjacent openings, peripheral fixtures, "
    "elements naturally visible at the frame edge) should be OMITTED from "
    "both lists. CRITICAL: if your final_prompt_assembly_preview narrates "
    "the physical presence of an element (an architectural opening, a "
    "covering, an access fixture, an adjacent visible fixture, or a "
    "physical device with a physical effect), that element's number MUST "
    "NOT appear in "
    "ignore_numbered_elements for this bg — put it in use_numbered_elements "
    "or leave it unlisted. use_numbered_elements ∩ ignore_numbered_elements "
    "= ∅. "
    "camera_axis_used is the axis_key the renderer uses for framing. "
    "camera_axis_source declares its origin and MUST be picked by the "
    "following rule (HARD): if EVERY zone_id listed in this bg's "
    "visible_zone_scope is either the from_zone or the to_zone of at least "
    "one W11 group_set_briefs camera_axes entry of this bg's group, AND the "
    "narrative subject of the bg's final_plate_prompt_text is consistent "
    "with that axis (e.g. the camera is sighting along that from→to path), "
    "set camera_axis_source='w11_axis' and camera_axis_used MUST equal an "
    "existing axis_key of that group's camera_axes. Otherwise — most "
    "importantly when the bg's visible_zone_scope is restricted to zones "
    "that the W11 axis does NOT pass through, or when the bg's primary "
    "subject lies entirely within a single zone that no W11 axis directly "
    "traverses — set camera_axis_source='candidate_camera_recommendation', "
    "make camera_axis_used a SHORT free-form slug summarising the local "
    "framing (form: 'local_zone_oblique' or 'local_area_view'), and ensure "
    "this bg_id is "
    "present in candidate_camera_recommendations of its candidate fp. Do "
    "NOT default to a W11 axis to satisfy the schema; choose the source "
    "honestly per the rule above. visible_zone_scope lists the zone_ids "
    "whose INTERIOR space is the PRIMARY subject of this bg's rendered "
    "frame. Exclude zones that are only peeked at through an opening as "
    "background, only named atmospherically in the prose, or that the "
    "camera does not actually frame. If the camera stands in zone A and "
    "looks toward an opening into zone B but the rendered subject is the "
    "zone-A interior, visible_zone_scope is ['A'] only; if the rendered "
    "subject is the zone-B interior seen THROUGH that opening, "
    "visible_zone_scope is ['B'] (or ['A','B'] only when both interiors "
    "are simultaneously primary subjects). Do NOT pad visible_zone_scope "
    "with adjacent zones merely to make a W11 axis appear consistent. "
    "STRICT GUARDRAIL (HARD): after you pick camera_axis_source='w11_axis' "
    "for a bg, EVERY zone_id you put in visible_zone_scope MUST be either "
    "the from_zone or the to_zone of the camera_axes entry whose axis_key "
    "you chose. If even one zone in visible_zone_scope is not on either "
    "endpoint of that axis, you must instead pick "
    "camera_axis_source='candidate_camera_recommendation' and write "
    "camera_axis_used as a short free-form slug describing the local "
    "framing inside that zone (form: '<zone>_oblique', '<zone>_local_view'). "
    "ADDITIONAL HARD RULE: do NOT shrink visible_zone_scope just to satisfy "
    "the W11 axis endpoint check. If your use_numbered_elements references "
    "candidate entries whose zone_id_pointer is OUTSIDE the chosen "
    "w11_axis endpoints, that proves the renderer must depict zones the "
    "axis does not pass through — you MUST switch source to "
    "candidate_camera_recommendation. Restated as a set rule: "
    "(visible_zone_scope ∪ {candidate_numbered_elements[n].zone_id_pointer "
    "for n in use_numbered_elements if nonempty}) MUST be a subset of the "
    "chosen w11_axis endpoints; otherwise use candidate_camera_recommendation. "
    "Generic example (not project-specific): if a bg's primary subject is "
    "ZONE_X and the only available W11 axis is ZONE_A→ZONE_B with ZONE_X "
    "different from both ZONE_A and ZONE_B, you MUST use "
    "camera_axis_source='candidate_camera_recommendation'. "
    "prior_bg_ref_role tells the downstream renderer how to treat any prior "
    "background PNG: 'none' (no prior carry), 'style_only' (lighting/material "
    "only), 'same_room_state_transition' (when the bg is the same subspace as "
    "its parent and only the state changes), or 'weak_continuity' (loose "
    "spatial tone). HARD selection rule: read the bg's depends_on_bg list. "
    "If a parent bg exists in depends_on_bg, compare visible_zone_scope "
    "between this bg and that parent. If both share the same primary "
    "zone(s) and only an in-zone state delta separates them (content/state/"
    "lighting/clutter changes within the same room), set "
    "'same_room_state_transition'. Set 'weak_continuity' when the parent bg "
    "is an adjacent/loosely-related space. Set 'style_only' when only "
    "tone/lighting carry is appropriate. Set 'none' ONLY when depends_on_bg "
    "is empty or no prior reference makes sense. render_prompt_appendix is "
    "a SHORT (<= 60 words) string "
    "that will be appended verbatim to the downstream gpt-image-2 t2i_prompt; "
    "describe layout_only role + use/ignore numbers + camera axis + "
    "prior_bg_ref_role. final_prompt_assembly_preview is your single "
    "best-guess at the W13 final prompt the renderer would emit: copy the "
    "bg's W11 final_plate_prompt_candidate text (if present) or W7 plate "
    "prompt text, then append two blank lines and the render_prompt_appendix. "
    "Do not invent shots or character entities. Use source-derived wording "
    "only. Output strict JSON matching the provided json_schema; no extra "
    "keys, no commentary."
)


def _build_w12_llm_input(*, w11_brief: dict, adapter_plan: dict,
                         source_bundle: dict, fp_context: dict) -> dict:
    """Compact view fed to the W12 LLM."""
    catalog = adapter_plan.get("background_catalog") or {}
    bg_to_fp = {
        bg: ((entry or {}).get("depends_on_fp") or [""])[0]
        for bg, entry in catalog.items()
    }
    fp_to_bg: Dict[str, List[str]] = {}
    for bg, fp in bg_to_fp.items():
        if not fp:
            continue
        fp_to_bg.setdefault(fp, []).append(bg)

    per_bg_cands = (w11_brief.get("final_plate_prompt_candidates") or {})
    per_bg_sel = (w11_brief.get("per_bg_set_selections") or {})
    group_briefs = (w11_brief.get("group_set_briefs") or {})

    # bg view
    bg_views: List[dict] = []
    for bg_id in sorted(bg_to_fp.keys()):
        catalog_entry = catalog.get(bg_id) or {}
        cand = per_bg_cands.get(bg_id) or {}
        sel = per_bg_sel.get(bg_id) or {}
        bg_views.append({
            "bg_id": bg_id,
            "group_id": sel.get("group_id") or "",
            "fp_id_via_adapter": bg_to_fp.get(bg_id, ""),
            "depends_on_bg": list((catalog_entry.get("depends_on_bg") or [])),
            "applies_to_shots": catalog_entry.get("applies_to_shots") or sel.get("applies_to_shots") or [],
            "sub_location_label": catalog_entry.get("sub_location_label"),
            "state_label_raw": catalog_entry.get("state_label_raw"),
            "loc_id": catalog_entry.get("loc_id"),
            "space_key": catalog_entry.get("space_key"),
            "w11_final_plate_prompt_text": cand.get("final_plate_prompt_text") or "",
            "w11_relevant_numbered_elements": sel.get("relevant_numbered_elements") or [],
            "w11_camera_axis_used": sel.get("camera_axis_used") or "",
        })

    # fp view — compact carry. t2i_prompt is large; drop it from LLM input
    # (we already keep it in the run artifact for HTML/diagnostic). The LLM
    # only needs numbered_elements + camera_recommendations + key_elements as
    # soft evidence.
    fp_views: Dict[str, dict] = {}
    fp_by_id = fp_context.get("floor_plans") or {}
    for fp_id, fp in fp_by_id.items():
        fp_views[fp_id] = {
            "fp_id": fp_id,
            "production_numbered_elements": fp.get("numbered_elements") or [],
            "production_camera_recommendations": fp.get("camera_recommendations") or [],
            "production_key_elements": fp.get("key_elements") or [],
            "png_exists": bool(fp.get("png_exists")),
        }

    # group_set_briefs carry (verbatim)
    return {
        "experiment_groups": list(group_briefs.values()),
        "experiment_bg_entries": bg_views,
        "experiment_fp_to_bg_index": fp_to_bg,
        "production_floor_plans": fp_views,
        "source_episode": {
            "project_id": source_bundle.get("project_id"),
            "episode_id": source_bundle.get("episode_id"),
            "language_hint": source_bundle.get("project_name"),  # carry-only; no semantic parsing
        },
        "json_schema": LLM_W12_SCHEMA,
    }


def _generate_w12_via_llm(llm_input: dict, *, model: str,
                          retry_once: bool = True) -> dict:
    import litellm

    user_prompt = json.dumps(llm_input, ensure_ascii=False)
    routed = (
        model
        if model.startswith("gemini/") or not model.lower().startswith("gemini")
        else f"gemini/{model}"
    )
    if not (os.environ.get("GEMINI_API_KEY") or os.environ.get("GOOGLE_API_KEY")):
        raise RuntimeError("missing GEMINI_API_KEY / GOOGLE_API_KEY env var")
    last_exc: Optional[Exception] = None
    attempts = 2 if retry_once else 1
    for _ in range(attempts):
        try:
            resp = litellm.completion(
                model=routed,
                messages=[
                    {"role": "system", "content": W12_SYSTEM_PROMPT},
                    {"role": "user", "content": user_prompt},
                ],
                response_format={"type": "json_object"},
            )
            raw_text = resp.choices[0].message.content
            decoder = json.JSONDecoder()
            stripped = raw_text.lstrip()
            parsed, _ = decoder.raw_decode(stripped)
            import jsonschema
            jsonschema.validate(parsed, LLM_W12_SCHEMA)
            return parsed
        except Exception as exc:
            last_exc = exc
            continue
    raise RuntimeError(f"w12_llm_failed_after_retry: {last_exc!s}"[:400])


def _build_placeholder_w12(adapter_plan: dict, fp_context: dict,
                           w11_brief: dict) -> dict:
    """Dry-run placeholder shells. Each per-bg use/ignore stay empty so the
    subset invariants pass trivially. Candidate fp entries are skeletal."""
    catalog = adapter_plan.get("background_catalog") or {}
    bg_to_fp = {
        bg: ((entry or {}).get("depends_on_fp") or [""])[0]
        for bg, entry in catalog.items()
    }
    per_bg_sel = (w11_brief.get("per_bg_set_selections") or {})
    per_bg_cands = (w11_brief.get("final_plate_prompt_candidates") or {})

    fp_to_bg: Dict[str, List[str]] = {}
    for bg, fp in bg_to_fp.items():
        if not fp:
            continue
        fp_to_bg.setdefault(fp, []).append(bg)

    candidate_floor_plans: Dict[str, dict] = {}
    for fp_id, bgs in fp_to_bg.items():
        candidate_floor_plans[fp_id] = {
            "fp_id": fp_id,
            "group_id_pointer": (per_bg_sel.get(bgs[0]) or {}).get("group_id", "") if bgs else "",
            "candidate_diagram_t2i_prompt": "placeholder_dry_run",
            "candidate_key_elements": [],
            "candidate_numbered_elements": [],
            "candidate_camera_recommendations": [
                {
                    "bg_id": bg, "sub_location": "placeholder",
                    "camera_position": "placeholder", "camera_height": "placeholder",
                    "lens_hint": "placeholder", "framing_notes": "",
                }
                for bg in bgs
            ],
            "reconciliation_notes_vs_production": "placeholder_dry_run",
        }

    per_bg: Dict[str, dict] = {}
    for bg_id, fp in bg_to_fp.items():
        sel = per_bg_sel.get(bg_id) or {}
        cand = per_bg_cands.get(bg_id) or {}
        per_bg[bg_id] = {
            "bg_id": bg_id,
            "group_id": sel.get("group_id", ""),
            "fp_id": fp,
            "applies_to_shots": sel.get("applies_to_shots") or [],
            "floor_plan_ref_role": "layout_only",
            "use_numbered_elements": [],
            "ignore_numbered_elements": [],
            "camera_axis_used": sel.get("camera_axis_used", ""),
            "camera_axis_source": "w11_axis",
            "visible_zone_scope": [],
            "prior_bg_ref_role": "none",
            "render_prompt_appendix": "placeholder_dry_run appendix",
            "final_prompt_assembly_preview": (cand.get("final_plate_prompt_text") or "")
                + "\n\nplaceholder_dry_run appendix",
        }
    return {
        "candidate_floor_plans": candidate_floor_plans,
        "per_bg_render_reference_instructions": per_bg,
    }


def _build_w12_compatibility_report(
    *, w12: dict, adapter_plan: dict, w11_brief: dict, fp_context: dict,
    production_diff_empty: bool, db_write_count: int,
    image_import_seen: bool, prev_run_id: str, missing_inputs: List[str],
    stage_status: str,
) -> dict:
    inv: Dict[str, Dict[str, Any]] = {}
    catalog = adapter_plan.get("background_catalog") or {}
    bg_ids = sorted(catalog.keys())
    candidates = w12.get("candidate_floor_plans") or {}
    per_bg = w12.get("per_bg_render_reference_instructions") or {}
    group_axes_by_group: Dict[str, set] = {}
    for gid, g in (w11_brief.get("group_set_briefs") or {}).items():
        group_axes_by_group[gid] = {
            ax.get("axis_key")
            for ax in (g.get("camera_axes") or [])
            if ax.get("axis_key")
        }

    inv["inputs_present"] = {
        "pass": len(missing_inputs) == 0,
        "detail": {"missing": missing_inputs, "prev_run": prev_run_id},
    }

    # fp coverage and per-fp bg coverage split. The combined version (single
    # invariant for all-bg-coverage) is brittle when an LLM produces a partial
    # candidate fp set; split keeps each failure addressable.
    bg_to_fp = {
        bg: ((entry or {}).get("depends_on_fp") or [""])[0]
        for bg, entry in catalog.items()
    }
    expected_fp_ids = sorted({fp for fp in bg_to_fp.values() if fp})
    missing_fp = sorted(set(expected_fp_ids) - set(candidates.keys()))
    inv["candidate_floor_plans_cover_all_adapter_fps"] = {
        "pass": stage_status == "placeholder_dry_run" or len(missing_fp) == 0,
        "detail": {"missing_fp": missing_fp[:5], "stage_status": stage_status},
    }

    # within each candidate fp, every adapter-mapped bg has a camera_rec.
    fp_to_bg: Dict[str, list] = {}
    for bg, fp in bg_to_fp.items():
        if fp:
            fp_to_bg.setdefault(fp, []).append(bg)
    per_fp_missing: List[str] = []
    for fp_id, fp_entry in candidates.items():
        expected_bgs = set(fp_to_bg.get(fp_id) or [])
        emitted_bgs = {
            (cr.get("bg_id") or "")
            for cr in fp_entry.get("candidate_camera_recommendations") or []
        }
        missing = sorted(expected_bgs - emitted_bgs)
        if missing:
            per_fp_missing.append(f"{fp_id}:missing={missing[:5]}")
    inv["candidate_camera_recs_cover_all_bgs_within_candidate_fps"] = {
        "pass": len(per_fp_missing) == 0,
        "detail": {"violating": per_fp_missing[:5]},
    }

    # use/ignore subset of candidate numbered_elements per fp + disjoint
    def _allowed_numbers_for_fp(fp_id: str) -> set:
        fp = candidates.get(fp_id) or {}
        return {
            int(e.get("number"))
            for e in (fp.get("candidate_numbered_elements") or [])
            if isinstance(e.get("number"), int)
        }

    use_violations: List[str] = []
    ignore_violations: List[str] = []
    disjoint_violations: List[str] = []
    for bg_id, instr in per_bg.items():
        fp_id = instr.get("fp_id") or ""
        allowed = _allowed_numbers_for_fp(fp_id)
        use = [int(n) for n in (instr.get("use_numbered_elements") or [])]
        ign = [int(n) for n in (instr.get("ignore_numbered_elements") or [])]
        extra_use = [n for n in use if n not in allowed]
        extra_ign = [n for n in ign if n not in allowed]
        if extra_use:
            use_violations.append(f"{bg_id}:fp={fp_id}:extra={extra_use[:5]}")
        if extra_ign:
            ignore_violations.append(f"{bg_id}:fp={fp_id}:extra={extra_ign[:5]}")
        overlap = sorted(set(use) & set(ign))
        if overlap:
            disjoint_violations.append(f"{bg_id}:overlap={overlap[:5]}")
    inv["per_bg_use_numbered_elements_subset_of_candidate"] = {
        "pass": len(use_violations) == 0,
        "detail": {"violating": use_violations[:5]},
    }
    inv["per_bg_ignore_numbered_elements_subset_of_candidate"] = {
        "pass": len(ignore_violations) == 0,
        "detail": {"violating": ignore_violations[:5]},
    }
    inv["per_bg_use_and_ignore_disjoint"] = {
        "pass": len(disjoint_violations) == 0,
        "detail": {"violating": disjoint_violations[:5]},
    }

    # camera_axis_used resolves against its declared source. Three branches:
    # (a) floor_plan_ref_role='not_used' — no candidate fp exists, so the
    #     axis must simply be a valid axis_key in the bg's group's W11
    #     camera_axes (source is informational only);
    # (b) source='w11_axis' — axis_key must be in the bg's group's W11
    #     camera_axes;
    # (c) source='candidate_camera_recommendation' — bg_id must appear in the
    #     candidate fp's camera_recommendations and camera_axis_used must be
    #     a non-empty free-form slug.
    # Placeholder dry-run skips deterministic axis check.
    fp_bg_camera_recs: Dict[str, set] = {}
    fp_number_to_zone: Dict[str, Dict[int, str]] = {}
    for fp_id, fp in candidates.items():
        fp_bg_camera_recs[fp_id] = {
            (cr.get("bg_id") or "")
            for cr in (fp.get("candidate_camera_recommendations") or [])
            if cr.get("bg_id")
        }
        zone_map: Dict[int, str] = {}
        for e in (fp.get("candidate_numbered_elements") or []):
            num = e.get("number")
            zone = e.get("zone_id_pointer") or ""
            if isinstance(num, int) and zone:
                zone_map[num] = zone
        fp_number_to_zone[fp_id] = zone_map
    # Per-group axis_key → {from_zone, to_zone} index. visible_zone_scope of
    # any bg whose camera_axis_source='w11_axis' must be a subset of this set
    # for the axis it claims (no semantic inference; pure set membership).
    group_axis_endpoints: Dict[str, Dict[str, set]] = {}
    for gid, g in (w11_brief.get("group_set_briefs") or {}).items():
        axis_index: Dict[str, set] = {}
        for ax in (g.get("camera_axes") or []):
            key = ax.get("axis_key") or ""
            if not key:
                continue
            endpoints = {ax.get("from_zone") or "", ax.get("to_zone") or ""}
            endpoints.discard("")
            axis_index[key] = endpoints
        group_axis_endpoints[gid] = axis_index
    axis_violations: List[str] = []
    for bg_id, instr in per_bg.items():
        gid = instr.get("group_id") or ""
        axis = instr.get("camera_axis_used") or ""
        source = instr.get("camera_axis_source") or ""
        role = instr.get("floor_plan_ref_role") or ""
        if not axis:
            continue
        if role == "not_used":
            allowed_axes = group_axes_by_group.get(gid) or set()
            if axis not in allowed_axes:
                axis_violations.append(
                    f"{bg_id}:role=not_used:group={gid}:axis={axis}"
                )
        elif source == "w11_axis":
            allowed_axes = group_axes_by_group.get(gid) or set()
            if axis not in allowed_axes:
                axis_violations.append(
                    f"{bg_id}:source=w11_axis:group={gid}:axis={axis}"
                )
            else:
                # Both visible zones AND the zone_id_pointer of every
                # use_numbered_elements entry must lie on the endpoints of
                # the chosen axis. Shrinking visible_zone_scope to satisfy
                # the rule while keeping cross-zone use entries is not
                # allowed: the renderer would still draw those zones.
                endpoints = (group_axis_endpoints.get(gid) or {}).get(axis) or set()
                zone_scope = set(instr.get("visible_zone_scope") or [])
                fp_id = instr.get("fp_id") or ""
                use_zone_map = fp_number_to_zone.get(fp_id) or {}
                used_zones: set = set()
                for n in (instr.get("use_numbered_elements") or []):
                    if not isinstance(n, int):
                        continue
                    z = use_zone_map.get(n) or ""
                    if z:
                        used_zones.add(z)
                effective_zones = zone_scope | used_zones
                if effective_zones and not effective_zones.issubset(endpoints):
                    leaked = sorted(effective_zones - endpoints)
                    axis_violations.append(
                        f"{bg_id}:source=w11_axis:axis={axis}:"
                        f"effective_zones_outside_endpoints={leaked[:5]}"
                    )
        elif source == "candidate_camera_recommendation":
            fp_id = instr.get("fp_id") or ""
            cam_bgs = fp_bg_camera_recs.get(fp_id) or set()
            if bg_id not in cam_bgs:
                axis_violations.append(
                    f"{bg_id}:source=candidate:fp={fp_id}:"
                    f"missing_from_camera_recs"
                )
        else:
            axis_violations.append(
                f"{bg_id}:source={source!r}:unknown_or_missing"
            )
    inv["per_bg_camera_axis_resolves"] = {
        "pass": stage_status == "placeholder_dry_run" or len(axis_violations) == 0,
        "detail": {"violating": axis_violations[:5],
                   "stage_status": stage_status},
    }

    # render_prompt_appendix nonempty per bg (>=10 chars)
    short_appendix = [
        bg for bg, instr in per_bg.items()
        if len((instr.get("render_prompt_appendix") or "").strip()) < 10
    ]
    inv["render_prompt_appendix_nonempty_per_bg"] = {
        "pass": len(short_appendix) == 0,
        "detail": {"short_for_bg": short_appendix[:5]},
    }

    # banned human-decision keys
    BANNED = {"needs_user", "manual_review", "decision", "awaiting_human"}

    def _has_banned(obj: Any) -> bool:
        if isinstance(obj, dict):
            if any(k in BANNED for k in obj.keys()):
                return True
            return any(_has_banned(v) for v in obj.values())
        if isinstance(obj, list):
            return any(_has_banned(x) for x in obj)
        return False

    inv["no_human_decision_field"] = {
        "pass": not _has_banned(w12),
        "detail": "no banned keys at this stage",
    }
    inv["production_diff_zero"] = {
        "pass": bool(production_diff_empty),
        "detail": "git diff backend/app backend/alembic empty",
    }
    inv["db_write_zero"] = {
        "pass": db_write_count == 0,
        "detail": f"writes={db_write_count}",
    }
    inv["no_image_api_call"] = {
        "pass": not image_import_seen,
        "detail": "no openai images / gemini_image_client / fal import",
    }
    inv["image_generation_count_zero"] = {
        "pass": True,
        "detail": "W12 stage emits zero PNGs by design",
    }

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


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

def _render_w12_html(run_meta: dict, w12: dict, report: dict,
                     w11_brief: dict, fp_context: 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><td class=\"{'pass' if v['pass'] else 'fail'}\">{'PASS' if v['pass'] else 'FAIL'}</td>"
        f"<td>{esc(json.dumps(v.get('detail'), ensure_ascii=False))[:300]}</td></tr>"
        for k, v in inv.items()
    )

    # per-bg first (HTML priority 1). Decorate use/ignore numbers with
    # candidate labels (looked up from the bg's candidate fp) so reviewers can
    # spot use/ignore-vs-final_prompt contradictions without opening the JSON.
    per_bg = w12.get("per_bg_render_reference_instructions") or {}
    candidates_for_html = w12.get("candidate_floor_plans") or {}
    fp_number_to_label: Dict[str, Dict[int, str]] = {}
    for fp_id, fp in candidates_for_html.items():
        mapping: Dict[int, str] = {}
        for e in (fp.get("candidate_numbered_elements") or []):
            num = e.get("number")
            if isinstance(num, int):
                mapping[num] = e.get("label") or ""
        fp_number_to_label[fp_id] = mapping

    def _decorate(nums: list, fp_id: str) -> str:
        mapping = fp_number_to_label.get(fp_id or "") or {}
        parts = []
        for n in nums or []:
            label = mapping.get(int(n)) if isinstance(n, int) else ""
            if label:
                parts.append(f"#{n} {esc(label)}")
            else:
                parts.append(f"#{n}")
        return ", ".join(parts)

    per_bg_rows = ""
    for bg_id, instr in per_bg.items():
        fp_id = instr.get("fp_id") or ""
        use = _decorate(instr.get("use_numbered_elements") or [], fp_id)
        ign = _decorate(instr.get("ignore_numbered_elements") or [], fp_id)
        axis_cell = esc(instr.get("camera_axis_used"))
        source = instr.get("camera_axis_source") or ""
        if source:
            axis_cell = f"{axis_cell}<br><small>({esc(source)})</small>"
        per_bg_rows += (
            f"<tr><td>{esc(bg_id)}</td>"
            f"<td>{esc(instr.get('fp_id'))}</td>"
            f"<td>{use}</td>"
            f"<td>{ign}</td>"
            f"<td>{axis_cell}</td>"
            f"<td>{esc(instr.get('prior_bg_ref_role'))}</td>"
            f"<td><pre>{esc(instr.get('render_prompt_appendix') or '')[:600]}</pre></td>"
            f"<td><pre>{esc(instr.get('final_prompt_assembly_preview') or '')[:1500]}</pre></td></tr>"
        )

    # candidate fp section
    cand_rows = ""
    for fp_id, fp in (w12.get("candidate_floor_plans") or {}).items():
        nums = ", ".join(
            f"#{e.get('number')}({esc(e.get('zone_id_pointer') or '')})"
            for e in (fp.get("candidate_numbered_elements") or [])
        )
        cam_bgs = ", ".join(
            esc(cr.get("bg_id") or "")
            for cr in (fp.get("candidate_camera_recommendations") or [])
        )
        cand_rows += (
            f"<tr><td>{esc(fp_id)}</td>"
            f"<td>{esc(fp.get('group_id_pointer'))}</td>"
            f"<td>{nums}</td>"
            f"<td>{cam_bgs}</td>"
            f"<td><pre>{esc(fp.get('candidate_diagram_t2i_prompt') or '')[:900]}</pre></td>"
            f"<td>{esc(fp.get('reconciliation_notes_vs_production') or '')[:400]}</td></tr>"
        )

    # production baseline (collapsed details)
    prod_fps = fp_context.get("floor_plans") or {}
    prod_rows = ""
    for fp_id, fp in prod_fps.items():
        prod_rows += (
            f"<tr><td>{esc(fp_id)}</td>"
            f"<td>{len(fp.get('numbered_elements') or [])}</td>"
            f"<td>{', '.join(cr.get('bg_id','') for cr in (fp.get('camera_recommendations') or []) if cr.get('bg_id'))}</td>"
            f"<td>{'Y' if fp.get('png_exists') else 'N'}</td></tr>"
        )

    # group brief carry preview
    group_briefs = (w11_brief.get("group_set_briefs") or {})
    group_rows = ""
    for gid, g in group_briefs.items():
        axes = " | ".join(
            f"{esc(ax.get('axis_key'))}: {esc(ax.get('from_zone'))} → {esc(ax.get('to_zone'))}"
            for ax in (g.get("camera_axes") or [])
        )
        group_rows += (
            f"<tr><td>{esc(gid)}</td>"
            f"<td>{', '.join(esc(z.get('zone_id')) for z in (g.get('spatial_zones') or []))}</td>"
            f"<td>{axes}</td></tr>"
        )

    html = f"""<!doctype html><html><head><meta charset=\"utf-8\">
<title>W12 floor_plan_generation_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:60ch}}
section{{margin:1.5em 0}}</style></head>
<body>
<h1>W12 — floor_plan_generation_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(W11): {esc(run_meta.get('derived_from'))}
| stage_a_source_mode: <b>{esc(run_meta.get('stage_a_source_mode'))}</b>
| source_language_mode: <b>{esc(run_meta.get('source_language_mode'))}</b>
| model: <b>{esc(run_meta.get('model_used'))}</b>
| 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. Per-bg render reference instruction (Stage C)</h2>
<table><tr><th>bg_id</th><th>fp_id</th><th>use #</th><th>ignore #</th><th>camera_axis</th><th>prior_bg_ref_role</th><th>render_prompt_appendix</th><th>final_prompt_assembly_preview</th></tr>{per_bg_rows}</table></section>

<section><h2>2. Floor-plan candidate (Stage B)</h2>
<table><tr><th>fp_id</th><th>group_pointer</th><th>candidate_numbered_elements</th><th>candidate_camera_bg_ids</th><th>candidate_diagram_t2i_prompt</th><th>reconciliation vs production</th></tr>{cand_rows}</table></section>

<section><h2>3. Group briefs carried from W11 (Stage A)</h2>
<table><tr><th>group_id</th><th>spatial_zones</th><th>camera_axes</th></tr>{group_rows}</table></section>

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

<details><summary>Production baseline floor_plan_prompt JSON (collapsed)</summary>
<table><tr><th>fp_id</th><th>numbered_count</th><th>production_camera_bg_ids</th><th>png_exists</th></tr>{prod_rows}</table></details>
<details><summary>raw run_meta.json</summary><pre>{esc(json.dumps(run_meta, ensure_ascii=False, indent=2))}</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_floor_plan_candidate_from)
    if not prev_run_dir.is_absolute():
        prev_run_dir = Path.cwd() / prev_run_dir

    artifacts = _load_w11_artifacts(prev_run_dir)
    missing = list(artifacts.get("_missing", []))
    outputs: List[str] = []
    failed: List[str] = []
    run_status = "succeeded"
    exit_code = 0
    model_used = None
    stage_status = "placeholder_dry_run"
    source_language_mode = "not_available"
    stage_a_source_mode = "derived_from_w11_plus_w3_source"

    run_meta: Dict[str, Any] = {
        "run_id": run_id,
        "stage": W12_STAGE,
        "plan_version": PLAN_VERSION,
        "generated_at": datetime.now(KST).isoformat(),
        "model": args.model if args.generate else None,
        "model_used": model_used,
        "image_generation_count": 0,
        "image_generation_backend": W12_IMAGE_BACKEND,
        "args": vars(args),
        "derived_from": prev_run_dir.name,
        "stage_a_source_mode": stage_a_source_mode,
        "source_language_mode": source_language_mode,
        "outputs": outputs,
        "run_status": run_status,
        "exit_code": exit_code,
        "failed_invariants": failed,
    }

    if missing:
        failed.append("w11_inputs_missing")
        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_dir / "run_meta.json").write_text(
            json.dumps(run_meta, ensure_ascii=False, indent=2)
        )
        return exit_code

    w11_brief = artifacts["brief"]

    # Carry W11 group_set_briefs verbatim as Stage A.
    (run_dir / "director_set_layout_brief.json").write_text(
        json.dumps(
            {"group_set_briefs": w11_brief.get("group_set_briefs") or {},
             "derived_from": prev_run_dir.name,
             "stage_a_source_mode": stage_a_source_mode},
            ensure_ascii=False, indent=2,
        )
    )
    outputs.append("director_set_layout_brief.json")

    # Walk derived_from chain from W11 → W7 → ... → W3 for adapter + source.
    try:
        adapter_plan = _load_w3_adapter_from_chain(prev_run_dir)
        resolved = _resolve_source_bundle_via_derived_from_chain(prev_run_dir)
        source_bundle = resolved["source_bundle"]
    except FileNotFoundError as exc:
        failed.append("w3_adapter_or_source_missing")
        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["error"] = str(exc)[:300]
        (run_dir / "run_meta.json").write_text(
            json.dumps(run_meta, ensure_ascii=False, indent=2)
        )
        return exit_code

    project_id = source_bundle.get("project_id") or ""
    episode_id = source_bundle.get("episode_id") or ""
    fp_context = _load_production_floor_plan_context(project_id, episode_id)

    # Carry a source_language hint when explicitly present in artifacts;
    # otherwise leave `not_available`. No deterministic inference.
    explicit_lang = (
        source_bundle.get("source_language")
        or source_bundle.get("episode_language")
        or ((w11_brief.get("source_episode") or {}).get("source_language"))
    )
    if explicit_lang:
        source_language_mode = f"carried:{explicit_lang}"
    run_meta["source_language_mode"] = source_language_mode

    llm_input = _build_w12_llm_input(
        w11_brief=w11_brief, adapter_plan=adapter_plan,
        source_bundle=source_bundle, fp_context=fp_context,
    )

    if args.generate:
        _load_backend_env()
        try:
            w12 = _generate_w12_via_llm(llm_input, model=args.model)
            model_used = args.model
            stage_status = "generated"
        except Exception as exc:
            w12 = _build_placeholder_w12(adapter_plan, fp_context, w11_brief)
            failed.append("llm_call_failed")
            run_meta["error"] = str(exc)[:400]
    else:
        w12 = _build_placeholder_w12(adapter_plan, fp_context, w11_brief)

    (run_dir / "floor_plan_prompt_candidate.json").write_text(
        json.dumps(
            {"candidate_floor_plans": w12.get("candidate_floor_plans") or {}},
            ensure_ascii=False, indent=2,
        )
    )
    outputs.append("floor_plan_prompt_candidate.json")
    (run_dir / "per_bg_render_reference_instruction.json").write_text(
        json.dumps(
            {"per_bg_render_reference_instructions":
                w12.get("per_bg_render_reference_instructions") or {}},
            ensure_ascii=False, indent=2,
        )
    )
    outputs.append("per_bg_render_reference_instruction.json")

    report = _build_w12_compatibility_report(
        w12=w12, adapter_plan=adapter_plan, w11_brief=w11_brief,
        fp_context=fp_context,
        production_diff_empty=_check_production_diff_empty(),
        db_write_count=0,
        image_import_seen=_check_image_imports_present(),
        prev_run_id=prev_run_dir.name,
        missing_inputs=missing,
        stage_status=stage_status,
    )
    (run_dir / "w12_compatibility_report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2)
    )
    outputs.append("w12_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["model_used"] = model_used
    run_meta["run_status"] = run_status
    run_meta["exit_code"] = exit_code
    run_meta["failed_invariants"] = failed
    run_meta["outputs"] = outputs
    run_meta["stage_status"] = stage_status
    _render_w12_html(run_meta, w12, report, w11_brief, fp_context, 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__":
    sys.exit(main())
