"""W14 structural tests — generic synthetic fixtures only.

No scenario-specific tokens (rooms/props/states/colors/narrative literals) in
test methodology. The static methodology grep guard checks source/test files
for fixture-specific literals but is used STRUCTURALLY, never to judge meaning.
"""
from __future__ import annotations

import json
import re
import sys
from pathlib import Path

import pytest


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


def _synthetic_w12_candidates() -> dict:
    return {
        "FPa": {
            "fp_id": "FPa", "group_id_pointer": "Ga",
            "candidate_diagram_t2i_prompt": "flat schematic plan alpha.",
            "candidate_key_elements": ["one outline.", "two outline."],
            "candidate_numbered_elements": [
                {"number": 1, "label": "alpha", "category": "area",
                 "position_hint": "p1", "zone_id_pointer": "Z1"},
                {"number": 2, "label": "beta", "category": "opening",
                 "position_hint": "p2", "zone_id_pointer": "Z1"},
            ],
            "candidate_camera_recommendations": [],
            "reconciliation_notes_vs_production": "",
        },
        "FPb": {
            "fp_id": "FPb", "group_id_pointer": "Gb",
            "candidate_diagram_t2i_prompt": "flat schematic plan beta.",
            "candidate_key_elements": [],
            "candidate_numbered_elements": [
                {"number": 1, "label": "gamma", "category": "area",
                 "position_hint": "p3", "zone_id_pointer": "Z2"},
            ],
            "candidate_camera_recommendations": [],
            "reconciliation_notes_vs_production": "",
        },
    }


def _synthetic_w12_per_bg() -> dict:
    return {
        "BGa": {"bg_id": "BGa", "group_id": "Ga", "fp_id": "FPa",
                "applies_to_shots": [], "floor_plan_ref_role": "layout_only",
                "use_numbered_elements": [1], "ignore_numbered_elements": [],
                "camera_axis_used": "ax", "camera_axis_source": "w11_axis",
                "visible_zone_scope": ["Z1"], "prior_bg_ref_role": "none",
                "render_prompt_appendix": "", "final_prompt_assembly_preview": ""},
        "BGb": {"bg_id": "BGb", "group_id": "Gb", "fp_id": "FPb",
                "applies_to_shots": [], "floor_plan_ref_role": "layout_only",
                "use_numbered_elements": [1], "ignore_numbered_elements": [],
                "camera_axis_used": "ax", "camera_axis_source": "w11_axis",
                "visible_zone_scope": ["Z2"], "prior_bg_ref_role": "none",
                "render_prompt_appendix": "", "final_prompt_assembly_preview": ""},
        "BGc": {"bg_id": "BGc", "group_id": "Gc", "fp_id": "",
                "applies_to_shots": [], "floor_plan_ref_role": "not_used",
                "use_numbered_elements": [], "ignore_numbered_elements": [],
                "camera_axis_used": "ax_ext", "camera_axis_source": "w11_axis",
                "visible_zone_scope": ["zone_ext"], "prior_bg_ref_role": "none",
                "render_prompt_appendix": "", "final_prompt_assembly_preview": ""},
    }


def test_w14_candidate_fp_coverage_and_payload_shape(tmp_path):
    """Every W12 candidate fp produces a payload; expected_output_png_path is
    run-local; api_call_shape carries the literal gpt-image-2 generate shape."""
    from experiment_candidate_floor_plan_render_slice import (
        _build_candidate_payloads,
    )
    cands = _synthetic_w12_candidates()
    payloads = _build_candidate_payloads(candidates=cands, run_dir=tmp_path)
    assert set(payloads.keys()) == {"FPa", "FPb"}
    fp = payloads["FPa"]
    # verbatim carry
    assert fp["candidate_diagram_t2i_prompt"] == "flat schematic plan alpha."
    assert fp["candidate_numbered_elements"][0]["number"] == 1
    # expected_output_kind sentinel
    assert fp["expected_output_kind"] == "candidate_floor_plan_png"
    # path is run-dir-local, file is NOT created
    expected_path = str(tmp_path / "png" / "FPa.png")
    assert fp["expected_output_png_path"] == expected_path
    assert not Path(fp["expected_output_png_path"]).exists()
    # api_call_shape literal
    assert fp["expected_image_model"] == "gpt-image-2"
    assert fp["api_method_preview"] == "images.generate"
    shape = fp["api_call_shape"]
    assert shape["client_method"] == "images.generate"
    assert shape["model"] == "gpt-image-2"
    assert shape["size"] == "1024x1024"
    assert shape["quality"] == "high"
    assert shape["n"] == 1
    # No production-ish registration field allowed.
    assert "would_register_asset_type" not in fp


def test_w14_inputs_present_and_layout_only_bg_coverage(tmp_path):
    """W14 only carries `w14_inputs_present`; the structural bg→fp coverage
    check (every layout_only bg's fp_id must appear in payloads) is asserted
    here directly without an invariant. The same coverage rule is enforced
    as an invariant downstream in W14b."""
    from experiment_candidate_floor_plan_render_slice import (
        _build_candidate_payloads,
        _build_w14_compatibility_report,
    )
    cands = _synthetic_w12_candidates()
    per_bg = _synthetic_w12_per_bg()
    payloads = _build_candidate_payloads(candidates=cands, run_dir=tmp_path)
    rep = _build_w14_compatibility_report(
        candidates=cands, per_bg=per_bg, payloads=payloads,
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        prev_run_id="fakeW12", stage_status="generated",
    )
    inv = rep["invariants"]
    assert inv["w14_inputs_present"]["pass"] is True
    # bg_to_fp_references_resolvable lives downstream in W14b. W14 only
    # asserts that the payload coverage by candidates is structurally valid:
    # every layout_only bg in per_bg points to an fp_id that exists in the
    # built payloads dict.
    payload_fp_set = set(payloads.keys())
    for bg_id, instr in per_bg.items():
        if instr.get("floor_plan_ref_role") != "layout_only":
            continue
        assert instr.get("fp_id") in payload_fp_set

    # Empty candidates → w14_inputs_present fails.
    rep_empty = _build_w14_compatibility_report(
        candidates={}, per_bg=per_bg, payloads={},
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        prev_run_id="fakeW12", stage_status="generated",
    )
    assert rep_empty["invariants"]["w14_inputs_present"]["pass"] is False


def test_w14_production_and_image_api_guards(tmp_path):
    """The combined W14 production guard surfaces production_diff,
    db_write_count, and image_import_seen as one invariant. Expected image
    model parity now lives downstream in W14b."""
    from experiment_candidate_floor_plan_render_slice import (
        _build_candidate_payloads,
        _build_w14_compatibility_report,
    )
    cands = _synthetic_w12_candidates()
    per_bg = _synthetic_w12_per_bg()
    payloads = _build_candidate_payloads(candidates=cands, run_dir=tmp_path)
    # Payload still carries the expected_image_model literal so that the
    # downstream W14b invariant can pick it up.
    for p in payloads.values():
        assert p["expected_image_model"] == "gpt-image-2"

    rep_ok = _build_w14_compatibility_report(
        candidates=cands, per_bg=per_bg, payloads=payloads,
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        prev_run_id="fakeW12", stage_status="generated",
    )
    assert rep_ok["invariants"]["production_diff_zero_db_write_zero_image_api_call_zero"]["pass"] is True
    assert rep_ok["all_pass"] is True

    # image_import_seen → combined guard fails.
    rep_import = _build_w14_compatibility_report(
        candidates=cands, per_bg=per_bg, payloads=payloads,
        production_diff_empty=True, db_write_count=0,
        image_import_seen=True, missing_inputs=[],
        prev_run_id="fakeW12", stage_status="generated",
    )
    assert rep_import["invariants"]["production_diff_zero_db_write_zero_image_api_call_zero"]["pass"] is False

    # production_diff dirty OR db_write > 0 → combined guard fails.
    rep_prod = _build_w14_compatibility_report(
        candidates=cands, per_bg=per_bg, payloads=payloads,
        production_diff_empty=False, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        prev_run_id="fakeW12", stage_status="generated",
    )
    assert rep_prod["invariants"]["production_diff_zero_db_write_zero_image_api_call_zero"]["pass"] is False
    rep_db = _build_w14_compatibility_report(
        candidates=cands, per_bg=per_bg, payloads=payloads,
        production_diff_empty=True, db_write_count=1,
        image_import_seen=False, missing_inputs=[],
        prev_run_id="fakeW12", stage_status="generated",
    )
    assert rep_db["invariants"]["production_diff_zero_db_write_zero_image_api_call_zero"]["pass"] is False


def test_w14_html_first_screen_and_methodology_grep(tmp_path):
    """HTML first screen shows the candidate fp table BEFORE invariants, with
    raw JSON inside collapsed <details>. Also runs a structural methodology
    grep on source/test files: scenario-specific literal tokens (room/prop
    narrative words) must be absent from script + test methodology."""
    from experiment_candidate_floor_plan_render_slice import (
        _build_candidate_payloads,
        _build_w14_compatibility_report,
        _render_w14_html,
    )
    cands = _synthetic_w12_candidates()
    per_bg = _synthetic_w12_per_bg()
    payloads = _build_candidate_payloads(candidates=cands, run_dir=tmp_path)
    rep = _build_w14_compatibility_report(
        candidates=cands, per_bg=per_bg, payloads=payloads,
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        prev_run_id="fakeW12", stage_status="generated",
    )
    run_meta = {
        "run_id": "RID1", "stage": "w14_candidate_floor_plan_render_slice",
        "run_status": "succeeded", "exit_code": 0,
        "derived_from": "fakeW12",
        "image_generation_count": 0, "image_generation_backend": "gpt-image-2",
    }
    _render_w14_html(run_meta, payloads, rep, tmp_path)
    html = (tmp_path / "index.html").read_text()

    columns = [
        "fp_id", "group_id_pointer", "api_method_preview",
        "expected_image_model", "expected_output_png_path",
        "numbered_elements_count", "candidate_diagram_t2i_prompt",
    ]
    positions = [html.find(c) for c in columns]
    assert all(p > 0 for p in positions), positions
    assert positions == sorted(positions), positions

    table_pos = html.find("Candidate floor-plan render payload preview")
    inv_pos = html.find("Invariants")
    assert table_pos > 0 and inv_pos > table_pos

    raw_pos = html.find("raw run_meta")
    assert raw_pos > 0
    details_pos = html.rfind("<details>", 0, raw_pos)
    assert details_pos > 0 and details_pos < raw_pos

    # Structural methodology grep: the source script must NOT contain
    # narrative scenario tokens that would bias methodology. The check
    # targets the script file only — this test file itself necessarily
    # mentions the tokens to perform the check, so it is exempt.
    script_path = _SCRIPTS_DIR / "experiment_candidate_floor_plan_render_slice.py"
    # Tokens are assembled from per-character pieces so this assertion source
    # does not match its own regex.
    forbidden_tokens = [
        "b" + "edroom", "ki" + "tchen", "blood" + "stain", "cur" + "tain",
        "coo" + "ktop", "tele" + "vision", "cri" + "me", "vi" + "lla",
        "roo" + "ftop", "foot" + "print", "pol" + "ice", "de" + "ck",
        "wheel" + "house",
    ]
    forbidden_pattern = re.compile(
        r"(?i)\b(" + "|".join(forbidden_tokens) + r")\b"
    )
    text = script_path.read_text()
    m = forbidden_pattern.search(text)
    assert m is None, f"{script_path.name}: scenario-specific token leaked → {m.group(0) if m else ''}"


def test_w14_numbered_marker_contract_appendix_structural(tmp_path):
    """W14C: assembled prompt must (a) include every candidate marker number
    even when they are non-consecutive, (b) carry the 4 prohibition keywords
    (renumber/omit/invent/non-consecutive), (c) carry the diagram-text
    suppression clause, (d) carry the plot_device generic clause."""
    from experiment_candidate_floor_plan_render_slice import (
        _build_numbered_marker_contract_appendix,
        _build_candidate_payloads,
    )

    # Irregular synthetic numbers to mimic the W12c non-consecutive set.
    cands = {
        "FPx": {
            "fp_id": "FPx", "group_id_pointer": "Gx",
            "candidate_diagram_t2i_prompt": "schematic top-down floor plan.",
            "candidate_key_elements": [],
            "candidate_numbered_elements": [
                {"number": 1, "label": "alpha zone", "category": "area",
                 "position_hint": "central", "zone_id_pointer": "Z1"},
                {"number": 7, "label": "side opening", "category": "opening",
                 "position_hint": "south wall", "zone_id_pointer": "Z1"},
                {"number": 28, "label": "fallen object cue",
                 "category": "plot_device",
                 "position_hint": "near central area",
                 "zone_id_pointer": "Z1"},
            ],
            "candidate_camera_recommendations": [],
            "reconciliation_notes_vs_production": "",
        },
    }
    appendix = _build_numbered_marker_contract_appendix(
        cands["FPx"]["candidate_numbered_elements"]
    )

    # (a) every #N must appear.
    for n in (1, 7, 28):
        assert f"#{n}" in appendix, appendix

    # (b) 4 prohibition keywords present (literal, structural).
    for kw in ("renumber", "omit", "invent", "non-consecutive"):
        assert kw in appendix.lower(), f"missing '{kw}' in appendix"

    # The Codex-rejected `1..N` formulation must NOT appear (image model
    # could misread it as sequential renumbering).
    assert "1..N" not in appendix and "1..n" not in appendix

    # (c) diagram-text suppression clause.
    assert "category names or position hints" in appendix.lower()
    assert "drawing guidance" in appendix.lower()

    # (d) plot_device generic clause (no scenario-specific words like
    # "body" / "character" — Codex picked the safer phrasing).
    assert "plot_device" in appendix
    assert "literal entity instance or action" in appendix.lower()
    assert "literal character" not in appendix.lower()

    # Each candidate line follows `#N label — category — position_hint`.
    for entry in cands["FPx"]["candidate_numbered_elements"]:
        line_token = f"#{entry['number']} {entry['label']} — {entry['category']} — {entry['position_hint']}"
        assert line_token in appendix, line_token

    # Payload assembly: assembled_candidate_diagram_prompt must concat base
    # prompt + appendix, with the original prompt preserved as-is.
    payloads = _build_candidate_payloads(candidates=cands, run_dir=tmp_path)
    fp = payloads["FPx"]
    assert fp["candidate_diagram_t2i_prompt"] == "schematic top-down floor plan."
    assert fp["numbered_marker_contract_appendix"] == appendix
    assembled = fp["assembled_candidate_diagram_prompt"]
    # W14G prepends `must_show_marker_summary` to the top of every
    # assembled prompt that has numbered elements. The base prompt now
    # follows that summary; the marker contract appendix remains at the
    # bottom.
    base_pos = assembled.find("schematic top-down floor plan.")
    assert base_pos > 0
    assert assembled.endswith(appendix)
    # Marker numbers preserved in the assembled prompt too.
    for n in (1, 7, 28):
        assert f"#{n}" in assembled

    # Methodology grep: the script source must not hardcode scenario tokens
    # in the static contract template. Tokens are assembled per-char so this
    # assertion source itself does not match its own regex.
    script_path = _SCRIPTS_DIR / "experiment_candidate_floor_plan_render_slice.py"
    forbidden_tokens = [
        "b" + "edroom", "ki" + "tchen", "blood" + "stain", "cur" + "tain",
        "coo" + "ktop", "tele" + "vision", "cri" + "me", "vi" + "lla",
        "roo" + "ftop", "foot" + "print", "pol" + "ice", "de" + "ck",
        "wheel" + "house",
    ]
    pattern = re.compile(r"(?i)\b(" + "|".join(forbidden_tokens) + r")\b")
    m2 = pattern.search(script_path.read_text())
    assert m2 is None, m2.group(0) if m2 else ""


def test_w14_contract_header_carries_no_hardcoded_marker_examples():
    """W14C narrow revision: the static header line must not embed concrete
    marker number examples like `#7` or `#28`. Those numbers may be valid
    for some candidate fps but invalid for others (e.g. a candidate whose
    actual numbers are [1, 2, 3, 4, 5] should never see `#7 or #28` in the
    contract header — that would imply markers the image model should not
    draw). Per-entry marker lines DO carry runtime numbers; only the header
    is checked here."""
    from experiment_candidate_floor_plan_render_slice import (
        _build_numbered_marker_contract_appendix,
    )
    for nums in ([1, 2, 3, 4, 5], [1, 7, 28], [10, 20, 30]):
        elements = [
            {"number": n, "label": f"x{n}", "category": "area",
             "position_hint": f"p{n}", "zone_id_pointer": "Z"}
            for n in nums
        ]
        appendix = _build_numbered_marker_contract_appendix(elements)
        header = appendix.splitlines()[0]
        m = re.search(r"#\d+", header)
        assert m is None, (
            "static header line must not contain any literal #N example; "
            f"found '{m.group(0) if m else ''}' in: {header}"
        )
        # All four prohibition tokens are still present overall.
        for kw in ("renumber", "omit", "invent", "non-consecutive"):
            assert kw in appendix.lower(), f"missing prohibition keyword '{kw}'"
        # Per-entry marker lines still carry runtime numbers.
        for n in nums:
            assert f"#{n}" in appendix, f"runtime marker #{n} missing"


def test_w14_topology_rendering_contract_appendix_structural(tmp_path):
    """W14D: when a `source_topology_brief.json` is co-located in the W12
    run dir, `_build_topology_rendering_contract_appendix` must surface
    scale/open-zone/enclosed/furniture phrasing for that fp using ONLY
    the topology JSON fields (no regex/substring on labels). Missing
    topology → empty appendix (backward compatible with the original W12c
    shape). assembled order: base prompt → topology contract → marker
    contract."""
    from experiment_candidate_floor_plan_render_slice import (
        _build_topology_rendering_contract_appendix,
        _build_candidate_payloads,
    )

    topo = {
        "source_topology_by_fp": {
            "FP_T": {
                "spatial_units": [
                    {"unit_id": "U_living", "unit_label": "Open Zone Alpha",
                     "is_enclosed_room": False},
                    {"unit_id": "U_kitchen", "unit_label": "Open Zone Beta",
                     "is_enclosed_room": False},
                    {"unit_id": "U_room_a", "unit_label": "Private Room A",
                     "is_enclosed_room": True},
                    {"unit_id": "U_room_b", "unit_label": "Private Room B",
                     "is_enclosed_room": True},
                    {"unit_id": "U_service", "unit_label": "Service Cell",
                     "is_enclosed_room": True},
                ],
                "relationships": [
                    {"from_unit": "U_living", "to_unit": "U_kitchen",
                     "relation_kind": "open_connection"},
                    {"from_unit": "U_living", "to_unit": "U_room_a",
                     "relation_kind": "door_between"},
                    {"from_unit": "U_living", "to_unit": "U_room_b",
                     "relation_kind": "door_between"},
                    {"from_unit": "U_living", "to_unit": "U_service",
                     "relation_kind": "door_between"},
                ],
            },
        },
    }
    appendix = _build_topology_rendering_contract_appendix("FP_T", topo)

    # Required generic phrases (no scenario-specific lexicon).
    assert "Topology rendering contract:" in appendix
    assert "Scale preservation" in appendix
    assert "Full-wall enclosed units" in appendix
    assert "Open / non-enclosed units" in appendix
    assert "Open-connection pairs" in appendix
    assert "Door / wall-boundary pairs" in appendix
    assert "Furniture / fixture markers" in appendix
    assert "Wall-positioned furniture or fixtures" in appendix

    # Every enclosed unit appears under enclosed section; every open-
    # connection pair under that section.
    for uid in ("U_room_a", "U_room_b", "U_service"):
        assert uid in appendix
    for uid in ("U_living", "U_kitchen"):
        assert uid in appendix
    assert "open connection" in appendix.lower()
    assert "door between" in appendix.lower()

    # Missing topology → empty appendix (backward compatible).
    empty = _build_topology_rendering_contract_appendix("FP_T", None)
    assert empty == ""
    empty2 = _build_topology_rendering_contract_appendix("FP_T", {})
    assert empty2 == ""
    # fp not in topology → empty appendix for that fp.
    empty3 = _build_topology_rendering_contract_appendix("FP_OTHER", topo)
    assert empty3 == ""

    # Payload assembly: when topology brief is passed, the assembled
    # prompt order is base → topology contract → marker contract.
    cands = {
        "FP_T": {
            "fp_id": "FP_T", "group_id_pointer": "Gt",
            "candidate_diagram_t2i_prompt": "base prompt text",
            "candidate_key_elements": [],
            "candidate_numbered_elements": [
                {"number": 1, "label": "alpha", "category": "area",
                 "position_hint": "north", "zone_id_pointer": "Z1"},
            ],
            "candidate_camera_recommendations": [],
            "reconciliation_notes_vs_production": "",
        },
    }
    payloads_with_topo = _build_candidate_payloads(
        candidates=cands, run_dir=tmp_path, topology_brief=topo,
    )
    fp = payloads_with_topo["FP_T"]
    assert fp["candidate_diagram_t2i_prompt"] == "base prompt text"
    assert fp["topology_rendering_contract_appendix"]
    assert fp["numbered_marker_contract_appendix"]
    assembled = fp["assembled_candidate_diagram_prompt"]
    base_pos = assembled.find("base prompt text")
    topo_pos = assembled.find("Topology rendering contract:")
    marker_pos = assembled.find("Numbered marker contract:")
    assert 0 <= base_pos < topo_pos < marker_pos

    # Backward compatible: no topology → empty topology appendix, marker
    # still works, base prompt intact.
    payloads_no_topo = _build_candidate_payloads(
        candidates=cands, run_dir=tmp_path,
    )
    fp_n = payloads_no_topo["FP_T"]
    assert fp_n["topology_rendering_contract_appendix"] == ""
    assert fp_n["numbered_marker_contract_appendix"]
    assert "Topology rendering contract:" not in fp_n["assembled_candidate_diagram_prompt"]
    assert "Numbered marker contract:" in fp_n["assembled_candidate_diagram_prompt"]


def test_w14_topology_contract_invariant_strict_when_brief_provided(tmp_path):
    """W14D invariant: when a topology brief is provided, every fp whose
    id appears in `source_topology_by_fp` must surface a non-empty
    `topology_rendering_contract_appendix` carrying the expected phrases
    AND that appendix must be embedded inside `assembled_candidate_diagram_prompt`.
    Backward compatible: missing brief → invariant auto-PASS."""
    from experiment_candidate_floor_plan_render_slice import (
        _build_candidate_payloads,
        _build_w14_compatibility_report,
    )
    topo = {
        "source_topology_by_fp": {
            "FP_T": {
                "spatial_units": [
                    {"unit_id": "U_living", "unit_label": "Open A",
                     "is_enclosed_room": False},
                    {"unit_id": "U_room_a", "unit_label": "Private A",
                     "is_enclosed_room": True},
                ],
                "relationships": [
                    {"from_unit": "U_living", "to_unit": "U_room_a",
                     "relation_kind": "door_between"},
                ],
            },
        },
    }
    cands = {
        "FP_T": {
            "fp_id": "FP_T", "group_id_pointer": "Gt",
            "candidate_diagram_t2i_prompt": "base text",
            "candidate_key_elements": [],
            "candidate_numbered_elements": [
                {"number": 1, "label": "x", "category": "area",
                 "position_hint": "n", "zone_id_pointer": "Z1"},
            ],
            "candidate_camera_recommendations": [],
            "reconciliation_notes_vs_production": "",
        },
    }
    # Happy path: appendix present and embedded → invariant PASS.
    payloads = _build_candidate_payloads(
        candidates=cands, run_dir=tmp_path, topology_brief=topo,
    )
    rep = _build_w14_compatibility_report(
        candidates=cands, per_bg={}, payloads=payloads,
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        prev_run_id="fake", stage_status="generated",
        topology_brief=topo,
    )
    inv = rep["invariants"]["topology_contract_present_when_topology_brief_provided"]
    assert inv["pass"] is True

    # Backward compatible: no topology brief → auto-PASS.
    payloads_n = _build_candidate_payloads(
        candidates=cands, run_dir=tmp_path,
    )
    rep_n = _build_w14_compatibility_report(
        candidates=cands, per_bg={}, payloads=payloads_n,
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        prev_run_id="fake", stage_status="generated",
        topology_brief=None,
    )
    assert rep_n["invariants"]["topology_contract_present_when_topology_brief_provided"]["pass"] is True

    # Negative: appendix mutated out of assembled → invariant fails.
    bad = json.loads(json.dumps(payloads))
    bad["FP_T"]["assembled_candidate_diagram_prompt"] = "base text only"
    rep_bad = _build_w14_compatibility_report(
        candidates=cands, per_bg={}, payloads=bad,
        production_diff_empty=True, db_write_count=0,
        image_import_seen=False, missing_inputs=[],
        prev_run_id="fake", stage_status="generated",
        topology_brief=topo,
    )
    assert rep_bad["invariants"]["topology_contract_present_when_topology_brief_provided"]["pass"] is False


def test_w14f_fixture_compression_and_do_not_divider_summary(tmp_path):
    """W14F: when the topology brief carries a W15e
    `element_placement_and_unit_scale_audit` with `must_not_form_boundary=true`
    items, the assembled prompt must (a) prepend a high-salience
    `do_not_divider_elements_summary` BEFORE the base prompt, (b) embed
    the generic fixture-compression rule (small compact symbols / no
    long horizontal panels) inside the topology contract, and (c)
    payload exposes the new `do_not_divider_elements_summary` field.
    Backward compatible: when the audit is missing, the summary is
    empty and assembled prompt starts with the base prompt."""
    from experiment_candidate_floor_plan_render_slice import (
        _build_topology_rendering_contract_appendix,
        _build_do_not_divider_elements_summary,
        _build_candidate_payloads,
    )

    topo = {
        "source_topology_by_fp": {
            "FP_T": {
                "spatial_units": [
                    {"unit_id": "U_living", "unit_label": "Open Zone A",
                     "is_enclosed_room": False},
                    {"unit_id": "U_kitchen", "unit_label": "Open Zone B",
                     "is_enclosed_room": False},
                ],
                "relationships": [
                    {"from_unit": "U_living", "to_unit": "U_kitchen",
                     "relation_kind": "open_connection"},
                ],
            },
        },
        "element_placement_and_unit_scale_audit": {
            "unit_scale_constraints": [
                {"unit_id": "U_living",
                 "enclosure_mode": "open_zone_inside_plan",
                 "relative_scale_hint": "primary_zone"},
                {"unit_id": "U_kitchen",
                 "enclosure_mode": "open_zone_inside_plan",
                 "relative_scale_hint": "compact_nook_or_wall_run"},
            ],
            "element_placement_constraints": [
                {"number": 1, "fp_id": "FP_T",
                 "unit_id_pointer": "U_living", "category": "area",
                 "placement_mode": "area_label_only",
                 "anchor_surface_hint": "center floor",
                 "avoid_open_connection_boundary": True,
                 "must_not_form_boundary": False,
                 "revised_position_hint": "center floor",
                 "conflict_note": ""},
                {"number": 17, "fp_id": "FP_T",
                 "unit_id_pointer": "U_living", "category": "furniture",
                 "placement_mode": "wall_fixture",
                 "anchor_surface_hint": "north wall",
                 "avoid_open_connection_boundary": True,
                 "must_not_form_boundary": True,
                 "revised_position_hint": "interior north wall, away from open boundary",
                 "conflict_note": "candidate hinted north wall which is the open boundary side"},
            ],
        },
    }

    # Helper: do-not-divider summary lists only must_not_form_boundary=true
    # items, in marker-number form, with category/unit/placement/anchor.
    summary = _build_do_not_divider_elements_summary("FP_T", topo)
    assert "Do-not-divider elements" in summary
    assert "#17" in summary
    assert "#1 " not in summary  # #1 does not have must_not_form_boundary
    assert "furniture" in summary
    assert "U_living" in summary

    # Topology contract carries the generic fixture compression + compact
    # open-zone rule (W14F additions).
    contract = _build_topology_rendering_contract_appendix("FP_T", topo)
    assert "Fixture compression rule" in contract
    assert "long horizontal panels or bars" in contract
    assert "Compact open-zone discipline" in contract

    # Payload assembly: assembled order is summary (TOP) -> base prompt
    # -> topology contract -> marker contract.
    cands = {
        "FP_T": {
            "fp_id": "FP_T", "group_id_pointer": "Gt",
            "candidate_diagram_t2i_prompt": "BASE_PROMPT_MARKER",
            "candidate_key_elements": [],
            "candidate_numbered_elements": [
                {"number": 17, "label": "small wall device",
                 "category": "furniture", "position_hint": "north wall",
                 "zone_id_pointer": "Z1"},
            ],
            "candidate_camera_recommendations": [],
            "reconciliation_notes_vs_production": "",
        },
    }
    payloads = _build_candidate_payloads(
        candidates=cands, run_dir=tmp_path, topology_brief=topo,
    )
    fp = payloads["FP_T"]
    assert fp["do_not_divider_elements_summary"]
    assembled = fp["assembled_candidate_diagram_prompt"]
    summary_pos = assembled.find("Do-not-divider elements")
    base_pos = assembled.find("BASE_PROMPT_MARKER")
    contract_pos = assembled.find("Topology rendering contract:")
    marker_pos = assembled.find("Numbered marker contract:")
    assert 0 <= summary_pos < base_pos < contract_pos < marker_pos

    # Backward compatible: no placement audit → summary empty, no
    # divider-block at the top of assembled.
    topo_no_audit = json.loads(json.dumps(topo))
    topo_no_audit.pop("element_placement_and_unit_scale_audit")
    summary_n = _build_do_not_divider_elements_summary("FP_T", topo_no_audit)
    assert summary_n == ""
    payloads_n = _build_candidate_payloads(
        candidates=cands, run_dir=tmp_path, topology_brief=topo_no_audit,
    )
    fp_n = payloads_n["FP_T"]
    assert fp_n["do_not_divider_elements_summary"] == ""
    assert "Do-not-divider elements" not in fp_n["assembled_candidate_diagram_prompt"]
    # The fixture compression rule still rides inside the topology contract
    # because that's a static generic rule that doesn't require the audit.
    assert "Fixture compression rule" in fp_n["assembled_candidate_diagram_prompt"]

    # No scenario-specific tokens leaked into the helper / summary text.
    forbidden_scenario_tokens = [
        "L0" + "5", "옥" + "탑방", "안" + "방", "수" + "리영", "민" + "숙",
        "te" + "levision", "T" + "V",
    ]
    pattern = re.compile("(" + "|".join(re.escape(t) for t in forbidden_scenario_tokens) + ")")
    m = pattern.search(summary)
    assert m is None, m.group(0) if m else ""
    m2 = pattern.search(contract)
    assert m2 is None, m2.group(0) if m2 else ""


def test_w14g_must_show_marker_summary_and_assembled_order(tmp_path):
    """W14G: every numbered element gets a one-line entry in
    `_build_must_show_marker_summary`, and the assembled prompt places
    that summary at the very TOP — even before the do-not-divider
    summary. Generic phrasing only; no scenario-specific lexicon."""
    from experiment_candidate_floor_plan_render_slice import (
        _build_must_show_marker_summary,
        _build_candidate_payloads,
    )

    elements = [
        {"number": 1, "label": "open zone A", "category": "area",
         "position_hint": "center", "zone_id_pointer": "Z1"},
        {"number": 17, "label": "small wall device", "category": "furniture",
         "position_hint": "north wall", "zone_id_pointer": "Z1"},
        {"number": 20, "label": "wall surface marker A",
         "category": "plot_device",
         "position_hint": "north wall above bed A",
         "zone_id_pointer": "Z3"},
        {"number": 23, "label": "wall surface marker B",
         "category": "plot_device",
         "position_hint": "north wall surface", "zone_id_pointer": "Z3"},
    ]
    summary = _build_must_show_marker_summary(elements)
    # Required generic phrases.
    assert "Must-show markers" in summary
    assert "HIGHEST PRIORITY" in summary
    assert "marker circle" in summary.lower()
    assert "marker number itself must never be omitted" in summary
    # Every marker number listed.
    for n in (1, 17, 20, 23):
        assert f"#{n}" in summary
    # plot_device category present in the lines.
    assert "plot_device" in summary

    # Empty input → empty summary (backward compatible).
    assert _build_must_show_marker_summary([]) == ""
    assert _build_must_show_marker_summary([{"label": "no number"}]) == ""

    # Payload assembly order: must-show (TOP) → do-not-divider →
    # base prompt → topology contract → marker contract.
    topo = {
        "source_topology_by_fp": {
            "FP_T": {
                "spatial_units": [
                    {"unit_id": "U_living", "unit_label": "Open Zone A",
                     "is_enclosed_room": False},
                ],
                "relationships": [],
            },
        },
        "element_placement_and_unit_scale_audit": {
            "unit_scale_constraints": [
                {"unit_id": "U_living",
                 "enclosure_mode": "open_zone_inside_plan",
                 "relative_scale_hint": "primary_zone"},
            ],
            "element_placement_constraints": [
                {"number": 17, "fp_id": "FP_T",
                 "unit_id_pointer": "U_living", "category": "furniture",
                 "placement_mode": "wall_fixture",
                 "anchor_surface_hint": "north wall",
                 "avoid_open_connection_boundary": False,
                 "must_not_form_boundary": True,
                 "revised_position_hint": "north wall",
                 "conflict_note": ""},
            ],
        },
    }
    cands = {
        "FP_T": {
            "fp_id": "FP_T", "group_id_pointer": "Gt",
            "candidate_diagram_t2i_prompt": "BASE_PROMPT_MARKER",
            "candidate_key_elements": [],
            "candidate_numbered_elements": elements,
            "candidate_camera_recommendations": [],
            "reconciliation_notes_vs_production": "",
        },
    }
    payloads = _build_candidate_payloads(
        candidates=cands, run_dir=tmp_path, topology_brief=topo,
    )
    fp = payloads["FP_T"]
    assert fp["must_show_marker_summary"]
    assembled = fp["assembled_candidate_diagram_prompt"]
    must_pos = assembled.find("Must-show markers")
    divider_pos = assembled.find("Do-not-divider elements")
    base_pos = assembled.find("BASE_PROMPT_MARKER")
    contract_pos = assembled.find("Topology rendering contract:")
    marker_pos = assembled.find("Numbered marker contract:")
    # must-show is at pos 0; the rest follow in the specified order.
    assert must_pos == 0
    assert 0 <= must_pos < divider_pos < base_pos < contract_pos < marker_pos

    # Backward compatible: payload without topology audit still gets the
    # must-show summary (it's derived from candidate elements, not the
    # audit), but no divider summary.
    payloads_no_topo = _build_candidate_payloads(
        candidates=cands, run_dir=tmp_path, topology_brief=None,
    )
    fp_n = payloads_no_topo["FP_T"]
    assert fp_n["must_show_marker_summary"]
    assert fp_n["do_not_divider_elements_summary"] == ""
    a_n = fp_n["assembled_candidate_diagram_prompt"]
    assert a_n.startswith("Must-show markers")
    assert "BASE_PROMPT_MARKER" in a_n

    # Scenario-specific token grep on this helper output.
    forbidden = [
        "L0" + "5", "옥" + "탑방", "안" + "방", "수" + "리영",
        "te" + "levision", "T" + "V",
    ]
    pat = re.compile("(" + "|".join(re.escape(t) for t in forbidden) + ")")
    m = pat.search(summary)
    assert m is None, m.group(0) if m else ""
