"""W21B-wave-1 (Commit 2) — background_render checkpoint-only attached
reference lineage diagnostic.

Codex 결정 2(c): ``ImageAsset.reference_image_ids`` / ``llm_call_log`` 컬럼은
이번 wave 에서 건드리지 않는다. ``attached_reference_lineage`` 는 step 의
``groups_out`` entry / checkpoint manifest 에만 기록된다.

LLM / image / VLM API call 0, DB write 0. helper 단독 검증 + entry shape
회귀 (legacy / shot_aware / opt-in failed) 만 검사한다.
"""
from __future__ import annotations

from pathlib import Path
from unittest.mock import MagicMock

from app.core.steps.background_render_step import (
    _build_attached_reference_lineage,
)


def _touch(p: Path) -> Path:
    p.parent.mkdir(parents=True, exist_ok=True)
    p.write_bytes(b"")
    return p


def test_lineage_text_only_no_fp_no_prior(tmp_path):
    """fp_path / prior_bg_paths 모두 비어 있으면 attached_ref_labels = [].

    ref_used 가 ``text_only`` 인 path 의 정상 baseline."""
    lineage = _build_attached_reference_lineage(
        fp_id="fp_a",
        fp_path=None,
        prior_bg_paths=[],
        ref_used="text_only",
    )
    assert lineage == {
        "fp_id": "fp_a",
        "prior_bg_ids": [],
        "attached_ref_labels": [],
        "ref_used": "text_only",
        "db_write_policy": "checkpoint_only",
    }


def test_lineage_fp_only_attaches_fp_label(tmp_path):
    """fp_path 가 실재 파일이면 attached_ref_labels 에 ``fp:<fp_id>`` 만."""
    fp = _touch(tmp_path / "fp_l05_main.png")
    lineage = _build_attached_reference_lineage(
        fp_id="fp_l05_main",
        fp_path=fp,
        prior_bg_paths=[],
        ref_used="fp_only",
    )
    assert lineage["attached_ref_labels"] == ["fp:fp_l05_main"]
    assert lineage["prior_bg_ids"] == []
    assert lineage["ref_used"] == "fp_only"
    assert lineage["db_write_policy"] == "checkpoint_only"


def test_lineage_prior_bg_only_uses_stem_as_id(tmp_path):
    """prior_bg_paths 의 각 PNG stem 이 bg_id 로 attached_ref_labels 에 추가."""
    p1 = _touch(tmp_path / "L05B06.png")
    p2 = _touch(tmp_path / "L05B07.png")
    lineage = _build_attached_reference_lineage(
        fp_id="fp_l05_main",
        fp_path=None,
        prior_bg_paths=[p1, p2],
        ref_used="refs_2",
    )
    assert lineage["prior_bg_ids"] == ["L05B06", "L05B07"]
    assert lineage["attached_ref_labels"] == ["bg:L05B06", "bg:L05B07"]
    assert "fp:fp_l05_main" not in lineage["attached_ref_labels"]


def test_lineage_fp_plus_prior_bg(tmp_path):
    """fp + prior bg 동시 첨부 — label 순서: fp 먼저, 이어 bg 순서."""
    fp = _touch(tmp_path / "fp_l05_main.png")
    bg = _touch(tmp_path / "L05B06.png")
    lineage = _build_attached_reference_lineage(
        fp_id="fp_l05_main",
        fp_path=fp,
        prior_bg_paths=[bg],
        ref_used="refs_2",
    )
    assert lineage["attached_ref_labels"] == [
        "fp:fp_l05_main",
        "bg:L05B06",
    ]
    assert lineage["prior_bg_ids"] == ["L05B06"]


def test_lineage_skips_missing_path_silently(tmp_path):
    """fp_path 가 path 객체이지만 실재하지 않으면 fp label 누락. silent diag."""
    missing = tmp_path / "fp_missing.png"  # not touched
    lineage = _build_attached_reference_lineage(
        fp_id="fp_missing",
        fp_path=missing,
        prior_bg_paths=[],
        ref_used="text_only",
    )
    # fp_id 는 audit field 에 carry 되지만 실제 첨부 label 은 없다 — Codex 결정 2(c) 의
    # "실제 첨부된 것만" 정책.
    assert lineage["fp_id"] == "fp_missing"
    assert lineage["attached_ref_labels"] == []
    assert lineage["prior_bg_ids"] == []


def test_lineage_building_fp_label_appended_last(tmp_path):
    """W-G — building fp 첨부 시 라벨 'building_fp:<id>' 를 마지막에 추가.

    prior_bg_ids 에는 절대 섞이지 않는다(bg lineage 채널 오염 방지)."""
    fp = _touch(tmp_path / "fp_l05_main.png")
    bg = _touch(tmp_path / "L05B06.png")
    bfp = _touch(tmp_path / "fp_l04_room.png")
    lineage = _build_attached_reference_lineage(
        fp_id="fp_l05_main",
        fp_path=fp,
        prior_bg_paths=[bg],
        ref_used="refs_3",
        building_fp_id="fp_l04_room",
        building_fp_path=bfp,
    )
    assert lineage["attached_ref_labels"] == [
        "fp:fp_l05_main",
        "bg:L05B06",
        "building_fp:fp_l04_room",
    ]
    assert lineage["prior_bg_ids"] == ["L05B06"]


def test_lineage_building_fp_missing_file_no_label(tmp_path):
    """W-G — building fp path 가 실재하지 않으면 라벨 없음(실제 첨부만 기록)."""
    lineage = _build_attached_reference_lineage(
        fp_id="fp_a",
        fp_path=None,
        prior_bg_paths=[],
        ref_used="text_only",
        building_fp_id="fp_l04_room",
        building_fp_path=tmp_path / "nope.png",
    )
    assert lineage["attached_ref_labels"] == []


def test_lineage_db_write_policy_is_constant():
    """db_write_policy 는 항상 'checkpoint_only' (ImageAsset / llm_call_log
    DB 쓰기 금지 — Codex 결정 2(c))."""
    lineage = _build_attached_reference_lineage(
        fp_id="fp_a",
        fp_path=None,
        prior_bg_paths=[],
        ref_used="text_only",
    )
    assert lineage["db_write_policy"] == "checkpoint_only"


def test_lineage_payload_keys_are_stable_shape():
    """audit payload 의 key set 이 안정적. checkpoint manifest 가 이 shape 을
    역추적 grep 의 SOT 로 사용하므로 새 key 추가는 별도 wave 의 decision."""
    lineage = _build_attached_reference_lineage(
        fp_id="fp_a",
        fp_path=None,
        prior_bg_paths=[],
        ref_used="text_only",
    )
    assert set(lineage.keys()) == {
        "fp_id",
        "prior_bg_ids",
        "attached_ref_labels",
        "ref_used",
        "db_write_policy",
    }


def test_opt_in_failed_entry_carries_lineage():
    """``_build_opt_in_failed_entry`` 도 lineage diagnostic field 를 채워서
    failed entry 가 groups_out shape 상 일관성을 유지한다 — checkpoint 에 lineage
    필드 누락 시 downstream review 가 false 'unknown' 으로 판정하지 않도록."""
    from app.core.steps.background_render_step import BackgroundRenderStep

    step = BackgroundRenderStep.__new__(BackgroundRenderStep)
    entry = step._build_opt_in_failed_entry(
        bid="L05B06",
        spec={
            "loc_id": "L05",
            "sub_location": "primary",
            "depends_on_fp": ["fp_l05_main"],
            "depends_on_bg": ["L05B05"],
            "applies_to_shots": ["S1_Shot1"],
            "state_label": "day_norm",
        },
        prompt_entry={"t2i_prompt": "", "shot_guides": []},
        camera_recs_by_bg={},
        error="shot_aware_plan: plan missing",
    )
    assert entry["status"] == "failed"
    assert "attached_reference_lineage" in entry
    lineage = entry["attached_reference_lineage"]
    assert lineage["fp_id"] == "fp_l05_main"
    assert lineage["attached_ref_labels"] == []  # 실제 첨부 없음.
    assert lineage["db_write_policy"] == "checkpoint_only"
    assert lineage["ref_used"] == "text_only"


def test_shot_aware_queue_renders_fp_less_exterior_direct_plate(
    tmp_path, monkeypatch,
):
    """W21B-wave-2: fp-less exterior plates bypass shot-aware planner queue."""
    from app.core.steps.background_render_step import BackgroundRenderStep

    calls = []

    def _fake_render_one_background(**kwargs):
        calls.append(kwargs)
        kwargs["out_path"].write_bytes(b"PNG")
        return {
            "status": "ok",
            "png_path": str(kwargs["out_path"]),
            "attempts": 1,
            "ref_used": "text_only",
        }

    monkeypatch.setattr(
        "app.modules.pipeline.background_render.render_one_background",
        _fake_render_one_background,
    )

    step = BackgroundRenderStep.__new__(BackgroundRenderStep)
    image_dir = tmp_path / "background_chain"
    image_dir.mkdir()

    groups_out, rendered_paths, failed, catalog_dump = step._run_shot_aware_plan_queue(
        bg_specs={
            "L04B01": {
                "loc_id": "L04",
                "sub_location": "rooftop",
                "surface_role": "exterior_plate",
                "depends_on_fp": [],
                "depends_on_bg": [],
                "applies_to_shots": ["S4_Shot1"],
                "state_label": "day quiet",
            }
        },
        prompts_map={
            "L04B01": {
                "status": "ok",
                "t2i_prompt": "photographic rooftop plate",
                "shot_guides": [],
            }
        },
        fp_paths_str={},
        camera_recs_by_bg={},
        plans_per_fp={},
        renderable={"L04B01"},
        order=["L04B01"],
        image_dir=image_dir,
        image_dir_resolved=image_dir.resolve(),
        client=object(),
    )

    assert failed == 0
    assert groups_out["L04B01"]["status"] == "ok"
    assert groups_out["L04B01"]["floor_plan_used"] is False
    assert groups_out["L04B01"]["shot_aware_plan_mode"] == "surface_role_direct_plate"
    assert groups_out["L04B01"]["reference_decision"]["surface_role"] == "exterior_plate"
    assert "L04B01" in rendered_paths
    assert catalog_dump[0]["source_kind"] == "surface_role_direct_plate"
    assert calls[0]["fp_path"] is None
    assert calls[0]["prior_bg_paths"] == []
    assert calls[0]["max_attempts"] == 2  # 2026-07-11 moderation-전용 재시도


def test_shot_aware_queue_still_fails_interior_without_fp(tmp_path, monkeypatch):
    """W21B-wave-2: only non-interior roles may be fp-less."""
    from app.core.steps.background_render_step import BackgroundRenderStep

    render_mock = MagicMock()
    monkeypatch.setattr(
        "app.modules.pipeline.background_render.render_one_background",
        render_mock,
    )

    step = BackgroundRenderStep.__new__(BackgroundRenderStep)
    image_dir = tmp_path / "background_chain"
    image_dir.mkdir()

    groups_out, rendered_paths, failed, catalog_dump = step._run_shot_aware_plan_queue(
        bg_specs={
            "L05B01": {
                "loc_id": "L05",
                "sub_location": "main",
                "surface_role": "interior_room",
                "depends_on_fp": [],
                "depends_on_bg": [],
                "applies_to_shots": ["S5_Shot1"],
                "state_label": "day quiet",
            }
        },
        prompts_map={
            "L05B01": {
                "status": "ok",
                "t2i_prompt": "photographic room plate",
                "shot_guides": [],
            }
        },
        fp_paths_str={},
        camera_recs_by_bg={},
        plans_per_fp={},
        renderable={"L05B01"},
        order=["L05B01"],
        image_dir=image_dir,
        image_dir_resolved=image_dir.resolve(),
        client=object(),
    )

    assert failed == 1
    assert groups_out["L05B01"]["status"] == "failed"
    assert "bg has no depends_on_fp" in groups_out["L05B01"]["render_error"]
    assert rendered_paths == {}
    assert catalog_dump == []
    render_mock.assert_not_called()


def test_lineage_building_anchor_label_between_bg_and_building_fp(tmp_path):
    """W-I — anchor 라벨은 bg 라벨들 다음·building_fp 라벨 앞 (첨부 순서 계약).

    prior_bg_ids 에는 절대 섞이지 않는다(chain lineage 채널 분리)."""
    fp = _touch(tmp_path / "fp_l05_main.png")
    bg = _touch(tmp_path / "L05B06.png")
    anchor = _touch(tmp_path / "L03B01.png")
    bfp = _touch(tmp_path / "fp_l04_room.png")
    lineage = _build_attached_reference_lineage(
        fp_id="fp_l05_main",
        fp_path=fp,
        prior_bg_paths=[bg],
        ref_used="refs_4",
        building_fp_id="fp_l04_room",
        building_fp_path=bfp,
        building_anchor_bg_id="L03B01",
        building_anchor_path=anchor,
    )
    assert lineage["attached_ref_labels"] == [
        "fp:fp_l05_main",
        "bg:L05B06",
        "building_anchor:L03B01",
        "building_fp:fp_l04_room",
    ]
    assert lineage["prior_bg_ids"] == ["L05B06"]


def test_lineage_building_anchor_missing_file_no_label(tmp_path):
    """W-I — anchor path 가 실재하지 않으면 라벨 없음(실제 첨부만 기록)."""
    lineage = _build_attached_reference_lineage(
        fp_id="fp_a",
        fp_path=None,
        prior_bg_paths=[],
        ref_used="text_only",
        building_anchor_bg_id="L03B01",
        building_anchor_path=tmp_path / "nope.png",
    )
    assert lineage["attached_ref_labels"] == []
