"""experiment_floor_plan_image_prompt_slice — W17A.

The W16 coordinate-first attempts (GPT-5.5 and Gemini variants) failed to
produce a usable 10x10 layout: large models can satisfy schema-level
budgets but still build infeasible rooms. The user redirected the wave:
ask the LLM to synthesize a T2I prompt + numbered marker legend for a
VLM-readable schematic floor-plan image (no coordinates), so that a later
stage can generate the image (W17B) and a VLM can read back coordinate-
like structure (W17C).

This W17A stage:
- consumes a prior W15e success run (`source_topology_brief.json` +
  `floor_plan_prompt_candidate.json` + `run_meta.json`)
- asks GPT-5.5 (exact id, fail-closed, retry 0) for
  `floor_plan_image_prompt_by_fp[fp_id]` containing:
    * `t2i_prompt_text` — orthographic schematic style + numbered
      marker contract
    * `numbered_marker_legend[]` — generic enum classification per
      candidate numbered element (element_kind + visual_encoding +
      label + priority + source_refs + must_be_legible)
- enforces deterministic invariants over the LLM output (legend
  uniqueness/coverage, enum validation, prompt contract, production /
  image guard).

No image API call. No DB / ImageAsset write. No production manifest
mutation. No commit. No scenario-specific lexicon in this file — every
token in the static code, prompt, and methodology grep is generic;
runtime source labels (loaded from W15e) may carry scenario words and
that is fine.

CLI:
  --derive-image-prompt-from <W15e_run_dir>     (required)
  --target-fp-ids fp_l05_01                     (default, restricted)
  --generate                                     (default off — dry-run)
  --model gpt-5.5                                (default; fail-closed)
  --output-root <path>
  --diag-print-imports
"""
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, Set, Tuple

_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,
    _check_image_imports_present,
    _check_production_diff_empty,
    _load_backend_env,
    _maybe_print_imports,
)
from experiment_floor_plan_grid_layout_slice import (  # type: ignore
    W16_ALLOWED_TARGET_FP_IDS,
    _load_w15e_artifacts,
    _resolve_targets,
)

W17A_STAGE = "w17a_floor_plan_image_prompt_slice"
W17A_DEFAULT_MODEL = "gpt-5.5"

# Generic enum vocabularies — strict whitelist, NO scenario-specific
# tokens. The LLM must classify every candidate numbered element under
# one of these enum values.
W17A_ELEMENT_KIND_ENUM = frozenset({
    "spatial_unit",
    "opening",
    "surface_covering_or_treatment",
    "screen_or_device_fixture",
    "furniture_or_large_prop",
    "state_or_evidence_surface_cue",
    "circulation_or_threshold",
    "window_or_exterior_opening",
    "other_fixture",
})
W17A_VISUAL_ENCODING_ENUM = frozenset({
    "filled_area",
    "thick_wall_opening",
    "blue_edge_bar",
    "small_device_icon",
    "outlined_furniture_symbol",
    "hatched_surface_marker",
    "dashed_threshold",
    "circle_with_label",
    "wedge_arc",
})
W17A_PRIORITY_ENUM = frozenset({"must_show", "should_show", "optional"})

# Numbered marker contract prohibition keywords the LLM prompt MUST
# carry verbatim (literal — not subject to LLM judgement).
W17A_REQUIRED_CONTRACT_TOKENS = ("renumber", "omit", "invent")

_DEFAULT_OUTPUT_ROOT = (
    _REPO_ROOT / "scripts_output" / "floor_plan_image_prompt_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=(
            "W17A floor-plan image prompt + numbered marker legend slice. "
            "No image API call. No production mutation."
        )
    )
    p.add_argument(
        "--derive-image-prompt-from", required=True,
        help="Path to a prior W15e success run dir.",
    )
    p.add_argument(
        "--target-fp-ids", default="fp_l05_01",
        help="Comma-separated fp_id subset. Wave 1 only allows fp_l05_01.",
    )
    p.add_argument(
        "--generate", action="store_true",
        help="Actual LLM call (GPT-5.5). Default off — dry-run placeholder.",
    )
    p.add_argument("--model", default=W17A_DEFAULT_MODEL)
    p.add_argument("--output-root", default=str(_DEFAULT_OUTPUT_ROOT))
    p.add_argument("--diag-print-imports", action="store_true")
    return p.parse_args(argv)


# ─────────────────────────────────────────────────────────────────────────────
# LLM input + system prompt
# ─────────────────────────────────────────────────────────────────────────────

W17A_SYSTEM_PROMPT = """\
You are a schematic floor-plan diagram briefer.

Input: a JSON object containing
- `topology_brief.spatial_units[]` (each with `unit_id`, `unit_kind`,
  `is_enclosed_room`, optional `evidence_refs`).
- `topology_brief.relationships[]` (`from_unit`, `to_unit`,
  `relation_kind`).
- `candidate_floor_plan.candidate_numbered_elements[]` — the canonical
  list of numbered markers the diagram must show, each with `number`,
  `category`, `label`, `position_hint`, `unit_id_pointer`.
- `target_fp_id` — the single fp_id you must brief.

Output: a single JSON object with one top-level key
`floor_plan_image_prompt_by_fp` containing exactly one entry keyed by
`target_fp_id`. Shape:

{
  "t2i_prompt_text": string,
  "numbered_marker_legend": [
    {
      "marker_number": int,
      "source_candidate_number": int,
      "element_kind": <one of the generic enum below>,
      "visual_encoding": <one of the generic enum below>,
      "label": string,
      "priority": "must_show" | "should_show" | "optional",
      "source_refs": [string, ...],
      "must_be_legible": true
    }, ...
  ]
}

Generic enums (NO scenario-specific values; pick the closest match):
- `element_kind`:
    spatial_unit, opening, surface_covering_or_treatment,
    screen_or_device_fixture, furniture_or_large_prop,
    state_or_evidence_surface_cue, circulation_or_threshold,
    window_or_exterior_opening, other_fixture
- `visual_encoding`:
    filled_area, thick_wall_opening, blue_edge_bar,
    small_device_icon, outlined_furniture_symbol,
    hatched_surface_marker, dashed_threshold, circle_with_label,
    wedge_arc
- `priority`: must_show, should_show, optional

Hard contract rules (deterministic invariants enforce these — the
prompt is NOT the only safeguard):
- Every candidate numbered element MUST appear EXACTLY ONCE in the
  legend with `marker_number == source_candidate_number`. Do not skip
  any candidate number. Do not duplicate marker_number.
- `must_be_legible` MUST be true for every entry — markers are the
  primary readout surface for downstream VLM readback.
- Every `element_kind` value MUST be from the enum list above. Every
  `visual_encoding` value MUST be from the enum list above. No free-
  form classification values.

T2I prompt text rules:
- Describe a flat orthographic schematic floor plan on a white
  background with thick black walls. Simple lines. No perspective. No
  photorealism. No lighting effects.
- Large high-contrast circled marker numbers; every marker number must
  be visible and non-overlapping where possible.
- Spatial units rendered as filled blocks with pale color fills.
  Openings rendered with thick wall-break shapes. Fixtures, devices,
  surface cues use small, distinguishable generic shape/color/line
  styles consistent with the legend.
- Include a compact side legend strip IF helpful, but marker numbers
  on the plan itself are primary.
- Draw for VLM (vision-language model) readability, not for aesthetic
  floor-plan realism.
- Include an explicit numbered marker contract clause: enumerate every
  marker number with `#<number>` syntax and forbid renumbering,
  omitting, or inventing markers. The prohibition keywords
  `renumber`, `omit`, and `invent` MUST appear verbatim in the prompt.

Generic phrasing only — do not embed scenario-specific proper nouns or
narrative words in the prompt or the legend label fields. The runtime
source `label` may carry whatever wording the candidate uses; you may
copy that verbatim into `label`, but do NOT invent new
scenario-specific lexicon and do NOT add narrative-color wording into
`t2i_prompt_text`.

Reply ONLY with the JSON object. No prose, no markdown fences.
"""


def _generate_w17a_via_llm(llm_input: dict, *, model: str = W17A_DEFAULT_MODEL,
                           retry_once: bool = False) -> dict:
    """GPT-5.5 fail-closed: missing OPENAI_API_KEY → RuntimeError; model
    id ≠ exact `W17A_DEFAULT_MODEL` → RuntimeError BEFORE litellm call;
    no auto retry by default."""
    if not os.environ.get("OPENAI_API_KEY"):
        raise RuntimeError("missing OPENAI_API_KEY env var")
    if (model or "").strip() != W17A_DEFAULT_MODEL:
        raise RuntimeError(
            f"w17a routing refuses non-exact model: got '{model}', "
            f"required exact id '{W17A_DEFAULT_MODEL}' (no fallback allowed)"
        )

    import litellm  # lazy import

    user_prompt = json.dumps(llm_input, ensure_ascii=False)
    last_exc: Optional[Exception] = None
    attempts = 2 if retry_once else 1
    for _ in range(attempts):
        try:
            resp = litellm.completion(
                model=model,
                messages=[
                    {"role": "system", "content": W17A_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 or "").lstrip()
            parsed, _ = decoder.raw_decode(stripped)
            return parsed
        except Exception as exc:  # noqa: BLE001
            last_exc = exc
            continue
    raise RuntimeError(f"w17a_llm_failed: {last_exc!s}"[:400])


def _build_w17a_llm_input(*, topology_brief: dict, candidate: dict,
                          target_fp_id: str) -> dict:
    by_fp = (topology_brief or {}).get("source_topology_by_fp") or {}
    fp_topo = by_fp.get(target_fp_id) or {}
    candidates = (candidate or {}).get("candidate_floor_plans") or {}
    fp_cand = candidates.get(target_fp_id) or {}
    return {
        "target_fp_id": target_fp_id,
        "topology_brief": {
            "fp_id": target_fp_id,
            "spatial_units": fp_topo.get("spatial_units") or [],
            "relationships": fp_topo.get("relationships") or [],
            "room_count_assessment": fp_topo.get("room_count_assessment") or {},
        },
        "candidate_floor_plan": {
            "fp_id": target_fp_id,
            "candidate_numbered_elements": fp_cand.get(
                "candidate_numbered_elements"
            ) or [],
            "candidate_key_elements": fp_cand.get("candidate_key_elements") or [],
        },
    }


# ─────────────────────────────────────────────────────────────────────────────
# Compatibility report — deterministic invariants over the LLM output
# ─────────────────────────────────────────────────────────────────────────────

def _build_w17a_compatibility_report(
    *, llm_output: Dict[str, Any],
    candidate: Dict[str, Any],
    target_fp_ids: Set[str],
    production_diff_empty: bool,
    db_write_count: int,
    image_import_seen: bool,
    image_api_call_count: int,
    model_used: Optional[str],
    stage_status: str,
    missing_inputs: List[str],
    prev_run_id: str,
) -> dict:
    inv: Dict[str, Dict[str, Any]] = {}
    by_fp = (llm_output or {}).get("floor_plan_image_prompt_by_fp") or {}
    candidates = (candidate or {}).get("candidate_floor_plans") or {}

    # 1. inputs_present.
    inv["inputs_present"] = {
        "pass": (
            not missing_inputs
            and bool(candidate)
            and bool(llm_output)
            and bool(by_fp)
        ),
        "detail": {
            "missing_inputs": list(missing_inputs),
            "llm_output_fp_count": len(by_fp),
            "candidate_fp_count": len(candidates),
            "stage_status": stage_status,
            "prev_run_id": prev_run_id,
        },
    }

    # 2. target_fp_only_fp_l05_01 — wave-1 lock (W16 constant reused).
    received_sorted = sorted(target_fp_ids or set())
    inv["target_fp_only_fp_l05_01"] = {
        "pass": bool(target_fp_ids) and set(target_fp_ids).issubset(
            W16_ALLOWED_TARGET_FP_IDS
        ),
        "detail": {
            "received_target_fp_ids": received_sorted,
            "allowed": sorted(W16_ALLOWED_TARGET_FP_IDS),
        },
    }

    # 3. model_is_gpt_5_5_when_generated. Dry-run skip.
    if stage_status == "dry_run":
        model_pass = True
        model_detail = {"skip_reason": "dry_run"}
    else:
        model_pass = (model_used or "").strip() == W17A_DEFAULT_MODEL
        model_detail = {
            "model_used": model_used,
            "required": W17A_DEFAULT_MODEL,
            "stage_status": stage_status,
        }
    inv["model_is_gpt_5_5_when_generated"] = {
        "pass": model_pass, "detail": model_detail,
    }

    llm_active = stage_status == "generated"
    dry_skip = {"skip_reason": "dry_run"}

    # 4. marker_legend_unique_and_covers_candidate.
    if not llm_active:
        inv["marker_legend_unique_and_covers_candidate"] = {
            "pass": True, "detail": dict(dry_skip),
        }
    else:
        legend_failures: List[dict] = []
        for fp_id, entry in by_fp.items():
            if not isinstance(entry, dict):
                legend_failures.append({"fp_id": fp_id, "reason": "not_a_dict"})
                continue
            legend = entry.get("numbered_marker_legend") or []
            fp_cand = candidates.get(fp_id) or {}
            cand_numbers: Set[int] = {
                int(e["number"]) for e in fp_cand.get(
                    "candidate_numbered_elements"
                ) or [] if isinstance(e, dict) and isinstance(e.get("number"), int)
            }
            seen_markers: Set[int] = set()
            for item in legend:
                if not isinstance(item, dict):
                    legend_failures.append({
                        "fp_id": fp_id, "reason": "legend_item_not_dict",
                    })
                    continue
                n = item.get("marker_number")
                if not isinstance(n, int):
                    legend_failures.append({
                        "fp_id": fp_id, "reason": "marker_number_not_int",
                        "value": n,
                    })
                    continue
                if n in seen_markers:
                    legend_failures.append({
                        "fp_id": fp_id, "reason": "duplicate_marker_number",
                        "marker_number": n,
                    })
                    continue
                seen_markers.add(n)
                src = item.get("source_candidate_number")
                if src != n:
                    legend_failures.append({
                        "fp_id": fp_id, "reason": "source_candidate_mismatch",
                        "marker_number": n,
                        "source_candidate_number": src,
                    })
                if item.get("must_be_legible") is not True:
                    legend_failures.append({
                        "fp_id": fp_id, "reason": "must_be_legible_not_true",
                        "marker_number": n,
                    })
            missing = sorted(cand_numbers - seen_markers)
            if missing:
                legend_failures.append({
                    "fp_id": fp_id, "reason": "candidate_numbers_missing",
                    "missing": missing,
                })
            extras = sorted(seen_markers - cand_numbers)
            if extras:
                legend_failures.append({
                    "fp_id": fp_id, "reason": "extra_marker_numbers",
                    "extras": extras,
                })
        inv["marker_legend_unique_and_covers_candidate"] = {
            "pass": not legend_failures,
            "detail": {
                "failures": legend_failures[:30],
                "failure_count": len(legend_failures),
            },
        }

    # 5. legend_enum_validation.
    if not llm_active:
        inv["legend_enum_validation"] = {"pass": True, "detail": dict(dry_skip)}
    else:
        enum_failures: List[dict] = []
        for fp_id, entry in by_fp.items():
            if not isinstance(entry, dict):
                continue
            for item in entry.get("numbered_marker_legend") or []:
                if not isinstance(item, dict):
                    continue
                ek = item.get("element_kind")
                if ek not in W17A_ELEMENT_KIND_ENUM:
                    enum_failures.append({
                        "fp_id": fp_id, "marker_number": item.get("marker_number"),
                        "reason": "element_kind_not_in_enum",
                        "value": ek,
                    })
                ve = item.get("visual_encoding")
                if ve not in W17A_VISUAL_ENCODING_ENUM:
                    enum_failures.append({
                        "fp_id": fp_id, "marker_number": item.get("marker_number"),
                        "reason": "visual_encoding_not_in_enum",
                        "value": ve,
                    })
                pr = item.get("priority")
                if pr not in W17A_PRIORITY_ENUM:
                    enum_failures.append({
                        "fp_id": fp_id, "marker_number": item.get("marker_number"),
                        "reason": "priority_not_in_enum",
                        "value": pr,
                    })
        inv["legend_enum_validation"] = {
            "pass": not enum_failures,
            "detail": {
                "failures": enum_failures[:30],
                "failure_count": len(enum_failures),
                "allowed_element_kinds": sorted(W17A_ELEMENT_KIND_ENUM),
                "allowed_visual_encodings": sorted(W17A_VISUAL_ENCODING_ENUM),
            },
        }

    # 6. prompt_includes_marker_contract.
    if not llm_active:
        inv["prompt_includes_marker_contract"] = {
            "pass": True, "detail": dict(dry_skip),
        }
    else:
        prompt_failures: List[dict] = []
        for fp_id, entry in by_fp.items():
            if not isinstance(entry, dict):
                continue
            text = entry.get("t2i_prompt_text") or ""
            lower = text.lower()
            fp_cand = candidates.get(fp_id) or {}
            cand_numbers = [
                int(e["number"]) for e in fp_cand.get(
                    "candidate_numbered_elements"
                ) or [] if isinstance(e, dict) and isinstance(e.get("number"), int)
            ]
            missing_marker_refs = [
                n for n in cand_numbers if f"#{n}" not in text
            ]
            if missing_marker_refs:
                prompt_failures.append({
                    "fp_id": fp_id, "reason": "prompt_missing_marker_refs",
                    "missing": missing_marker_refs,
                })
            missing_tokens = [
                t for t in W17A_REQUIRED_CONTRACT_TOKENS if t not in lower
            ]
            if missing_tokens:
                prompt_failures.append({
                    "fp_id": fp_id, "reason": "prompt_missing_contract_tokens",
                    "missing": missing_tokens,
                })
        inv["prompt_includes_marker_contract"] = {
            "pass": not prompt_failures,
            "detail": {
                "failures": prompt_failures[:30],
                "failure_count": len(prompt_failures),
                "required_tokens": list(W17A_REQUIRED_CONTRACT_TOKENS),
            },
        }

    # 7. production guard (always live).
    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
            and image_api_call_count == 0
        ),
        "detail": {
            "production_diff_empty": production_diff_empty,
            "db_write_count": db_write_count,
            "image_import_seen": image_import_seen,
            "image_api_call_count": image_api_call_count,
        },
    }

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


# ─────────────────────────────────────────────────────────────────────────────
# Placeholder dry-run + HTML
# ─────────────────────────────────────────────────────────────────────────────

def _placeholder_dry_run_output(*, target_fp_ids: Set[str]) -> dict:
    out: Dict[str, Dict[str, Any]] = {}
    for fp_id in sorted(target_fp_ids):
        out[fp_id] = {
            "t2i_prompt_text": "placeholder_dry_run",
            "numbered_marker_legend": [],
        }
    return {"floor_plan_image_prompt_by_fp": out, "_dry_run": True}


def _render_w17a_html(*, run_meta: dict, llm_output: 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'}\">"
        f"{'PASS' if v['pass'] else 'FAIL'}</td>"
        f"<td><pre>{esc(json.dumps(v.get('detail'), ensure_ascii=False))[:600]}</pre></td>"
        f"</tr>"
        for k, v in inv.items()
    )

    fp_sections: List[str] = []
    by_fp = (llm_output or {}).get("floor_plan_image_prompt_by_fp") or {}
    for fp_id, entry in by_fp.items():
        if not isinstance(entry, dict):
            continue
        legend_rows = "".join(
            f"<tr><td>#{esc(it.get('marker_number'))}</td>"
            f"<td>{esc(it.get('label'))}</td>"
            f"<td>{esc(it.get('element_kind'))}</td>"
            f"<td>{esc(it.get('visual_encoding'))}</td>"
            f"<td>{esc(it.get('priority'))}</td></tr>"
            for it in entry.get("numbered_marker_legend") or []
        )
        prompt_text = entry.get("t2i_prompt_text") or ""
        fp_sections.append(
            f"<section><h2>fp: {esc(fp_id)}</h2>"
            f"<h3>t2i_prompt_text ({len(prompt_text)} chars)</h3>"
            f"<pre>{esc(prompt_text)}</pre>"
            f"<h3>numbered_marker_legend ({len(entry.get('numbered_marker_legend') or [])} entries)</h3>"
            f"<table><tr><th>#</th><th>label</th><th>element_kind</th>"
            f"<th>visual_encoding</th><th>priority</th></tr>{legend_rows}</table>"
            f"</section>"
        )

    body = f"""<!doctype html><html><head><meta charset="utf-8">
<title>W17A floor_plan_image_prompt_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:84ch}}
section{{margin:1.5em 0}}</style></head><body>
<h1>W17A — floor_plan_image_prompt_slice {esc(run_meta.get('run_id'))}</h1>
<p>stage: <b>{esc(run_meta.get('stage'))}</b>
| run_status: <b class="{'pass' if run_meta.get('run_status')=='succeeded' else 'fail'}">
{esc(run_meta.get('run_status'))}</b>
| exit_code: {esc(run_meta.get('exit_code'))}
| model: <b>{esc(run_meta.get('model_used'))}</b>
| derived_from(W15e): {esc(run_meta.get('derived_from'))}
| image_api_call_count: <b>{esc(run_meta.get('image_api_call_count'))}</b></p>

{''.join(fp_sections)}

<section><h2>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 llm_output.json</summary>
<pre>{esc(json.dumps(llm_output, ensure_ascii=False, indent=2))[:200000]}</pre></details>
</body></html>"""
    (run_dir / "index.html").write_text(body)


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

def main(argv=None) -> int:
    args = _parse_args(argv)
    if args.generate:
        _load_backend_env()
    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_image_prompt_from)
    if not prev_run_dir.is_absolute():
        prev_run_dir = Path.cwd() / prev_run_dir

    artifacts = _load_w15e_artifacts(prev_run_dir)
    missing = list(artifacts.get("_missing", []))

    target_fp_ids, invalid_targets = _resolve_targets(args.target_fp_ids)

    failed_invariants: List[str] = []
    run_status = "succeeded"
    exit_code = 0
    model_used: Optional[str] = None
    stage_status = "dry_run"

    run_meta: Dict[str, Any] = {
        "run_id": run_id,
        "stage": W17A_STAGE,
        "plan_version": PLAN_VERSION,
        "generated_at": datetime.now(KST).isoformat(),
        "args": vars(args),
        "derived_from": prev_run_dir.name,
        "target_fp_ids": sorted(target_fp_ids),
        "invalid_targets": invalid_targets,
        "model_used": None,
        "expected_model": W17A_DEFAULT_MODEL,
        "image_api_call_count": 0,
        "image_generation_count": 0,
        "outputs": [],
        "run_status": run_status,
        "exit_code": exit_code,
        "failed_invariants": failed_invariants,
        "stage_status": stage_status,
    }

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

    topology_brief = artifacts["topology"]
    candidate = artifacts["candidate"]

    by_fp_output: Dict[str, Any] = {}
    if args.generate:
        try:
            for fp_id in sorted(target_fp_ids):
                llm_input = _build_w17a_llm_input(
                    topology_brief=topology_brief,
                    candidate=candidate,
                    target_fp_id=fp_id,
                )
                parsed = _generate_w17a_via_llm(
                    llm_input, model=args.model, retry_once=False,
                )
                fp_dict = (parsed or {}).get("floor_plan_image_prompt_by_fp") or {}
                if fp_id not in fp_dict and "t2i_prompt_text" in (parsed or {}):
                    fp_dict = {fp_id: parsed}
                by_fp_output.update(
                    {k: v for k, v in fp_dict.items() if k == fp_id}
                )
            model_used = args.model
            stage_status = "generated"
        except Exception as exc:  # noqa: BLE001
            failed_invariants.append("llm_call_failed")
            run_meta["llm_error"] = str(exc)[:400]
            run_status = "validation_failed"
            exit_code = 1
            stage_status = "llm_failed"
    else:
        by_fp_output = _placeholder_dry_run_output(
            target_fp_ids=target_fp_ids
        ).get("floor_plan_image_prompt_by_fp") or {}
        model_used = None
        stage_status = "dry_run"

    llm_output = {"floor_plan_image_prompt_by_fp": by_fp_output}
    (run_dir / "floor_plan_image_prompt.json").write_text(
        json.dumps(llm_output, ensure_ascii=False, indent=2)
    )
    run_meta["outputs"].append("floor_plan_image_prompt.json")

    report = _build_w17a_compatibility_report(
        llm_output=llm_output,
        candidate=candidate,
        target_fp_ids=target_fp_ids,
        production_diff_empty=_check_production_diff_empty(),
        db_write_count=0,
        image_import_seen=_check_image_imports_present(),
        image_api_call_count=0,
        model_used=model_used,
        stage_status=stage_status,
        missing_inputs=missing,
        prev_run_id=prev_run_dir.name,
    )
    (run_dir / "floor_plan_image_prompt_compatibility_report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2)
    )
    run_meta["outputs"].append("floor_plan_image_prompt_compatibility_report.json")

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

    run_meta["model_used"] = model_used
    run_meta["stage_status"] = stage_status
    run_meta["run_status"] = run_status
    run_meta["exit_code"] = exit_code
    run_meta["failed_invariants"] = failed_invariants

    _render_w17a_html(
        run_meta=run_meta, llm_output=llm_output,
        report=report, run_dir=run_dir,
    )
    run_meta["outputs"].append("index.html")

    (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())
