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

No scenario-specific tokens (rooms/props/states/colors/narrative literals) in
test methodology. Production checkpoint loader is exercised only by the W11
test in the sibling script test file; W12 tests use synthetic fixtures.
"""
from __future__ import annotations

import json
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_adapter() -> dict:
    return {
        "background_catalog": {
            "L01B01": {"depends_on_fp": ["FP1"], "depends_on_bg": [],
                       "sub_location_label": "zone_a",
                       "state_label_raw": "base",
                       "loc_id": "L01", "space_key": "S01",
                       "applies_to_shots": ["S1_Shot1"]},
            "L01B02": {"depends_on_fp": ["FP1"], "depends_on_bg": ["L01B01"],
                       "sub_location_label": "zone_a",
                       "state_label_raw": "alt",
                       "loc_id": "L01", "space_key": "S01",
                       "applies_to_shots": ["S1_Shot2"]},
        }
    }


def _synthetic_w11_brief() -> dict:
    return {
        "group_set_briefs": {
            "G1": {
                "group_id": "G1",
                "fp_blocks": [{"fp_id": "FP1", "png_available": False,
                               "production_camera_bg_ids": []}],
                "spatial_zones": [
                    {"zone_id": "Z1", "label_source_derived": "zone_a",
                     "supporting_numbered_elements": [1]},
                ],
                "openings_and_transitions": [],
                "persistent_fixtures": [],
                "state_transition_zones": [],
                "camera_axes": [
                    {"axis_key": "ax1", "from_zone": "Z1", "to_zone": "Z1",
                     "reason": "within zone"},
                ],
                "default_avoidance": "",
                "conflicts_and_assumptions": [],
                "director_set_summary": "",
            },
        },
        "per_bg_set_selections": {
            "L01B01": {"bg_id": "L01B01", "group_id": "G1", "fp_id": "FP1",
                       "applies_to_shots": ["S1_Shot1"],
                       "relevant_zone_ids": ["Z1"],
                       "relevant_numbered_elements": [1],
                       "matched_production_camera": None,
                       "camera_axis_used": "ax1", "continuity_intent": "",
                       "state_delta": "", "reconciliation_notes": ""},
            "L01B02": {"bg_id": "L01B02", "group_id": "G1", "fp_id": "FP1",
                       "applies_to_shots": ["S1_Shot2"],
                       "relevant_zone_ids": ["Z1"],
                       "relevant_numbered_elements": [1, 2],
                       "matched_production_camera": None,
                       "camera_axis_used": "ax1", "continuity_intent": "",
                       "state_delta": "", "reconciliation_notes": ""},
        },
        "final_plate_prompt_candidates": {
            "L01B01": {"bg_id": "L01B01", "final_plate_prompt_text": "base text",
                       "source_grounded_anchors_used": [],
                       "set_brief_grounded": True},
            "L01B02": {"bg_id": "L01B02", "final_plate_prompt_text": "alt text",
                       "source_grounded_anchors_used": [],
                       "set_brief_grounded": True},
        },
    }


def _synthetic_fp_context() -> dict:
    return {
        "fp_prompt_path": "/fake", "fp_render_path": "/fake",
        "fp_prompt_status": "ok", "fp_render_status": "ok",
        "floor_plans": {
            "FP1": {"fp_id": "FP1", "group_id": "G1", "depends_on_fp": [],
                    "key_elements": [],
                    "numbered_elements": [
                        {"number": 1, "category": "area",
                         "label": "z", "position_hint": "c"},
                    ],
                    "camera_recommendations": [],
                    "t2i_prompt": "", "applied_shots": [],
                    "png_path": "", "png_exists": False},
        },
    }


def _w12_ok_payload() -> dict:
    return {
        "candidate_floor_plans": {
            "FP1": {
                "fp_id": "FP1", "group_id_pointer": "G1",
                "candidate_diagram_t2i_prompt": "flat schematic plan ...",
                "candidate_key_elements": [],
                "candidate_numbered_elements": [
                    {"number": 1, "label": "z", "category": "area",
                     "position_hint": "c", "zone_id_pointer": "Z1"},
                    {"number": 2, "label": "o", "category": "opening",
                     "position_hint": "n", "zone_id_pointer": "Z1"},
                    {"number": 3, "label": "f", "category": "furniture",
                     "position_hint": "s", "zone_id_pointer": "Z1"},
                ],
                "candidate_camera_recommendations": [
                    {"bg_id": "L01B01", "sub_location": "zone_a",
                     "camera_position": "near #1", "camera_height": "1.6m",
                     "lens_hint": "35mm", "framing_notes": ""},
                    {"bg_id": "L01B02", "sub_location": "zone_a",
                     "camera_position": "near #2", "camera_height": "1.6m",
                     "lens_hint": "35mm", "framing_notes": ""},
                ],
                "reconciliation_notes_vs_production": "smaller set",
            },
        },
        "per_bg_render_reference_instructions": {
            "L01B01": {"bg_id": "L01B01", "group_id": "G1", "fp_id": "FP1",
                       "applies_to_shots": ["S1_Shot1"],
                       "floor_plan_ref_role": "layout_only",
                       "use_numbered_elements": [1, 2],
                       "ignore_numbered_elements": [3],
                       "camera_axis_used": "ax1",
                       "camera_axis_source": "w11_axis",
                       "visible_zone_scope": ["Z1"],
                       "prior_bg_ref_role": "none",
                       "render_prompt_appendix": "layout_only. use #1,#2. ignore #3. axis=ax1. prior=none.",
                       "final_prompt_assembly_preview": "base text\n\nlayout_only. use #1,#2. ignore #3."},
            "L01B02": {"bg_id": "L01B02", "group_id": "G1", "fp_id": "FP1",
                       "applies_to_shots": ["S1_Shot2"],
                       "floor_plan_ref_role": "layout_only",
                       "use_numbered_elements": [1, 3],
                       "ignore_numbered_elements": [2],
                       "camera_axis_used": "ax1",
                       "camera_axis_source": "w11_axis",
                       "visible_zone_scope": ["Z1"],
                       "prior_bg_ref_role": "same_room_state_transition",
                       "render_prompt_appendix": "layout_only. use #1,#3. ignore #2. axis=ax1. prior=same_room_state_transition.",
                       "final_prompt_assembly_preview": "alt text\n\nlayout_only. use #1,#3. ignore #2."},
        },
    }


def test_w12_candidate_camera_recs_cover_all_current_bgs():
    """Stage B coverage split: every adapter fp must have a candidate fp entry,
    and every adapter-mapped bg within that candidate fp must have a camera_rec."""
    from experiment_floor_plan_generation_slice import _build_w12_compatibility_report
    w12 = _w12_ok_payload()
    rep = _build_w12_compatibility_report(
        w12=w12, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep["invariants"]["candidate_floor_plans_cover_all_adapter_fps"]["pass"] is True
    assert rep["invariants"]["candidate_camera_recs_cover_all_bgs_within_candidate_fps"]["pass"] is True
    # Drop one camera_recommendation to force per-fp coverage failure.
    w12_bad = json.loads(json.dumps(w12))
    w12_bad["candidate_floor_plans"]["FP1"]["candidate_camera_recommendations"].pop()
    rep_bad = _build_w12_compatibility_report(
        w12=w12_bad, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep_bad["invariants"]["candidate_camera_recs_cover_all_bgs_within_candidate_fps"]["pass"] is False
    # Drop the whole candidate fp to force the fp-coverage invariant to fail.
    w12_no_fp = json.loads(json.dumps(w12))
    w12_no_fp["candidate_floor_plans"].pop("FP1")
    rep_no_fp = _build_w12_compatibility_report(
        w12=w12_no_fp, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep_no_fp["invariants"]["candidate_floor_plans_cover_all_adapter_fps"]["pass"] is False


def test_w12_use_numbered_elements_subset_invariant():
    """use_numbered_elements ⊆ candidate_numbered_elements.number for each bg."""
    from experiment_floor_plan_generation_slice import _build_w12_compatibility_report
    w12 = _w12_ok_payload()
    rep = _build_w12_compatibility_report(
        w12=w12, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep["invariants"]["per_bg_use_numbered_elements_subset_of_candidate"]["pass"] is True
    w12_bad = json.loads(json.dumps(w12))
    w12_bad["per_bg_render_reference_instructions"]["L01B01"]["use_numbered_elements"] = [1, 99]
    rep_bad = _build_w12_compatibility_report(
        w12=w12_bad, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep_bad["invariants"]["per_bg_use_numbered_elements_subset_of_candidate"]["pass"] is False


def test_w12_use_and_ignore_disjoint_invariant():
    """use ∩ ignore must be empty per bg."""
    from experiment_floor_plan_generation_slice import _build_w12_compatibility_report
    w12 = _w12_ok_payload()
    rep = _build_w12_compatibility_report(
        w12=w12, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep["invariants"]["per_bg_use_and_ignore_disjoint"]["pass"] is True
    w12_bad = json.loads(json.dumps(w12))
    w12_bad["per_bg_render_reference_instructions"]["L01B01"]["use_numbered_elements"] = [1, 2]
    w12_bad["per_bg_render_reference_instructions"]["L01B01"]["ignore_numbered_elements"] = [2, 3]
    rep_bad = _build_w12_compatibility_report(
        w12=w12_bad, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep_bad["invariants"]["per_bg_use_and_ignore_disjoint"]["pass"] is False


def test_w12_camera_axis_resolves_split_by_source():
    """camera_axis_used resolution branches on camera_axis_source. 'w11_axis'
    requires axis_key membership in the bg's group's W11 camera_axes;
    'candidate_camera_recommendation' requires the bg_id to appear in the
    candidate fp's camera_recommendations and accepts a free-form axis slug."""
    from experiment_floor_plan_generation_slice import _build_w12_compatibility_report

    # Baseline: both bgs use w11_axis with matching axis_key — pass.
    w12 = _w12_ok_payload()
    rep = _build_w12_compatibility_report(
        w12=w12, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep["invariants"]["per_bg_camera_axis_resolves"]["pass"] is True

    # Switch L01B02 to candidate_camera_recommendation with a free-form slug
    # — pass because L01B02 IS in candidate_camera_recommendations.
    w12_cand = json.loads(json.dumps(w12))
    w12_cand["per_bg_render_reference_instructions"]["L01B02"]["camera_axis_used"] = "free_form_local_axis"
    w12_cand["per_bg_render_reference_instructions"]["L01B02"]["camera_axis_source"] = "candidate_camera_recommendation"
    rep_cand = _build_w12_compatibility_report(
        w12=w12_cand, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep_cand["invariants"]["per_bg_camera_axis_resolves"]["pass"] is True

    # w11_axis with unknown axis_key — fail.
    w12_bad_w11 = json.loads(json.dumps(w12))
    w12_bad_w11["per_bg_render_reference_instructions"]["L01B01"]["camera_axis_used"] = "no_such_axis"
    rep_bad_w11 = _build_w12_compatibility_report(
        w12=w12_bad_w11, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep_bad_w11["invariants"]["per_bg_camera_axis_resolves"]["pass"] is False

    # candidate source but bg_id missing from candidate_camera_recommendations
    # — fail. Drop L01B02 from candidate_camera_recommendations, then flip its
    # axis source to candidate_camera_recommendation.
    w12_bad_cand = json.loads(json.dumps(w12))
    fp = w12_bad_cand["candidate_floor_plans"]["FP1"]
    fp["candidate_camera_recommendations"] = [
        cr for cr in fp["candidate_camera_recommendations"]
        if cr["bg_id"] != "L01B02"
    ]
    w12_bad_cand["per_bg_render_reference_instructions"]["L01B02"]["camera_axis_source"] = "candidate_camera_recommendation"
    w12_bad_cand["per_bg_render_reference_instructions"]["L01B02"]["camera_axis_used"] = "free_form_local_axis"
    rep_bad_cand = _build_w12_compatibility_report(
        w12=w12_bad_cand, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep_bad_cand["invariants"]["per_bg_camera_axis_resolves"]["pass"] is False

    # floor_plan_ref_role='not_used' branch: candidate fp is irrelevant; axis
    # must just be a valid axis_key in the bg's group's W11 camera_axes.
    w12_not_used = json.loads(json.dumps(w12))
    w12_not_used["per_bg_render_reference_instructions"]["L01B02"]["floor_plan_ref_role"] = "not_used"
    w12_not_used["per_bg_render_reference_instructions"]["L01B02"]["camera_axis_source"] = "candidate_camera_recommendation"
    w12_not_used["per_bg_render_reference_instructions"]["L01B02"]["fp_id"] = ""
    w12_not_used["per_bg_render_reference_instructions"]["L01B02"]["camera_axis_used"] = "ax1"
    rep_not_used = _build_w12_compatibility_report(
        w12=w12_not_used, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep_not_used["invariants"]["per_bg_camera_axis_resolves"]["pass"] is True

    # w11_axis branch: shrinking visible_zone_scope while keeping a
    # use_numbered_elements entry whose candidate zone_id_pointer is
    # OUTSIDE the chosen axis endpoints must FAIL (the renderer would draw
    # an out-of-axis zone). visible_zone_scope ∪ used_element_zone_ids ⊆
    # endpoints. Mutate a candidate element to belong to a foreign zone.
    w12_cross_zone = json.loads(json.dumps(w12))
    fp = w12_cross_zone["candidate_floor_plans"]["FP1"]
    for e in fp["candidate_numbered_elements"]:
        if e["number"] == 2:
            e["zone_id_pointer"] = "Zforeign"
            break
    # L01B01 uses [1,2]; #2 now points to Zforeign which is not in axis
    # endpoints {Z1}. visible_zone_scope is still ['Z1'] but use covers
    # Zforeign — must fail.
    rep_cross = _build_w12_compatibility_report(
        w12=w12_cross_zone, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep_cross["invariants"]["per_bg_camera_axis_resolves"]["pass"] is False
    # Same payload with source switched to candidate_camera_recommendation
    # for L01B01 — the zone subset rule no longer applies and L01B01 is in
    # candidate_camera_recommendations; the bg must pass.
    w12_cross_fallback = json.loads(json.dumps(w12_cross_zone))
    w12_cross_fallback["per_bg_render_reference_instructions"]["L01B01"]["camera_axis_source"] = "candidate_camera_recommendation"
    w12_cross_fallback["per_bg_render_reference_instructions"]["L01B01"]["camera_axis_used"] = "local_zone_oblique"
    rep_cross_fallback = _build_w12_compatibility_report(
        w12=w12_cross_fallback, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    assert rep_cross_fallback["invariants"]["per_bg_camera_axis_resolves"]["pass"] is True


def test_w12_html_section_order_and_first_screen_priority(tmp_path):
    """HTML first screen must show per-bg instruction (Stage C) before
    candidate floor plans (Stage B), before group briefs (Stage A), with
    production baseline collapsed below invariants."""
    from experiment_floor_plan_generation_slice import (
        _build_w12_compatibility_report,
        _render_w12_html,
    )
    w12 = _w12_ok_payload()
    rep = _build_w12_compatibility_report(
        w12=w12, adapter_plan=_synthetic_adapter(), w11_brief=_synthetic_w11_brief(),
        fp_context=_synthetic_fp_context(),
        production_diff_empty=True, db_write_count=0, image_import_seen=False,
        prev_run_id="fakeW11", missing_inputs=[], stage_status="generated",
    )
    run_meta = {
        "run_id": "RID1", "stage": "w12_floor_plan_generation_slice",
        "run_status": "succeeded", "exit_code": 0,
        "derived_from": "fakeW11", "model_used": None,
        "stage_a_source_mode": "derived_from_w11_plus_w3_source",
        "source_language_mode": "not_available",
        "image_generation_count": 0, "image_generation_backend": "gpt-image-2",
    }
    _render_w12_html(run_meta, w12, rep, _synthetic_w11_brief(),
                     _synthetic_fp_context(), tmp_path)
    html = (tmp_path / "index.html").read_text()
    sections = [
        "Per-bg render reference instruction",
        "Floor-plan candidate",
        "Group briefs carried from W11",
        "Invariants",
        "Production baseline floor_plan_prompt JSON",
    ]
    positions = [html.find(s) for s in sections]
    assert all(p > 0 for p in positions), positions
    assert positions == sorted(positions), positions
    # Production baseline must live inside a collapsed <details>: the
    # immediately preceding <details> opener must come before its summary text.
    baseline_pos = html.find("Production baseline floor_plan_prompt JSON")
    details_pos = html.rfind("<details>", 0, baseline_pos)
    assert details_pos > 0 and details_pos < baseline_pos
    # No image section.
    assert "<img" not in html
