"""experiment_floor_plan_base_layout_prompt_slice — W18A.

Codex APPROVED direction (post-W17 wave): the floor-plan image used for
downstream background generation must depict stable architectural layout
+ persistent layout anchors only. Transient narrative state markers
(event-driven floor/wall cues, displaced or knocked-over items) must NOT
compete for geometric space inside the base floor-plan image; they move
to a per-background state overlay payload that the background prompt
stage consumes separately.

W18A is the LLM-only base FP prompt + transient/region overlay synthesis
slice. The W15e candidate stays the truth surface: this stage does NOT
rewrite candidate numbered elements; it only partitions them into
`included_marker_legend` (base FP image layer) vs
`excluded_transient_elements` (per-bg overlay layer), and emits the
per-bg overlay payload by joining bg_unit_bindings.

No image API call. No VLM call. No DB / ImageAsset write. No production
manifest mutation. No commit / push. No grid/cell coordinates here —
VLM readback comes later (W18C, reusing W17C).

CLI:
  --derive-base-layout-from <W15e_run_dir>      (required)
  --target-fp-ids fp_l05_01                     (default, wave-1 lock)
  --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,
)

W18A_STAGE = "w18a_floor_plan_base_layout_prompt_slice"
W18A_DEFAULT_MODEL = "gpt-5.5"

# base_layer_decision enum (Codex W18A spec). Strict whitelist.
W18A_BASE_INCLUDED_DECISIONS = frozenset({
    "base_structural_unit",
    "base_opening",
    "base_persistent_fixture",
    "base_persistent_furniture",
})
W18A_OVERLAY_EXCLUDED_DECISIONS = frozenset({
    "state_overlay_plot_cue",
    "state_overlay_transient_object",
})
W18A_UNKNOWN_EXCLUDED_DECISIONS = frozenset({
    "exclude_from_base_unknown",
})
W18A_ALL_DECISIONS = (
    W18A_BASE_INCLUDED_DECISIONS
    | W18A_OVERLAY_EXCLUDED_DECISIONS
    | W18A_UNKNOWN_EXCLUDED_DECISIONS
)
W18A_EXCLUDED_DECISIONS = (
    W18A_OVERLAY_EXCLUDED_DECISIONS | W18A_UNKNOWN_EXCLUDED_DECISIONS
)

_DEFAULT_OUTPUT_ROOT = (
    _REPO_ROOT / "scripts_output" / "floor_plan_base_layout_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=(
            "W18A base layout prompt + transient overlay synthesis. "
            "LLM-only. No image API call. No production mutation."
        )
    )
    p.add_argument(
        "--derive-base-layout-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=W18A_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)


# ─────────────────────────────────────────────────────────────────────────────
# Loader helpers — W15e per-bg + topology brief join
# ─────────────────────────────────────────────────────────────────────────────

def _load_w15e_per_bg(prev_run_dir: Path) -> Dict[str, Dict[str, Any]]:
    p = prev_run_dir / "per_bg_render_reference_instruction.json"
    if not p.exists():
        return {}
    try:
        data = json.loads(p.read_text())
    except Exception:  # noqa: BLE001
        return {}
    return data.get("per_bg_render_reference_instructions") or {}


def _filter_bg_unit_bindings_for_fp(
    *, bg_unit_bindings: Dict[str, Dict[str, Any]],
    per_bg: Dict[str, Dict[str, Any]],
    target_fp_id: str,
) -> Dict[str, Dict[str, Any]]:
    """Return only the bgs whose per_bg.fp_id == target_fp_id. ID-based
    join, no semantic judgement."""
    out: Dict[str, Dict[str, Any]] = {}
    for bg_id, instr in (per_bg or {}).items():
        if not isinstance(instr, dict):
            continue
        if (instr.get("fp_id") or "") != target_fp_id:
            continue
        binding = (bg_unit_bindings or {}).get(bg_id)
        if isinstance(binding, dict):
            out[bg_id] = binding
    return out


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

W18A_DIAGNOSTIC_W17_FAILURE_SUMMARY = (
    "Previous experimental attempts placed both base architectural layout "
    "elements (spatial units, openings, persistent fixtures/furniture) AND "
    "transient narrative state markers (event-driven floor or wall surface "
    "cues, displaced or knocked-over objects, plot-condition evidence) on a "
    "single floor-plan diagram. The image model compressed several transient "
    "markers into one room to satisfy the must-show contract, distorting "
    "room proportions and weakening structural fidelity. The fix is to keep "
    "the base FP image limited to stable layout + persistent anchors and to "
    "expose transient state markers only via a per-background state overlay "
    "payload that the background prompt stage consumes separately."
)

W18A_SYSTEM_PROMPT = """\
You are a floor-plan layout/state partitioner.

You are given:
- `topology_brief.spatial_units[]`, `topology_brief.relationships[]`,
  `topology_brief.room_count_assessment` — the source topology for one
  target floor-plan id.
- `placement_and_scale_audit.unit_scale_constraints[]` — per-unit
  enclosure_mode + relative_scale_hint + open_connection_behavior.
- `placement_and_scale_audit.element_placement_constraints[]` — per
  numbered element placement_mode + avoid_open_connection_boundary +
  must_not_form_boundary + revised_position_hint + conflict_note.
- `candidate_numbered_elements[]` — the canonical list of numbered
  markers for the target fp (W15e candidate truth surface).
- `bg_unit_bindings_for_fp` — per-bg primary/secondary/state_delta unit
  ids that downstream backgrounds need.
- `per_bg_render_reference_instructions_for_fp` — for each bg, the
  W15e per-bg payload subset: `use_numbered_elements`,
  `ignore_numbered_elements`, `render_prompt_appendix`,
  `final_prompt_assembly_preview`. This is the authoritative source
  for which numbered markers each bg should ACTUALLY depict at runtime
  (e.g. a "clean restoration" bg deliberately ignores event-driven
  state markers even when their unit is bound to the bg).
- `diagnostic_w17_failure_summary` — generic background explaining why
  transient markers must move out of the base FP image.

Your job for the single `target_fp_id`:

1. Decide, for every numbered marker in `candidate_numbered_elements`,
   a `base_layer_decision` value from this strict enum:
     - `base_structural_unit`        (area / room / open zone — drawn
                                      as a filled block on the base FP)
     - `base_opening`                (door / window / threshold — drawn
                                      as an opening marker)
     - `base_persistent_fixture`     (wall-mounted, built-in, or
                                      installation-level — TV mount,
                                      sink-counter run, fixed cabinetry)
     - `base_persistent_furniture`   (large persistent furniture that
                                      defines a room's identity and
                                      anchors camera framing — bed,
                                      dining table, sofa, full counter)
     - `state_overlay_plot_cue`      (narrative surface cue tied to the
                                      scene event — an architectural
                                      floor or wall surface bears a
                                      temporary mark, impression, or
                                      residue caused by the event;
                                      ephemeral and not part of the
                                      persistent surface treatment)
     - `state_overlay_transient_object` (object whose presence,
                                      position, or condition is caused
                                      by the scene event — would
                                      normally be furniture-class, but
                                      the source describes it as moved,
                                      displaced, or altered by the
                                      event rather than at its
                                      persistent resting place)
     - `exclude_from_base_unknown`   (cannot confidently classify; do
                                      not place on base FP)
   The `category` field in the candidate (area / opening / furniture /
   prop / plot_device) is a HINT, not the answer. Use the source
   evidence implied by `position_hint`, `placement_mode`, label, and
   the `diagnostic_w17_failure_summary` to detect transient state even
   when the source category says `furniture` (i.e. an object whose
   pose, position, or surface condition is described as being caused
   by the scene event is transient, not persistent).

2. Emit a base FP t2i prompt that depicts ONLY the included markers.
   - Hard rule: the base prompt MUST NOT reference excluded marker
     numbers using `#N` syntax. Excluded markers do not appear in the
     base image.
   - The base prompt MUST reference each included marker number using
     `#N` syntax and include a numbered-marker prohibition contract
     clause (the prohibition keywords `renumber`, `omit`, `invent` must
     appear verbatim).
   - Style: square 1024x1024 flat orthographic schematic floor plan,
     white background, thick black walls, simple lines, no
     perspective, no photorealism, no lighting effects. Spatial units
     as pale filled blocks; openings as thick wall-break shapes with
     swing arcs / blue exterior edge bars for windows; persistent
     furniture / fixtures as outlined symbols. Generic phrasing only.
   - DO NOT describe transient state, narrative event, plot condition,
     event-driven surface marking, or moved-or-altered object pose
     inside the base prompt. The prompt describes only stable
     architectural layout + persistent anchors.

3. Emit `excluded_transient_elements[]` with one entry per excluded
   marker. Each entry MUST include an `overlay_instruction_hint` (one
   short sentence) describing how the background prompt should depict
   this transient state at runtime. Generic phrasing only.

4. Emit `bg_state_overlay_payload_by_bg[]` — one entry per bg in
   `bg_unit_bindings_for_fp`. For each bg:
   - `target_unit_ids[]`: bound unit ids the bg should reference (from
     primary_unit_ids + secondary_visible_unit_ids).
   - `base_markers_to_reference[]`: included marker numbers that this
     bg's camera frame should be able to see / use as anchor.
   - `transient_markers_to_describe[]`: HARD RULE — this list is the
     INTERSECTION of (the global excluded set) AND (this bg's
     `use_numbered_elements` from
     `per_bg_render_reference_instructions_for_fp`). In other words:
       * an excluded marker may appear here ONLY when it is also in
         this bg's `use_numbered_elements`.
       * any excluded marker in this bg's `ignore_numbered_elements`
         is FORBIDDEN here. Do not include it under any circumstance.
       * a "clean", restored, or no-state bg legitimately yields an
         EMPTY transient list — do NOT pad it with the bg's bound
         units. The W15e `render_prompt_appendix` /
         `final_prompt_assembly_preview` will say things like "clean
         room", "no evidence left", "before the event"; respect that.
     The background prompt will describe each listed transient marker
     in natural language while the base FP image stays clean.
   - `prompt_appendix_hint`: short generic guidance for the background
     prompt stage. No scenario-specific proper nouns.

5. Emit `base_fp_contract_notes` — compact reminders about open-zone
   handling, scale hint compliance, and fixed-fixture anchoring rules
   for the next image generation step (W18B). Generic phrasing only.

6. Emit `production_prompt_delta_recommendations` — review-only notes
   about what the production `floor_plan_prompt` / `background_prompt`
   would need to change to natively support base/overlay separation.
   This section is for human review; this stage does NOT modify
   production code or prompts.

Policy summary (HARD constraints):
- Base FP image depicts stable layout and persistent layout anchors
  only.
- Transient / state / event markers must not compete for geometric
  space in base FP image.
- Excluded state markers are not lost; they move to
  `bg_state_overlay_payload_by_bg` for background prompt generation.
- No grid / cell / 10x10 / coordinate vocabulary anywhere in this
  output. VLM readback comes later (separate wave).

Output: a single JSON object with this top-level shape:

{
  "base_layout_prompt_by_fp": {
    "<target_fp_id>": {
      "base_fp_t2i_prompt_text": string,
      "included_marker_legend": [
        {
          "marker_number": int,
          "source_candidate_number": int,
          "base_layer_decision": "<one of the BASE_INCLUDED decisions>",
          "label": string,
          "unit_id": string,
          "visual_encoding": string,
          "must_be_legible": true
        }, ...
      ],
      "excluded_transient_elements": [
        {
          "marker_number": int,
          "source_candidate_number": int,
          "base_layer_decision": "<one of the EXCLUDED decisions>",
          "label": string,
          "unit_id": string,
          "excluded_reason": string,
          "overlay_instruction_hint": string
        }, ...
      ],
      "bg_state_overlay_payload_by_bg": {
        "<bg_id>": {
          "bg_id": string,
          "fp_id": string,
          "target_unit_ids": [string, ...],
          "base_markers_to_reference": [int, ...],
          "transient_markers_to_describe": [int, ...],
          "prompt_appendix_hint": string
        }, ...
      },
      "base_fp_contract_notes": string,
      "production_prompt_delta_recommendations": string
    }
  }
}

Marker partition rules (deterministic invariants enforce these — the
prompt is NOT the only safeguard):
- Every candidate numbered element MUST appear EXACTLY ONCE either in
  `included_marker_legend` (decision in BASE_INCLUDED set) or in
  `excluded_transient_elements` (decision in EXCLUDED set). No marker
  may be omitted; no marker may appear in both lists.
- Every bg in `bg_unit_bindings_for_fp` MUST appear in
  `bg_state_overlay_payload_by_bg`.
- Every excluded marker that appears in ANY bg's
  `use_numbered_elements` MUST be covered by at least one overlay
  entry's `transient_markers_to_describe`. Excluded markers that
  never appear in any bg's use list are allowed to remain uncovered
  (they may simply never be rendered downstream — that is correct
  behaviour, not a missing assignment).
- For every overlay entry, every value in
  `transient_markers_to_describe` MUST satisfy both:
    (a) it is in the global excluded set;
    (b) it is in this bg's `use_numbered_elements`;
  and MUST NOT be in this bg's `ignore_numbered_elements`.

Generic phrasing only — do not embed scenario-specific proper nouns or
narrative wording in any string field beyond what the source candidate
labels already provide.

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


_PER_BG_INSTRUCTION_KEEP_FIELDS = (
    "bg_id", "fp_id",
    "use_numbered_elements", "ignore_numbered_elements",
    "render_prompt_appendix", "final_prompt_assembly_preview",
)


def _per_bg_instructions_for_fp(
    *, per_bg: Dict[str, Dict[str, Any]], target_fp_id: str,
) -> Dict[str, Dict[str, Any]]:
    """W18A2: minimal per-bg row carry for the target fp. ID join only."""
    out: Dict[str, Dict[str, Any]] = {}
    for bg_id, instr in (per_bg or {}).items():
        if not isinstance(instr, dict):
            continue
        if (instr.get("fp_id") or "") != target_fp_id:
            continue
        row = {k: instr.get(k) for k in _PER_BG_INSTRUCTION_KEEP_FIELDS}
        # Ensure use/ignore lists are int-only (drop any malformed entry).
        row["use_numbered_elements"] = [
            int(n) for n in (row.get("use_numbered_elements") or [])
            if isinstance(n, int)
        ]
        row["ignore_numbered_elements"] = [
            int(n) for n in (row.get("ignore_numbered_elements") or [])
            if isinstance(n, int)
        ]
        out[bg_id] = row
    return out


def _build_w18a_llm_input(
    *, topology_brief: dict, candidate: dict,
    per_bg: Dict[str, Dict[str, Any]],
    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 {}
    audit = (topology_brief or {}).get(
        "element_placement_and_unit_scale_audit"
    ) or {}
    fp_units = {
        (u or {}).get("unit_id")
        for u in fp_topo.get("spatial_units") or []
        if isinstance(u, dict) and (u or {}).get("unit_id")
    }
    unit_scale_for_fp = [
        c for c in audit.get("unit_scale_constraints") or []
        if isinstance(c, dict) and (c.get("unit_id") in fp_units)
    ]
    element_constraints_for_fp = [
        c for c in audit.get("element_placement_constraints") or []
        if isinstance(c, dict) and (c.get("fp_id") == target_fp_id)
    ]
    bg_unit_bindings = (topology_brief or {}).get("bg_unit_bindings") or {}
    bg_bindings_for_fp = _filter_bg_unit_bindings_for_fp(
        bg_unit_bindings=bg_unit_bindings, per_bg=per_bg,
        target_fp_id=target_fp_id,
    )
    per_bg_for_fp = _per_bg_instructions_for_fp(
        per_bg=per_bg, target_fp_id=target_fp_id,
    )
    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 {},
        },
        "placement_and_scale_audit": {
            "unit_scale_constraints": unit_scale_for_fp,
            "element_placement_constraints": element_constraints_for_fp,
        },
        "candidate_numbered_elements": fp_cand.get(
            "candidate_numbered_elements"
        ) or [],
        "bg_unit_bindings_for_fp": bg_bindings_for_fp,
        "per_bg_render_reference_instructions_for_fp": per_bg_for_fp,
        "diagnostic_w17_failure_summary": W18A_DIAGNOSTIC_W17_FAILURE_SUMMARY,
    }


def _generate_w18a_via_llm(
    llm_input: dict, *, model: str = W18A_DEFAULT_MODEL,
    retry_once: bool = False,
) -> dict:
    if not os.environ.get("OPENAI_API_KEY"):
        raise RuntimeError("missing OPENAI_API_KEY env var")
    if (model or "").strip() != W18A_DEFAULT_MODEL:
        raise RuntimeError(
            f"w18a routing refuses non-exact model: got '{model}', "
            f"required exact id '{W18A_DEFAULT_MODEL}' (no fallback allowed)"
        )

    import litellm  # lazy

    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": W18A_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"w18a_llm_failed: {last_exc!s}"[:400])


# ─────────────────────────────────────────────────────────────────────────────
# Compatibility report — 5 deterministic invariants
# ─────────────────────────────────────────────────────────────────────────────

def _collect_included(fp_entry: Dict[str, Any]) -> List[Dict[str, Any]]:
    return [
        e for e in fp_entry.get("included_marker_legend") or []
        if isinstance(e, dict) and isinstance(e.get("marker_number"), int)
    ]


def _collect_excluded(fp_entry: Dict[str, Any]) -> List[Dict[str, Any]]:
    return [
        e for e in fp_entry.get("excluded_transient_elements") or []
        if isinstance(e, dict) and isinstance(e.get("marker_number"), int)
    ]


def _candidate_numbers_for_fp(
    candidate: Dict[str, Any], fp_id: str,
) -> Set[int]:
    fp_cand = (
        (candidate or {}).get("candidate_floor_plans") or {}
    ).get(fp_id) or {}
    return {
        int(e["number"]) for e in fp_cand.get(
            "candidate_numbered_elements"
        ) or []
        if isinstance(e, dict) and isinstance(e.get("number"), int)
    }


def _build_w18a_compatibility_report(
    *, llm_output: Dict[str, Any],
    candidate: Dict[str, Any],
    target_fp_ids: Set[str],
    bg_bindings_for_fp_by_target: Dict[str, Dict[str, Dict[str, Any]]],
    per_bg_for_fp_by_target: Optional[
        Dict[str, Dict[str, Dict[str, Any]]]
    ] = None,
    production_diff_empty: bool = True,
    db_write_count: int = 0,
    image_import_seen: bool = False,
    image_api_call_count: int = 0,
    vlm_api_call_count: int = 0,
    llm_api_call_count: int = 0,
    expected_llm_api_call_count: int = 0,
    model_used: Optional[str] = None,
    stage_status: str = "dry_run",
    missing_inputs: Optional[List[str]] = None,
    prev_run_id: str = "",
) -> dict:
    if missing_inputs is None:
        missing_inputs = []
    if per_bg_for_fp_by_target is None:
        per_bg_for_fp_by_target = {}
    inv: Dict[str, Dict[str, Any]] = {}
    by_fp = (llm_output or {}).get("base_layout_prompt_by_fp") or {}

    # 1. inputs_present
    inv["inputs_present"] = {
        "pass": (
            not missing_inputs
            and bool(candidate)
            and bool(llm_output)
            and bool(by_fp)
            and (
                target_fp_ids.issubset(W16_ALLOWED_TARGET_FP_IDS)
                if target_fp_ids else False
            )
            and set(by_fp.keys()) >= set(target_fp_ids)
        ),
        "detail": {
            "missing_inputs": list(missing_inputs),
            "by_fp_count": len(by_fp),
            "received_target_fp_ids": sorted(target_fp_ids),
            "allowed_target_fp_ids": sorted(W16_ALLOWED_TARGET_FP_IDS),
            "stage_status": stage_status,
            "prev_run_id": prev_run_id,
            "model_used": model_used,
        },
    }

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

    # 2. included_plus_excluded_partitions_all_candidate_numbers
    if not llm_active:
        inv["included_plus_excluded_partitions_all_candidate_numbers"] = {
            "pass": True, "detail": dict(dry_skip),
        }
    else:
        partition_failures: List[dict] = []
        for fp_id in sorted(target_fp_ids):
            fp_entry = by_fp.get(fp_id) or {}
            cand_numbers = _candidate_numbers_for_fp(candidate, fp_id)
            included = _collect_included(fp_entry)
            excluded = _collect_excluded(fp_entry)
            included_numbers: Set[int] = set()
            excluded_numbers: Set[int] = set()
            for e in included:
                n = int(e["marker_number"])
                if e.get("base_layer_decision") not in W18A_BASE_INCLUDED_DECISIONS:
                    partition_failures.append({
                        "fp_id": fp_id, "marker_number": n,
                        "reason": "included_decision_not_in_BASE_INCLUDED_enum",
                        "value": e.get("base_layer_decision"),
                    })
                if n in included_numbers:
                    partition_failures.append({
                        "fp_id": fp_id, "marker_number": n,
                        "reason": "duplicate_in_included",
                    })
                    continue
                included_numbers.add(n)
                if e.get("source_candidate_number") != n:
                    partition_failures.append({
                        "fp_id": fp_id, "marker_number": n,
                        "reason": "source_candidate_mismatch_included",
                        "source_candidate_number": e.get("source_candidate_number"),
                    })
            for e in excluded:
                n = int(e["marker_number"])
                if e.get("base_layer_decision") not in W18A_EXCLUDED_DECISIONS:
                    partition_failures.append({
                        "fp_id": fp_id, "marker_number": n,
                        "reason": "excluded_decision_not_in_EXCLUDED_enum",
                        "value": e.get("base_layer_decision"),
                    })
                if n in excluded_numbers:
                    partition_failures.append({
                        "fp_id": fp_id, "marker_number": n,
                        "reason": "duplicate_in_excluded",
                    })
                    continue
                excluded_numbers.add(n)
                if e.get("source_candidate_number") != n:
                    partition_failures.append({
                        "fp_id": fp_id, "marker_number": n,
                        "reason": "source_candidate_mismatch_excluded",
                        "source_candidate_number": e.get("source_candidate_number"),
                    })
            overlap = included_numbers & excluded_numbers
            if overlap:
                partition_failures.append({
                    "fp_id": fp_id,
                    "reason": "overlap_between_included_and_excluded",
                    "overlap": sorted(overlap),
                })
            covered = included_numbers | excluded_numbers
            missing = sorted(cand_numbers - covered)
            if missing:
                partition_failures.append({
                    "fp_id": fp_id, "reason": "candidate_numbers_missing",
                    "missing": missing,
                })
            extras = sorted(covered - cand_numbers)
            if extras:
                partition_failures.append({
                    "fp_id": fp_id, "reason": "extra_marker_numbers",
                    "extras": extras,
                })
        inv["included_plus_excluded_partitions_all_candidate_numbers"] = {
            "pass": not partition_failures,
            "detail": {
                "failures": partition_failures[:40],
                "failure_count": len(partition_failures),
                "allowed_included_decisions": sorted(W18A_BASE_INCLUDED_DECISIONS),
                "allowed_excluded_decisions": sorted(W18A_EXCLUDED_DECISIONS),
            },
        }

    # 3. base_prompt_excludes_overlay_marker_numbers
    # Exact `#N` substring check ONLY — no semantic/substring on labels.
    if not llm_active:
        inv["base_prompt_excludes_overlay_marker_numbers"] = {
            "pass": True, "detail": dict(dry_skip),
        }
    else:
        leak_failures: List[dict] = []
        for fp_id in sorted(target_fp_ids):
            fp_entry = by_fp.get(fp_id) or {}
            text = fp_entry.get("base_fp_t2i_prompt_text") or ""
            excluded = _collect_excluded(fp_entry)
            for e in excluded:
                n = int(e["marker_number"])
                token = f"#{n}"
                if token in text:
                    leak_failures.append({
                        "fp_id": fp_id, "marker_number": n,
                        "reason": "excluded_marker_referenced_in_base_prompt",
                        "token": token,
                    })
        inv["base_prompt_excludes_overlay_marker_numbers"] = {
            "pass": not leak_failures,
            "detail": {
                "failures": leak_failures[:30],
                "failure_count": len(leak_failures),
                "rule": (
                    "excluded marker #N must not appear as `#N` "
                    "substring in base_fp_t2i_prompt_text"
                ),
            },
        }

    # 4. bg_state_overlay_covers_target_bgs_and_excluded_markers
    # W18A2 strengthened: every transient_markers_to_describe entry must
    # (a) be in the global excluded set, (b) be in that bg's W15e
    # `use_numbered_elements`, and (c) NOT be in that bg's W15e
    # `ignore_numbered_elements`. The coverage criterion is relaxed:
    # an excluded marker only needs overlay coverage if it appears in
    # ANY bg's `use_numbered_elements` (i.e. some bg will actually
    # render it). Markers ignored by every bg may stay uncovered.
    if not llm_active:
        inv["bg_state_overlay_covers_target_bgs_and_excluded_markers"] = {
            "pass": True, "detail": dict(dry_skip),
        }
    else:
        overlay_failures: List[dict] = []
        for fp_id in sorted(target_fp_ids):
            fp_entry = by_fp.get(fp_id) or {}
            overlay = fp_entry.get("bg_state_overlay_payload_by_bg") or {}
            expected_bgs = set(
                (bg_bindings_for_fp_by_target.get(fp_id) or {}).keys()
            )
            actual_bgs = set(overlay.keys())
            missing_bgs = sorted(expected_bgs - actual_bgs)
            extra_bgs = sorted(actual_bgs - expected_bgs)
            if missing_bgs:
                overlay_failures.append({
                    "fp_id": fp_id, "reason": "missing_bgs",
                    "missing_bgs": missing_bgs,
                })
            if extra_bgs:
                overlay_failures.append({
                    "fp_id": fp_id, "reason": "extra_bgs",
                    "extra_bgs": extra_bgs,
                })
            excluded_numbers = {
                int(e["marker_number"])
                for e in _collect_excluded(fp_entry)
            }
            per_bg_for_fp = per_bg_for_fp_by_target.get(fp_id) or {}
            invalid_overlay_numbers: List[dict] = []
            covered_by_overlay: Set[int] = set()
            for bg_id, entry in overlay.items():
                if not isinstance(entry, dict):
                    overlay_failures.append({
                        "fp_id": fp_id, "bg_id": bg_id,
                        "reason": "overlay_entry_not_a_dict",
                    })
                    continue
                if (entry.get("fp_id") or "") != fp_id:
                    overlay_failures.append({
                        "fp_id": fp_id, "bg_id": bg_id,
                        "reason": "overlay_fp_id_mismatch",
                        "got": entry.get("fp_id"),
                    })
                w15e_row = per_bg_for_fp.get(bg_id) or {}
                w15e_use = set(w15e_row.get("use_numbered_elements") or [])
                w15e_ignore = set(
                    w15e_row.get("ignore_numbered_elements") or []
                )
                for n in entry.get("transient_markers_to_describe") or []:
                    if not isinstance(n, int):
                        invalid_overlay_numbers.append({
                            "bg_id": bg_id, "value": n,
                            "reason": "transient_marker_not_int",
                        })
                        continue
                    if n not in excluded_numbers:
                        invalid_overlay_numbers.append({
                            "bg_id": bg_id, "marker_number": n,
                            "reason": "transient_marker_not_in_excluded_set",
                        })
                        continue
                    if w15e_row and n not in w15e_use:
                        invalid_overlay_numbers.append({
                            "bg_id": bg_id, "marker_number": n,
                            "reason": "transient_marker_not_in_w15e_use_list",
                            "w15e_use_numbered_elements": sorted(w15e_use),
                        })
                        continue
                    if w15e_row and n in w15e_ignore:
                        invalid_overlay_numbers.append({
                            "bg_id": bg_id, "marker_number": n,
                            "reason": "transient_marker_in_w15e_ignore_list",
                        })
                        continue
                    covered_by_overlay.add(n)
            if invalid_overlay_numbers:
                overlay_failures.append({
                    "fp_id": fp_id, "reason": "invalid_overlay_marker_numbers",
                    "details": invalid_overlay_numbers[:30],
                })
            # Coverage rule: an excluded marker only needs coverage if
            # it appears in any bg's use_numbered_elements.
            excluded_appearing_in_any_use: Set[int] = set()
            for bg_id, row in per_bg_for_fp.items():
                for n in row.get("use_numbered_elements") or []:
                    if isinstance(n, int) and n in excluded_numbers:
                        excluded_appearing_in_any_use.add(n)
            uncovered = sorted(
                excluded_appearing_in_any_use - covered_by_overlay
            )
            if uncovered:
                overlay_failures.append({
                    "fp_id": fp_id,
                    "reason": (
                        "excluded_markers_with_w15e_use_not_covered_"
                        "by_any_bg_overlay"
                    ),
                    "uncovered": uncovered,
                })
        inv["bg_state_overlay_covers_target_bgs_and_excluded_markers"] = {
            "pass": not overlay_failures,
            "detail": {
                "failures": overlay_failures[:30],
                "failure_count": len(overlay_failures),
                "policy_summary": (
                    "transient_markers_to_describe[N] requires "
                    "N ∈ excluded AND N ∈ bg.w15e_use AND "
                    "N ∉ bg.w15e_ignore; excluded markers absent from "
                    "every bg.w15e_use may stay uncovered."
                ),
            },
        }

    # 5. production / db / image / vlm / llm count guard
    expected_llm = expected_llm_api_call_count
    inv["production_diff_zero_db_write_zero_llm_count_as_expected_image_zero_vlm_zero"] = {
        "pass": (
            production_diff_empty
            and db_write_count == 0
            and not image_import_seen
            and image_api_call_count == 0
            and vlm_api_call_count == 0
            and llm_api_call_count == expected_llm
        ),
        "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,
            "vlm_api_call_count": vlm_api_call_count,
            "llm_api_call_count": llm_api_call_count,
            "expected_llm_api_call_count": expected_llm,
        },
    }

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


# ─────────────────────────────────────────────────────────────────────────────
# Dry-run placeholder + 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] = {
            "base_fp_t2i_prompt_text": "placeholder_dry_run",
            "included_marker_legend": [],
            "excluded_transient_elements": [],
            "bg_state_overlay_payload_by_bg": {},
            "base_fp_contract_notes": "placeholder_dry_run",
            "production_prompt_delta_recommendations": "placeholder_dry_run",
        }
    return {"base_layout_prompt_by_fp": out, "_dry_run": True}


def _render_w18a_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))[:800]}</pre></td>"
        f"</tr>"
        for k, v in inv.items()
    )

    fp_sections: List[str] = []
    by_fp = (llm_output or {}).get("base_layout_prompt_by_fp") or {}
    for fp_id, entry in by_fp.items():
        if not isinstance(entry, dict):
            continue
        included = entry.get("included_marker_legend") or []
        excluded = entry.get("excluded_transient_elements") or []
        overlay = entry.get("bg_state_overlay_payload_by_bg") or {}
        included_rows = "".join(
            f"<tr><td>#{esc(it.get('marker_number'))}</td>"
            f"<td>{esc(it.get('label'))}</td>"
            f"<td>{esc(it.get('base_layer_decision'))}</td>"
            f"<td>{esc(it.get('unit_id'))}</td>"
            f"<td>{esc(it.get('visual_encoding'))}</td></tr>"
            for it in included
        )
        excluded_rows = "".join(
            f"<tr><td>#{esc(it.get('marker_number'))}</td>"
            f"<td>{esc(it.get('label'))}</td>"
            f"<td>{esc(it.get('base_layer_decision'))}</td>"
            f"<td>{esc(it.get('unit_id'))}</td>"
            f"<td><pre>{esc(it.get('excluded_reason') or '')[:240]}</pre></td>"
            f"<td><pre>{esc(it.get('overlay_instruction_hint') or '')[:240]}</pre></td></tr>"
            for it in excluded
        )
        overlay_rows = "".join(
            f"<tr><td>{esc(bg_id)}</td>"
            f"<td>{esc(', '.join(o.get('target_unit_ids') or []))}</td>"
            f"<td>{esc(', '.join(f'#{n}' for n in (o.get('base_markers_to_reference') or [])))}</td>"
            f"<td>{esc(', '.join(f'#{n}' for n in (o.get('transient_markers_to_describe') or [])))}</td>"
            f"<td><pre>{esc(o.get('prompt_appendix_hint') or '')[:240]}</pre></td></tr>"
            for bg_id, o in overlay.items() if isinstance(o, dict)
        )
        prompt_text = entry.get("base_fp_t2i_prompt_text") or ""
        contract_notes = entry.get("base_fp_contract_notes") or ""
        prod_delta = entry.get("production_prompt_delta_recommendations") or ""
        fp_sections.append(
            f"<section><h2>fp: {esc(fp_id)}</h2>"
            f"<h3>base_fp_t2i_prompt_text ({len(prompt_text)} chars)</h3>"
            f"<pre>{esc(prompt_text)}</pre>"
            f"<h3>included_marker_legend ({len(included)} entries)</h3>"
            f"<table><tr><th>#</th><th>label</th><th>base_layer_decision</th>"
            f"<th>unit_id</th><th>visual_encoding</th></tr>{included_rows}</table>"
            f"<h3>excluded_transient_elements ({len(excluded)} entries)</h3>"
            f"<table><tr><th>#</th><th>label</th><th>base_layer_decision</th>"
            f"<th>unit_id</th><th>excluded_reason</th><th>overlay_instruction_hint</th></tr>{excluded_rows}</table>"
            f"<h3>bg_state_overlay_payload_by_bg ({len(overlay)} bgs)</h3>"
            f"<table><tr><th>bg_id</th><th>target_unit_ids</th>"
            f"<th>base_markers_to_reference</th><th>transient_markers_to_describe</th>"
            f"<th>prompt_appendix_hint</th></tr>{overlay_rows}</table>"
            f"<h3>base_fp_contract_notes</h3><pre>{esc(contract_notes)}</pre>"
            f"<h3>production_prompt_delta_recommendations (review-only)</h3>"
            f"<pre>{esc(prod_delta)}</pre>"
            f"</section>"
        )

    body = f"""<!doctype html><html><head><meta charset="utf-8">
<title>W18A floor_plan_base_layout_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>W18A — floor_plan_base_layout_prompt_slice {esc(run_meta.get('run_id'))}</h1>
<p>stage: <b>{esc(run_meta.get('stage_status'))}</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'))}
| llm_api_call_count: <b>{esc(run_meta.get('llm_api_call_count'))}</b>
| image_api_call_count: <b>{esc(run_meta.get('image_api_call_count'))}</b>
| vlm_api_call_count: <b>{esc(run_meta.get('vlm_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_base_layout_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", []))
    per_bg = _load_w15e_per_bg(prev_run_dir)
    if not per_bg:
        # diagnostic only — bg coverage invariant will still surface this
        missing.append("per_bg_render_reference_instruction.json")

    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"
    llm_api_call_count = 0
    expected_llm = 1 if args.generate else 0

    run_meta: Dict[str, Any] = {
        "run_id": run_id,
        "stage": W18A_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": model_used,
        "expected_model": W18A_DEFAULT_MODEL,
        "llm_api_call_count": llm_api_call_count,
        "expected_llm_api_call_count": expected_llm,
        "image_api_call_count": 0,
        "vlm_api_call_count": 0,
        "outputs": [],
        "run_status": run_status,
        "exit_code": exit_code,
        "failed_invariants": failed_invariants,
        "stage_status": stage_status,
    }

    def _persist_and_exit(code: int) -> int:
        run_meta["exit_code"] = code
        if code != 0:
            run_meta["run_status"] = "validation_failed"
        run_meta["failed_invariants"] = failed_invariants
        (run_dir / "run_meta.json").write_text(
            json.dumps(run_meta, ensure_ascii=False, indent=2)
        )
        return code

    if missing and ("source_topology_brief.json" in missing
                    or "floor_plan_prompt_candidate.json" in missing
                    or "run_meta.json" in missing):
        failed_invariants.append("w15e_inputs_missing")
        run_meta["missing_inputs"] = missing
        return _persist_and_exit(1)
    if invalid_targets:
        failed_invariants.append("invalid_target_fp_ids")
        run_meta["invalid_targets"] = invalid_targets
        return _persist_and_exit(1)
    if not target_fp_ids.issubset(W16_ALLOWED_TARGET_FP_IDS):
        failed_invariants.append("target_fp_outside_allowed")
        return _persist_and_exit(1)

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

    bg_bindings_for_fp_by_target: Dict[str, Dict[str, Dict[str, Any]]] = {}
    per_bg_for_fp_by_target: Dict[str, Dict[str, Dict[str, Any]]] = {}
    for fp_id in sorted(target_fp_ids):
        bg_bindings_for_fp_by_target[fp_id] = _filter_bg_unit_bindings_for_fp(
            bg_unit_bindings=(topology_brief or {}).get("bg_unit_bindings") or {},
            per_bg=per_bg, target_fp_id=fp_id,
        )
        per_bg_for_fp_by_target[fp_id] = _per_bg_instructions_for_fp(
            per_bg=per_bg, target_fp_id=fp_id,
        )

    by_fp_output: Dict[str, Any] = {}
    if args.generate:
        try:
            for fp_id in sorted(target_fp_ids):
                llm_input = _build_w18a_llm_input(
                    topology_brief=topology_brief,
                    candidate=candidate,
                    per_bg=per_bg,
                    target_fp_id=fp_id,
                )
                parsed = _generate_w18a_via_llm(
                    llm_input, model=args.model, retry_once=False,
                )
                llm_api_call_count += 1
                fp_dict = (parsed or {}).get("base_layout_prompt_by_fp") or {}
                # Tolerate single-fp top-level shape.
                if (
                    fp_id not in fp_dict
                    and "base_fp_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:
        placeholder = _placeholder_dry_run_output(target_fp_ids=target_fp_ids)
        by_fp_output = placeholder.get("base_layout_prompt_by_fp") or {}
        model_used = None
        stage_status = "dry_run"

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

    # Also emit the LLM input we built (review surface).
    llm_inputs_for_review: Dict[str, Any] = {}
    for fp_id in sorted(target_fp_ids):
        llm_inputs_for_review[fp_id] = _build_w18a_llm_input(
            topology_brief=topology_brief,
            candidate=candidate, per_bg=per_bg,
            target_fp_id=fp_id,
        )
    (run_dir / "base_layout_llm_input_for_review.json").write_text(
        json.dumps(llm_inputs_for_review, ensure_ascii=False, indent=2)
    )
    run_meta["outputs"].append("base_layout_llm_input_for_review.json")

    report = _build_w18a_compatibility_report(
        llm_output=llm_output,
        candidate=candidate,
        target_fp_ids=target_fp_ids,
        bg_bindings_for_fp_by_target=bg_bindings_for_fp_by_target,
        per_bg_for_fp_by_target=per_bg_for_fp_by_target,
        production_diff_empty=_check_production_diff_empty(),
        db_write_count=0,
        image_import_seen=_check_image_imports_present(),
        image_api_call_count=0,
        vlm_api_call_count=0,
        llm_api_call_count=llm_api_call_count,
        expected_llm_api_call_count=expected_llm,
        model_used=model_used,
        stage_status=stage_status,
        missing_inputs=missing,
        prev_run_id=prev_run_dir.name,
    )
    (run_dir / "base_layout_compatibility_report.json").write_text(
        json.dumps(report, ensure_ascii=False, indent=2)
    )
    run_meta["outputs"].append("base_layout_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["llm_api_call_count"] = llm_api_call_count
    run_meta["run_status"] = run_status
    run_meta["exit_code"] = exit_code
    run_meta["failed_invariants"] = failed_invariants

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