"""Phase A 6 step verify_completion 본체 테스트."""
import json
from pathlib import Path


def test_ref_image_gen_verify_clean(seed_episode_with_chars, tmp_path):
    """active character 3 + low_freq_skip 0 → ImageAsset 3 + 파일 3 → is_complete=True."""
    runner = seed_episode_with_chars(["C01", "C02", "C03"], skip=[])
    for sid in ["C01", "C02", "C03"]:
        png = tmp_path / f"{sid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_ref_asset(short_id=sid, file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.metadata["found"] == 3


def test_ref_image_gen_verify_missing(seed_episode_with_chars, tmp_path):
    """active 3, ImageAsset 1 → is_complete=False, severity='partial'."""
    runner = seed_episode_with_chars(["C01", "C02", "C03"], skip=[])
    png = tmp_path / "C01.png"
    png.write_bytes(b"\x89PNG")
    runner._add_ref_asset(short_id="C01", file_path=str(png))

    report = runner.verify_completion()
    assert not report.is_complete
    assert report.severity == "partial"
    assert report.metadata["expected"] == 3
    assert report.metadata["found"] == 1


def test_ref_image_gen_verify_low_freq_excluded(seed_episode_with_chars, tmp_path):
    """active 3, low_freq_skip [C03], ImageAsset 2 → expected=2, is_complete=True."""
    runner = seed_episode_with_chars(["C01", "C02", "C03"], skip=["C03"])
    for sid in ["C01", "C02"]:
        png = tmp_path / f"{sid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_ref_asset(short_id=sid, file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True


def test_ref_image_gen_verify_file_missing(seed_episode_with_chars, tmp_path):
    """ImageAsset row 3개 있는데 파일 1개 missing → is_complete=False."""
    runner = seed_episode_with_chars(["C01", "C02", "C03"], skip=[])
    for sid in ["C01", "C02"]:
        png = tmp_path / f"{sid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_ref_asset(short_id=sid, file_path=str(png))
    runner._add_ref_asset(short_id="C03", file_path=str(tmp_path / "missing.png"))

    report = runner.verify_completion()
    assert not report.is_complete
    assert report.metadata["found"] == 2
    assert report.metadata["rows"] == 3


# ── A6 IMPORTANT: RefImageGen verify scope to non-location/non-outlook ──

def test_ref_image_gen_verify_includes_props(
    seed_episode_with_mixed_entities, tmp_path,
):
    """character 2 + prop 2 → expected=4 (character만 보던 verify는 false clean).

    production reference_phase1_service.py:81은 entity_type ∉ {location, outlook}
    인 모든 엔티티에 대해 ref 생성. verify가 character만 검사하면 prop ref 누락
    발견 못 함 → false-clean (A6 IMPORTANT).
    """
    runner = seed_episode_with_mixed_entities(
        chars=["C01", "C02"],
        props=["P01", "P02"],
    )
    # character 2개만 ref 파일 생성, prop은 누락.
    for sid in ["C01", "C02"]:
        png = tmp_path / f"{sid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_ref_asset(short_id=sid, file_path=str(png))

    report = runner.verify_completion()
    assert not report.is_complete, "prop ref 2개 누락이 missing으로 잡혀야 함"
    assert report.metadata["expected"] == 4
    assert report.metadata["found"] == 2


def test_ref_image_gen_verify_excludes_location_outlook(
    seed_episode_with_mixed_entities, tmp_path,
):
    """location/outlook은 다른 step에서 처리 → RefImageGen scope에서 제외.

    chars 2 + props 1 + locations 2 + outlooks 1 시드, 모두 ref 파일은 chars+prop
    3개만 → location/outlook이 expected에 포함되면 missing 발생, 제외되면 clean.
    """
    runner = seed_episode_with_mixed_entities(
        chars=["C01", "C02"],
        props=["P01"],
        locations=["L01", "L02"],
        outlooks=["O01"],
    )
    for sid in ["C01", "C02", "P01"]:
        png = tmp_path / f"{sid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_ref_asset(short_id=sid, file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True, "location/outlook이 expected에서 제외돼야 함"
    assert report.metadata["expected"] == 3
    assert report.metadata["found"] == 3


def test_ref_image_gen_verify_props_low_freq_excluded(
    seed_episode_with_mixed_entities, tmp_path,
):
    """저빈도 prop이 low_freq_skip에 있으면 expected에서 제외 (character와 동일).

    production reference_pipeline_orchestrator.py:287-297은 entity_type !=
    location/outlook + count <= 1인 entity를 low_freq_skip에 기록 (prop 포함).
    """
    runner = seed_episode_with_mixed_entities(
        chars=["C01"],
        props=["P01", "P02"],
        skip=["P02"],  # P02는 저빈도 prop
    )
    for sid in ["C01", "P01"]:
        png = tmp_path / f"{sid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_ref_asset(short_id=sid, file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.metadata["expected"] == 2  # P02 제외
    assert report.metadata["found"] == 2


def test_composite_verify_clean(seed_episode_with_combos, tmp_path):
    """char×outlook 4 pair (O00 1개 제외) → composite 3개 + 파일 → is_complete=True.

    Phase 2 outlook standalone도 검증되지만 fixture는 outlook EntityEpisodeLink를
    시드하지 않으므로 outlook_expected=0. 합산 expected=3 / found=3."""
    runner = seed_episode_with_combos(combos=[
        ("C01", "O01"), ("C01", "O02"), ("C02", "O01"), ("C02", "O00")
    ])
    for char, outlook in [("C01", "O01"), ("C01", "O02"), ("C02", "O01")]:
        png = tmp_path / f"{char}_{outlook}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_composite_asset(char_sid=char, outlook_sid=outlook, file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.metadata["composite_found"] == 3
    assert report.metadata["composite_expected"] == 3
    assert report.metadata["outlook_expected"] == 0


def test_composite_verify_missing(seed_episode_with_combos, tmp_path):
    """4 pair 중 O00 제외 3, 1개만 생성 → is_complete=False, severity='partial'."""
    runner = seed_episode_with_combos(combos=[
        ("C01", "O01"), ("C01", "O02"), ("C02", "O01"), ("C02", "O00")
    ])
    png = tmp_path / "C01_O01.png"
    png.write_bytes(b"\x89PNG")
    runner._add_composite_asset(char_sid="C01", outlook_sid="O01", file_path=str(png))

    report = runner.verify_completion()
    assert not report.is_complete
    assert report.severity == "partial"
    assert report.metadata["composite_expected"] == 3
    assert report.metadata["composite_found"] == 1


def test_state_variant_verify_clean(seed_episode_with_staging, tmp_path):
    """staging의 dead/severely_injured/unconscious 인물 (char, state) 쌍 수 ==
    ImageAsset(prompt_used~'state_variant:char:state') row + 파일 존재."""
    runner = seed_episode_with_staging(states=[
        ("C01", "dead"), ("C02", "severely_injured")
    ])
    for char, state in [("C01", "dead"), ("C02", "severely_injured")]:
        png = tmp_path / f"{char}_{state}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_state_variant_asset(char_sid=char, state=state, file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True


def test_state_variant_verify_missing(seed_episode_with_staging, tmp_path):
    """state pair 1 → row 0 → severity='missing'."""
    runner = seed_episode_with_staging(states=[("C01", "dead")])
    report = runner.verify_completion()
    assert not report.is_complete
    assert report.severity == "missing"
    assert report.metadata["expected"] == 1
    assert report.metadata["found"] == 0


def test_state_variant_verify_skips_low_freq_registered_character(
    seed_episode_with_staging, tmp_path,
):
    """registered immobilized character가 low_freq_skip set에 있으면 expected에서 제외.

    Sticky-partial 회귀 가드: ref_image_gen은 low-frequency character의 base
    reference 생성을 skip한다 (ref_low_freq_skip.json). 그런 character는 정상
    reference가 없어 state variant도 생성 불가하고, pipeline_gate.
    check_scene_images_ready 도 이 set을 missing-ref gate에서 제외한다. 따라서
    character_state_variant의 _execute/verify expected도 동일하게 제외해야
    한다 — 그러지 않으면 episode가 영원히 'analyzed'로 advance하지 못하고
    scene_image_pipeline gate가 막힌다 (특정 이름 하드코딩 아님, ref_image_gen
    skip policy와 정렬).

    시나리오: C01(dead) + C02(dead) 둘 다 immobilized, C02는 low_freq_skip.
    C01 variant만 생성됨 → expected=1(C02 제외), found=1 → clean.
    fix 없으면 expected=2, found=1 → partial (회귀).
    """
    runner = seed_episode_with_staging(
        states=[("C01", "dead"), ("C02", "dead")], skip_chars=["C02"],
    )
    # low_freq skip 안 된 C01 variant만 생성 (파일 + ImageAsset row).
    png = tmp_path / "C01_dead.png"
    png.write_bytes(b"\x89PNG")
    runner._add_state_variant_asset(char_sid="C01", state="dead", file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True, (
        f"low_freq-skipped C02가 expected에서 제외돼야 clean — {report.metadata}"
    )
    assert report.metadata["expected"] == 1  # C02 제외
    assert report.metadata["found"] == 1


# ── A6 minor: state_variant unregistered names lenient skip 회귀 가드 ──

def test_state_variant_verify_lenient_skip_unregistered(
    seed_episode_with_staging, tmp_path,
):
    """manifest의 unregistered name(EntityCanon에 없음)은 silent skip — false-missing 금지.

    production image_steps.py:662-677은 entity_merge에 미등록된 이름(예: 익명 단역
    "Unnamed Korean woman", scene_consistency가 보통명사로 식별한 인물 등)을
    `_affected_registered`에서 제외해 사전에 dropping한다 (warning만 로그). verify는
    이 정책을 mirror해 expected_pairs 구성 시 `name_to_uuid`에 매핑되지 않는 이름을
    skip한다 — 그렇지 않으면 production이 합법적으로 생성하지 않은 variant를
    누락이라고 잘못 보고하게 된다.

    시나리오: registered 1(C01/dead) + unregistered 1(이름만 manifest에 존재)
    → expected = 1(C01/dead만), unregistered는 expected에서 제외되어 clean.
    """
    runner = seed_episode_with_staging(states=[("C01", "dead")])

    # registered C01 variant 파일 + ImageAsset row 시드.
    png = tmp_path / "C01_dead.png"
    png.write_bytes(b"\x89PNG")
    runner._add_state_variant_asset(char_sid="C01", state="dead", file_path=str(png))

    # unregistered 이름을 manifest에 직접 추가 (EntityCanon에 매칭되지 않는 이름).
    # production은 이런 이름을 _affected_registered 단계에서 익명 단역으로 사전 제외.
    cp_dir = (
        Path(tmp_path) / runner.project_id / "checkpoints" / "episodes"
        / runner.episode_id / "shot_staging"
    )
    manifest_path = cp_dir / "manifest.json"
    manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
    manifest["data"]["shots"].append({
        "character_angles": [
            {"character": "Unnamed Korean villager",
             "gaze_direction_kind": "closed_eyes", "subject_state": "dead"},
        ]
    })
    manifest_path.write_text(
        json.dumps(manifest, ensure_ascii=False), encoding="utf-8",
    )

    report = runner.verify_completion()
    # unregistered가 expected에 들어가면 expected=2 / found=1 → not clean.
    # production policy(lenient skip) 일치 시 expected=1 / found=1 → clean.
    assert report.is_complete is True, (
        f"unregistered name은 expected에서 사전 제외되어야 함 "
        f"(production _affected_registered policy mirror) — meta={report.metadata}"
    )
    assert report.metadata["expected"] == 1
    assert report.metadata["found"] == 1


def test_outlook_standalone_counts_expected_and_found(
    seed_episode_with_outlook_wearers, tmp_path,
):
    """옷 개수를 **세는** 시험 — `is_complete` 는 여기서 안 본다.

    ★2026-09-19 — 두 번 고쳤다.

    ① 앞서는 착용자 없이 옷만 시드했다. 착용자 0인 옷은 이제 기대에서
       빠지므로(낼 곳이 없다) 그대로 두면 「0 == 0」을 보며 뜻을 잃는다.
       그래서 **입는 사람을 시드**한다.
    ② 착용자를 시드하면 합성 쌍도 기대에 들어오는데 이 fixture 에는 합성
       자산을 넣을 helper 가 없다. 그래서 `is_complete` 를 단언할 수 없고,
       이름도 「clean」이 아니라 **세는 시험**으로 바꿨다 (Codex 지적).
       clean 판정은 같은 파일의 다른 시험들이 본다.
    """
    runner = seed_episode_with_outlook_wearers(
        outlooks=["O01", "O02"],
        wearers=[("C01", "O01"), ("C02", "O02")],
        skip_chars=[],
    )
    for sid in ["O01", "O02"]:
        png = tmp_path / f"{sid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_outlook_asset(short_id=sid, file_path=str(png))

    report = runner.verify_completion()
    assert report.metadata["outlook_expected"] == 2
    assert report.metadata["outlook_found"] == 2
    assert report.metadata["outlook_no_wearer_skipped"] == 0


def test_outlook_standalone_verify_missing(
    seed_episode_with_outlook_wearers, tmp_path,
):
    """파일이 없으면 **빠졌다고 말해야 한다** — 착용자가 있는 옷에 한해."""
    runner = seed_episode_with_outlook_wearers(
        outlooks=["O01", "O02"],
        wearers=[("C01", "O01"), ("C02", "O02")],
        skip_chars=[],
    )
    report = runner.verify_completion()
    assert not report.is_complete
    assert report.metadata["outlook_expected"] == 2
    assert report.metadata["outlook_found"] == 0


# ── A7 critical: outlook low_freq_skip wearer reverse-lookup ──

def test_outlook_skipped_when_only_wearer_low_freq(
    seed_episode_with_outlook_wearers, tmp_path,
):
    """O01만 입는 character C01이 low-freq → O01 outlook은 expected에서 제외.

    production reference_phase2_service.py:122 — wearer character_id가
    low-freq면 outlook generation 자체를 skip. verify가 모든 wearer가
    low-freq인 outlook을 expected에서 빼야 일관 (false partial 방지).
    """
    runner = seed_episode_with_outlook_wearers(
        outlooks=["O01"],
        wearers=[("C01", "O01")],
        skip_chars=["C01"],
    )
    # C01이 low-freq → O01은 generation skip → expected=0 → clean.
    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.metadata["outlook_expected"] == 0


def test_outlook_kept_when_any_wearer_non_low_freq(
    seed_episode_with_outlook_wearers, tmp_path,
):
    """O01을 C01(low-freq) + C02(non-low-freq) 둘이 입음 → O01은 expected에 포함.

    production은 C02를 위한 outlook generation을 실행 → verify도 이 outlook을
    expected에 포함해야 일관 (단일 wearer가 non-low-freq면 outlook 생성됨).
    """
    runner = seed_episode_with_outlook_wearers(
        outlooks=["O01"],
        wearers=[("C01", "O01"), ("C02", "O01")],
        skip_chars=["C01"],
    )
    # 파일 미생성 → expected=1 / found=0 → not clean.
    report = runner.verify_completion()
    assert not report.is_complete
    assert report.metadata["outlook_expected"] == 1
    assert report.metadata["outlook_found"] == 0


def test_outlook_with_no_wearer_is_not_expected_but_is_counted(
    seed_episode_with_outlook_wearers, tmp_path,
):
    """★뒤집었다 (2026-09-19) — wearer 0 인 outlook 은 **낼 곳이 없다.**

    옛 계약은 「skip 근거가 없으니 expected 유지」였다. 그런데 만드는 쪽
    (`reference_phase2_service`)은 **착용자 쌍을 돌면서만** 만든다. 쌍이 0이면
    그 옷은 영원히 안 만들어지므로, 기대에 넣으면 멀쩡히 끝난 주행이 영구
    `partial` 이 된다.

    실측(컨트리로드 2판): O30 연구원가운·O31 청소작업복·O40 미군복 셋 때문에
    `3 outlook standalones missing (expected=28, found=25)` 로 서서, 다음 단계를
    도는 스크립트가 거기서 멈췄다. 합성은 24/24 전부 있었다.

    ★조용히 넘기지는 않는다 — 몇 개를 뺐는지 `outlook_no_wearer_skipped` 로
    남긴다. 안 그러면 아웃룩 배정이 **정말로** 빈 날에도 똑같이 통과한다.
    """
    runner = seed_episode_with_outlook_wearers(
        outlooks=["O01"],
        wearers=[],  # CharacterOutlook row 없음
        skip_chars=[],
    )
    png = tmp_path / "O01.png"
    png.write_bytes(b"\x89PNG")
    runner._add_outlook_asset(short_id="O01", file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.metadata["outlook_expected"] == 0, (
        "낼 곳이 없는 옷을 기대에 넣으면 영구 partial 이다")
    assert report.metadata["outlook_no_wearer_skipped"] == 1, (
        "몇 개를 뺐는지 안 남기면 진짜 배정 누락도 같은 모습이 된다")


def test_outlook_mixed_skip_aggregates_per_outlook(
    seed_episode_with_outlook_wearers, tmp_path,
):
    """C01(low-freq) + C02(non-low-freq) 환경:
       O01: only C01 wears → skipped (expected에서 제외)
       O02: C01 + C02 wear → kept (단일 wearer 규칙)
       O03: only C02 wears → kept

    production aggregation: 모든 wearer가 low-freq일 때만 outlook skip.
    """
    runner = seed_episode_with_outlook_wearers(
        outlooks=["O01", "O02", "O03"],
        wearers=[
            ("C01", "O01"),
            ("C01", "O02"), ("C02", "O02"),
            ("C02", "O03"),
        ],
        skip_chars=["C01"],
    )
    # O02, O03 두 개만 expected → 둘 다 파일 미생성 → not clean.
    report = runner.verify_completion()
    assert not report.is_complete
    assert report.metadata["outlook_expected"] == 2
    assert report.metadata["outlook_found"] == 0


# ── Task 24: FloorPlanRenderStep.verify_completion ──
#
# Plan-vs-production 정찰 결과 mismatch 4건 반영:
#   1) asset_type='floor_plan' (NOT 'reference')
#   2) variant_type=fp_id (NOT prompt_used regex)
#   3) expected source = master_plan.data.plans[gid].plan.floor_plans[].fp_id
#      (NOT data.groups[].needs_floor_plan; 후자는 production에 없음)
#   4) renderable filter = floor_plan_prompt.data.floor_plans[fid].status=='ok'

def test_floor_plan_render_verify_skip_when_background_mode_off(
    seed_episode_with_floor_plans, tmp_path,
):
    """background_mode='off' → step 자체가 skip이므로 verify는 expected=0 clean.

    production line ~93: settings.background_mode not in {"on","floor_plan_anchored"}
    → applicable_count=0 즉시 반환. verify는 이 gate를 mirror해야 false-missing 방지.
    """
    runner = seed_episode_with_floor_plans(
        master_plan_fps=[("L01", "fp_living"), ("L02", "fp_kitchen")],
        background_mode="off",
    )
    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.metadata["floor_plan_expected"] == 0
    assert report.metadata["floor_plan_found"] == 0


def test_floor_plan_render_verify_clean(
    seed_episode_with_floor_plans, tmp_path,
):
    """master_plan 2 fp + prompt status=ok 2 + 2 row file 존재 → clean."""
    runner = seed_episode_with_floor_plans(
        master_plan_fps=[("L01", "fp_living"), ("L02", "fp_kitchen")],
    )
    for loc, fid in [("L01", "fp_living"), ("L02", "fp_kitchen")]:
        png = tmp_path / f"{fid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_floor_plan_asset(loc_short=loc, fp_id=fid, file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.metadata["floor_plan_expected"] == 2
    assert report.metadata["floor_plan_found"] == 2


def test_floor_plan_render_verify_renderable_filter_excludes_failed_prompt(
    seed_episode_with_floor_plans, tmp_path,
):
    """renderable filter: prompt status='failed' 인 fp는 expected에서 제외.

    master_plan 3 fp + prompt status=ok 2 (fp3=failed) + ImageAsset 2개 file 존재.
    expected는 renderable 2개로 한정 → clean (fp3는 검증 대상 아님).
    """
    runner = seed_episode_with_floor_plans(
        master_plan_fps=[
            ("L01", "fp1"), ("L01", "fp2"), ("L02", "fp3"),
        ],
        prompt_status={"fp1": "ok", "fp2": "ok", "fp3": "failed"},
    )
    # renderable 2개만 파일 + row 시드
    for loc, fid in [("L01", "fp1"), ("L01", "fp2")]:
        png = tmp_path / f"{fid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_floor_plan_asset(loc_short=loc, fp_id=fid, file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.metadata["floor_plan_expected"] == 2
    assert report.metadata["floor_plan_found"] == 2


def test_floor_plan_render_verify_partial_missing(
    seed_episode_with_floor_plans, tmp_path,
):
    """renderable 3 + 1 row file 미존재 → severity='partial', missing=[fp3]."""
    runner = seed_episode_with_floor_plans(
        master_plan_fps=[
            ("L01", "fp1"), ("L01", "fp2"), ("L02", "fp3"),
        ],
        # 모두 ok
        prompt_status={"fp1": "ok", "fp2": "ok", "fp3": "ok"},
    )
    # 2개만 파일 존재, fp3은 row+file 모두 미생성
    for loc, fid in [("L01", "fp1"), ("L01", "fp2")]:
        png = tmp_path / f"{fid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_floor_plan_asset(loc_short=loc, fp_id=fid, file_path=str(png))

    report = runner.verify_completion()
    assert not report.is_complete
    assert report.severity == "partial"
    assert report.metadata["floor_plan_expected"] == 3
    assert report.metadata["floor_plan_found"] == 2
    assert report.missing == ["fp3"]


def test_floor_plan_render_verify_no_master_plan(
    seed_episode_with_floor_plans, tmp_path,
):
    """master_plan 체크포인트 없음 (전 단계 미실행) → expected=0 clean."""
    runner = seed_episode_with_floor_plans(
        master_plan_fps=[("L01", "fp_living")],
    )
    runner._set_master_plan_missing()  # master_plan cp 제거

    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.metadata["floor_plan_expected"] == 0
    assert report.metadata["floor_plan_found"] == 0


# ── Carry B: floor_plan_render register/verify parity ──
#
# verify_completion 의 expected 가 renderable(prompt ok) 전체를 세던 반면
# _register_image_assets 는 renderable 중 {primary_loc_id 있음 AND location
# EntityCanon resolve 가능} 인 것만 ImageAsset 으로 등록한다. 미사용 location
# 의 floor plan(ref background 0 → primary_loc_id 빈값)은 등록되지 않으므로
# verify 가 그것을 영구 missing 으로 오인한다. expected 를 registerable set 으로
# 좁히고 제외분을 skipped_unregistered_floor_plan metadata 에 기록한다.


def test_floor_plan_render_verify_excludes_no_primary_loc(
    seed_episode_with_floor_plans, tmp_path,
):
    """ref background 0 인 fp(primary_loc_id 빈값)는 expected 에서 제외 → clean.

    repro: fp_a(ref bg L01) + fp_b(ref bg 0). 둘 다 prompt ok. ImageAsset 는
    fp_a 만 → 좁히기 전엔 expected=2/found=1=partial 이지만, fp_b 는 애초에
    등록 불가능하므로 expected=1/found=1=clean 이어야 한다.
    """
    runner = seed_episode_with_floor_plans(
        master_plan_fps=[("L01", "fp_a"), ("", "fp_b")],
    )
    png = tmp_path / "fp_a.png"
    png.write_bytes(b"\x89PNG")
    runner._add_floor_plan_asset(loc_short="L01", fp_id="fp_a", file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.metadata["floor_plan_expected"] == 1
    assert report.metadata["floor_plan_found"] == 1
    skipped = report.metadata["skipped_unregistered_floor_plan"]
    assert skipped["no_primary_loc_id"] == ["fp_b"]
    assert "missing_location_canon" not in skipped


def test_floor_plan_render_verify_clean_has_no_skipped_metadata(
    seed_episode_with_floor_plans, tmp_path,
):
    """모든 fp 가 primary_loc + canon + ImageAsset → clean, skipped key 부재.

    registerable narrowing 이 정상 케이스의 metadata 에 잡음을 남기지 않는지
    회귀 가드 — skipped fp 가 없으면 skipped_unregistered_floor_plan key 자체가
    metadata 에 없어야 한다.
    """
    runner = seed_episode_with_floor_plans(
        master_plan_fps=[("L01", "fp_a"), ("L02", "fp_b")],
    )
    for loc, fid in [("L01", "fp_a"), ("L02", "fp_b")]:
        png = tmp_path / f"{fid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_floor_plan_asset(loc_short=loc, fp_id=fid, file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.metadata["floor_plan_expected"] == 2
    assert report.metadata["floor_plan_found"] == 2
    assert "skipped_unregistered_floor_plan" not in report.metadata


def test_floor_plan_render_verify_real_missing_still_partial(
    seed_episode_with_floor_plans, tmp_path,
):
    """primary_loc + canon 있는 fp 의 ImageAsset 누락은 계속 partial 로 감지.

    registerable narrowing 이 진짜 누락(등록 가능했어야 하는데 안 된 fp)까지
    숨기면 안 된다 — fp_b 는 등록 가능 조건을 다 갖췄으므로 ImageAsset 가
    없으면 partial/missing 이어야 한다.
    """
    runner = seed_episode_with_floor_plans(
        master_plan_fps=[("L01", "fp_a"), ("L02", "fp_b")],
    )
    png = tmp_path / "fp_a.png"
    png.write_bytes(b"\x89PNG")
    runner._add_floor_plan_asset(loc_short="L01", fp_id="fp_a", file_path=str(png))

    report = runner.verify_completion()
    assert not report.is_complete
    assert report.severity == "partial"
    assert report.metadata["floor_plan_expected"] == 2
    assert report.metadata["floor_plan_found"] == 1
    assert report.missing == ["fp_b"]
    assert "skipped_unregistered_floor_plan" not in report.metadata


def test_floor_plan_render_verify_excludes_missing_location_canon(
    seed_episode_with_floor_plans, tmp_path,
):
    """primary_loc_id 는 있으나 그 location 의 EntityCanon 이 없는 fp 는 제외.

    _register_image_assets 는 primary_loc 의 canon 을 resolve 못 하면 그 fp 를
    skip 한다(per-fp no-canon path). verify 도 동일하게 expected 에서 제외하고
    reason=missing_location_canon 으로 기록해야 한다.
    """
    runner = seed_episode_with_floor_plans(
        master_plan_fps=[("L01", "fp_a"), ("L02", "fp_b")],
        skip_loc_canons=["L02"],
    )
    png = tmp_path / "fp_a.png"
    png.write_bytes(b"\x89PNG")
    runner._add_floor_plan_asset(loc_short="L01", fp_id="fp_a", file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.metadata["floor_plan_expected"] == 1
    assert report.metadata["floor_plan_found"] == 1
    skipped = report.metadata["skipped_unregistered_floor_plan"]
    assert skipped["missing_location_canon"] == ["fp_b"]
    assert "no_primary_loc_id" not in skipped


# ── Task 23: BackgroundChainRenderStep.verify_completion (planner+legacy dual) ──
#
# Plan-vs-production 정찰 결과 mismatch 4건 반영 (plan §4.4 Task 23):
#   1) asset_type='chain_bg' (planner) / 'background_chain_node' (legacy)
#      — NOT 'reference'
#   2) traceability = variant_type=group_id|node_id (NOT prompt_used regex)
#   3) expected source = background_planner.data.chain_bg_order (planner) /
#      background_chain_planning.data.locations[loc_id].nodes[] (legacy)
#      — NOT background_chain_planning.data.groups
#   4) verify는 background_chain_render_step.py 직접 추가 (NOT image_steps.py)

def test_chain_bg_render_verify_clean_planner(
    seed_episode_with_chain_bg_planner, tmp_path,
):
    """planner-driven path: chain_order 3개 + 3 ImageAsset(chain_bg) + 파일 존재 → clean.

    production line ~191: planner_chain_order is not None일 때 group 단위
    UPSERT (asset_type='chain_bg', variant_type=group_id). verify는 이 path를
    mirror해 row + file_path 존재 검증.
    """
    runner = seed_episode_with_chain_bg_planner(
        chain_order=["g1", "g2", "g3"],
    )
    for gid in ["g1", "g2", "g3"]:
        png = tmp_path / f"{gid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_chain_bg_asset(group_id=gid, file_path=str(png))

    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.metadata["chain_bg_expected"] == 3
    assert report.metadata["chain_bg_found"] == 3
    assert report.metadata["path"] == "planner"


def test_chain_bg_render_verify_missing_planner(
    seed_episode_with_chain_bg_planner, tmp_path,
):
    """planner-driven: chain_order 3개 + 2 row file 존재 + 1 row file 미존재 → partial.

    file_path 컬럼은 있지만 디스크에 PNG가 없는 경우(이미지 손상/삭제 후 sync drift)
    production은 verify에서 false-clean이 되면 안 된다 — file_path stat 기반 검증.
    """
    runner = seed_episode_with_chain_bg_planner(
        chain_order=["g1", "g2", "g3"],
    )
    # g1, g2는 정상 파일 + row
    for gid in ["g1", "g2"]:
        png = tmp_path / f"{gid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_chain_bg_asset(group_id=gid, file_path=str(png))
    # g3은 row만 있고 file_path는 존재하지 않는 경로
    runner._add_chain_bg_asset(
        group_id="g3", file_path=str(tmp_path / "missing_g3.png"),
    )

    report = runner.verify_completion()
    assert not report.is_complete
    assert report.severity == "partial"
    assert report.metadata["chain_bg_expected"] == 3
    assert report.metadata["chain_bg_found"] == 2
    assert report.metadata["path"] == "planner"
    # missing 메시지는 g3을 정확히 지목해야 함
    assert any("g3" in msg for msg in report.missing)


def test_chain_bg_render_verify_clean_legacy(
    seed_episode_with_chain_bg_legacy, tmp_path,
):
    """planner cp 없음 + legacy nodes 2 + 2 row file 존재 → clean.

    production line ~218: planner_chain_order=None → legacy path.
    nodes는 background_chain_planning.data.locations[loc_id].nodes[]에서 추출.
    asset_type='background_chain_node' + variant_type=node_id로 verify.
    """
    runner = seed_episode_with_chain_bg_legacy(
        nodes_by_location={"L01": ["n1", "n2"]},
    )
    for nid in ["n1", "n2"]:
        png = tmp_path / f"{nid}.png"
        png.write_bytes(b"\x89PNG")
        runner._add_chain_node_asset(
            node_id=nid, file_path=str(png), location_short_id="L01",
        )

    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.metadata["chain_bg_expected"] == 2
    assert report.metadata["chain_bg_found"] == 2
    assert report.metadata["path"] == "legacy"


def test_chain_bg_render_verify_no_data(
    seed_episode_with_chain_bg_legacy, tmp_path,
):
    """planner cp 없음 + legacy chain_planning cp도 없음 → expected=0 clean.

    background_chain_render는 on_demand step. 사용자가 트리거 전에 verify가
    돌면 input cp 양쪽 모두 없을 수 있다 — false-missing 금지.
    """
    runner = seed_episode_with_chain_bg_legacy()  # cp 자체 없음
    report = runner.verify_completion()
    assert report.is_complete is True
    assert report.severity == "clean"
    assert report.metadata["chain_bg_expected"] == 0
    assert report.metadata["chain_bg_found"] == 0
    assert report.metadata["path"] in ("none", "legacy")
