"""outdoor_view_cluster 결정론 테스트 — 스키마/validator/topo만.

LLM 군집 완성도는 검증하지 않는다. fixture 전부 시나리오 중립 SAMPLE.
"""
import pytest

from app.modules.pipeline.outdoor_view_cluster import (
    MAX_CLUSTERS,
    build_cluster_schema,
    generation_order,
    validate_cluster_plan,
)

KEYS = ["S3_Shot1", "S3_Shot2", "S5_Shot1"]


def _cluster(cid, members, parent, bg, **over):
    base = {
        "cluster_id": cid,
        "member_shot_keys": members,
        "spatial_scope_en": "front yard and facade of the main structure",
        "stable_visible_features_en": ["brick facade", "steel entry door"],
        "view_axis_family_en": "frontal approach axis",
        "overlap_evidence_en": "both views share the same facade wall",
        "parent_cluster_id": parent,
        "bg_prompt_en": bg,
        "rationale_ko": "SAMPLE 근거",
    }
    base.update(over)
    return base


def _plan(**over):
    base = {"clusters": [
        _cluster("CL1", ["S3_Shot1", "S3_Shot2"], None, ""),
        _cluster("CL2", ["S5_Shot1"], "CL1",
                 "the same structure seen from the side alley"),
    ]}
    base.update(over)
    return base


def test_schema_locks_ids_and_member_enum():
    s = build_cluster_schema(KEYS)
    c = s["properties"]["clusters"]["items"]["properties"]
    assert c["cluster_id"]["enum"] == [
        f"CL{i}" for i in range(1, MAX_CLUSTERS + 1)]
    assert c["member_shot_keys"]["items"]["enum"] == KEYS
    assert None in c["parent_cluster_id"]["enum"]


def test_validate_passes_clean():
    assert validate_cluster_plan(_plan(), KEYS) == []


def test_validate_rejects_missing_or_duplicate_member():
    p = _plan()
    p["clusters"][1]["member_shot_keys"] = []  # S5_Shot1 누락
    out = validate_cluster_plan(p, KEYS)
    assert any("누락" in v for v in out)
    p2 = _plan()
    p2["clusters"][1]["member_shot_keys"] = ["S3_Shot1", "S5_Shot1"]  # 중복 소속
    assert any("중복" in v for v in validate_cluster_plan(p2, KEYS))


def test_validate_rejects_bad_root_count_and_cycle():
    p = _plan()
    p["clusters"][0]["parent_cluster_id"] = "CL2"  # root 0 + 순환
    out = validate_cluster_plan(p, KEYS)
    assert any("root" in v for v in out)
    p2 = _plan()
    p2["clusters"][1]["parent_cluster_id"] = None  # root 2개
    p2["clusters"][1]["bg_prompt_en"] = ""
    assert any("root" in v for v in validate_cluster_plan(p2, KEYS))
    p3 = _plan()
    p3["clusters"][1]["parent_cluster_id"] = "CL2"  # 자기 참조
    assert any("자기" in v or "순환" in v
               for v in validate_cluster_plan(p3, KEYS))


def test_validate_rejects_unknown_parent_and_dup_id():
    p = _plan()
    p["clusters"][1]["parent_cluster_id"] = "CL9"
    assert any("parent" in v for v in validate_cluster_plan(p, KEYS))
    p2 = _plan()
    p2["clusters"][1]["cluster_id"] = "CL1"
    assert any("중복" in v for v in validate_cluster_plan(p2, KEYS))


def test_validate_bg_prompt_rules():
    p = _plan()
    p["clusters"][1]["bg_prompt_en"] = ""       # 비-root 인데 없음
    assert any("bg_prompt" in v for v in validate_cluster_plan(p, KEYS))
    p2 = _plan()
    p2["clusters"][0]["bg_prompt_en"] = "should not be here"  # root 인데 있음
    assert any("root" in v and "bg_prompt" in v
               for v in validate_cluster_plan(p2, KEYS))


def test_validate_rejects_malformed_root_and_types():
    assert validate_cluster_plan([], KEYS) != []          # root 가 dict 아님
    p = _plan()
    p["clusters"] = [None, "x"]
    out = validate_cluster_plan(p, KEYS)
    assert out and all(isinstance(v, str) for v in out)
    p2 = _plan()
    p2["clusters"][0]["stable_visible_features_en"] = []
    assert any("features" in v for v in validate_cluster_plan(p2, KEYS))


def test_validate_malformed_field_types_return_violations_not_exceptions():
    """Codex Stage C BLOCKING 실증 — 예외 없이 위반 리스트 (fail-closed)."""
    p = _plan()
    p["clusters"][1]["bg_prompt_en"] = {"bad": True}   # dict → strip 크래시였음
    out = validate_cluster_plan(p, KEYS)
    assert out and all(isinstance(v, str) for v in out)
    p2 = _plan()
    p2["clusters"][1]["parent_cluster_id"] = []        # unhashable 크래시였음
    out2 = validate_cluster_plan(p2, KEYS)
    assert out2 and all(isinstance(v, str) for v in out2)
    p3 = _plan()
    p3["clusters"][0]["spatial_scope_en"] = 7          # required text 타입
    assert any("spatial_scope_en" in v
               for v in validate_cluster_plan(p3, KEYS))
    p4 = _plan()
    p4["clusters"][0]["rationale_ko"] = ""             # required text 필수
    assert any("rationale_ko" in v for v in validate_cluster_plan(p4, KEYS))
    p5 = _plan()
    p5["clusters"][0]["stable_visible_features_en"] = ["ok", 3, "  "]
    assert any("features" in v for v in validate_cluster_plan(p5, KEYS))
    p6 = _plan()
    p6["clusters"][1]["member_shot_keys"] = "S5_Shot1"  # str = 배열 아님
    out6 = validate_cluster_plan(p6, KEYS)
    assert out6 and all(isinstance(v, str) for v in out6)


def test_validate_combo_malformed_cid_and_parent_no_exception():
    """재리뷰 실증 — cluster_id=list + parent=list 조합도 예외 없음."""
    p = _plan()
    p["clusters"][1]["cluster_id"] = ["bad"]
    p["clusters"][1]["parent_cluster_id"] = []
    out = validate_cluster_plan(p, KEYS)
    assert out and all(isinstance(v, str) for v in out)


def test_validate_rejects_root_bg_none_missing_and_parent_key_missing():
    """required 키 누락=위반 — root 도 명시적 빈 문자열/명시적 null 만."""
    p = _plan()
    p["clusters"][0]["bg_prompt_en"] = None
    assert any("bg_prompt" in v for v in validate_cluster_plan(p, KEYS))
    p2 = _plan()
    del p2["clusters"][0]["bg_prompt_en"]
    assert any("bg_prompt" in v for v in validate_cluster_plan(p2, KEYS))
    p3 = _plan()
    del p3["clusters"][0]["parent_cluster_id"]
    assert any("parent" in v and "누락" in v
               for v in validate_cluster_plan(p3, KEYS))


def test_run_missing_input_includes_nonstring_scene_text():
    """씬 원문이 비문자면 .strip() 예외 아닌 missing_input (재리뷰 MINOR)."""
    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_view_cluster import (
        run_view_cluster_plan_group,
    )

    def fake(step, system, user, schema, **kw):
        raise AssertionError("결손 시 LLM 호출되면 안 됨")

    kw = _run_kwargs(fake)
    kw["scene_texts"] = {3: "그가 현관 앞에 선다.", 5: {"bad": 1}}
    with pytest.raises(AppError) as ei:
        run_view_cluster_plan_group(**kw)
    assert ei.value.code == "step.missing_input.outdoor_view_cluster"


def test_run_fails_fast_on_missing_upstream_inputs():
    """Codex Stage C HIGH — 씬 원문/확정 플레이트 결손=조용한 성공 금지."""
    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_view_cluster import (
        run_view_cluster_plan_group,
    )

    def fake(step, system, user, schema, **kw):
        raise AssertionError("upstream 결손 시 LLM 호출되면 안 됨")

    # (a) structure_shots 비어 있음
    kw = _run_kwargs(fake)
    kw["structure_shots"] = []
    with pytest.raises(AppError) as e1:
        run_view_cluster_plan_group(**kw)
    assert e1.value.code == "step.missing_input.outdoor_view_cluster"
    # (b) 씬 원문 결손 (scene 5 텍스트 없음)
    kw2 = _run_kwargs(fake)
    kw2["scene_texts"] = {3: "그가 현관 앞에 선다.", 5: "  "}
    with pytest.raises(AppError) as e2:
        run_view_cluster_plan_group(**kw2)
    assert e2.value.code == "step.missing_input.outdoor_view_cluster"
    # (c) 확정 플레이트 0장
    kw3 = _run_kwargs(fake)
    kw3["plate_images"] = []
    with pytest.raises(AppError) as e3:
        run_view_cluster_plan_group(**kw3)
    assert e3.value.code == "step.missing_input.outdoor_view_cluster"


def test_bg_refs_reject_missing_root_or_labels():
    """Codex Stage C HIGH — root seed 결손이 조용히 통과 금지."""
    from app.modules.pipeline.outdoor_view_cluster import (
        build_cluster_bg_refs,
    )
    with pytest.raises(ValueError):
        build_cluster_bg_refs(
            root_plate=None, parent_bg=None,
            root_label="ROOT", parent_label="PARENT")
    with pytest.raises(ValueError):
        build_cluster_bg_refs(
            root_plate=b"png", parent_bg=None,
            root_label="  ", parent_label="PARENT")
    with pytest.raises(ValueError):
        build_cluster_bg_refs(
            root_plate=b"png", parent_bg=b"png2",
            root_label="ROOT", parent_label="")


def _spec():
    return {
        "zone_labels_en": ["Structure Front"],
        "items": [{"code": "P1", "kind": "door",
                   "name_en": "steel entry door",
                   "placement_en": "center of the facade"}],
    }


def _shots():
    return [
        {"scene_index": 3, "shot_index": 1,
         "description": "현관 앞에 선 인물"},
        {"scene_index": 3, "shot_index": 2,
         "description": "골목에서 본 건물 측면"},
        {"scene_index": 5, "shot_index": 1,
         "description": "지붕 위 전경"},
    ]


def _run_kwargs(fake_fn):
    return dict(
        spec=_spec(),
        structure_shots=_shots(),
        scene_texts={3: "그가 현관 앞에 선다.", 5: "지붕 위로 올라간다."},
        plate_images=[("CONFIRMED PLATE — front", b"\x89PNG-fake")],
        call_structured_fn=fake_fn,
    )


def _llm_plan():
    p = _plan()
    p["clusters"][0]["member_shot_keys"] = ["S3_Shot1", "S3_Shot2"]
    p["clusters"][1]["member_shot_keys"] = ["S5_Shot1"]
    return p


def test_run_ok_returns_plan_with_order():
    captured = {}

    def fake(step, system, user, schema, **kw):
        captured["user"] = user
        return _llm_plan()

    from app.modules.pipeline.outdoor_view_cluster import (
        run_view_cluster_plan_group,
    )
    out = run_view_cluster_plan_group(**_run_kwargs(fake))
    assert out["attempts"] == 1
    assert [c["cluster_id"] for c in out["plan"]["clusters"]] == [
        "CL1", "CL2"]
    texts = [p.get("text", "") for p in captured["user"]
             if p.get("type") == "text"]
    joined = "\n".join(texts)
    assert "그가 현관 앞에 선다." in joined          # 씬 전문
    assert "현관 앞에 선 인물" in joined              # 샷 블록
    assert any(p.get("type") == "image_url" for p in captured["user"])


def test_run_retries_on_violation_then_ok():
    bad = _llm_plan()
    bad["clusters"][1]["member_shot_keys"] = []  # S5_Shot1 누락
    responses = [bad, _llm_plan()]
    calls = []

    def fake(step, system, user, schema, **kw):
        calls.append(user)
        return responses[len(calls) - 1]

    from app.modules.pipeline.outdoor_view_cluster import (
        run_view_cluster_plan_group,
    )
    out = run_view_cluster_plan_group(**_run_kwargs(fake))
    assert out["attempts"] == 2
    assert "재시도" in calls[1][-1]["text"]


def test_run_exhausts_raises_app_error():
    bad = _llm_plan()
    bad["clusters"][0]["parent_cluster_id"] = "CL2"  # root 0

    def fake(step, system, user, schema, **kw):
        return bad

    from app.core.errors import AppError
    from app.modules.pipeline.outdoor_view_cluster import (
        run_view_cluster_plan_group,
    )
    with pytest.raises(AppError) as ei:
        run_view_cluster_plan_group(**_run_kwargs(fake), max_attempts=2)
    assert ei.value.code == "step.contract_violation.outdoor_view_cluster"


def test_generation_order_topo_and_deterministic():
    p = {"clusters": [
        _cluster("CL3", ["S5_Shot1"], "CL1", "side view"),
        _cluster("CL1", ["S3_Shot1"], None, ""),
        _cluster("CL2", ["S3_Shot2"], "CL1", "rear view"),
    ]}
    assert generation_order(p) == ["CL1", "CL2", "CL3"]
    assert generation_order(p) == generation_order(p)  # 결정론


def test_bg_prompt_assembles_contracts_and_rejects_root():
    from app.modules.pipeline.outdoor_view_cluster import (
        build_cluster_bg_prompt,
    )
    cl = _cluster("CL2", ["S5_Shot1"], "CL1",
                  "the same structure seen from the side alley")
    p = build_cluster_bg_prompt(
        cluster=cl, place_text="EXT. SAMPLE 주택 - 낮",
        world_anchor=" — contemporary SAMPLE region, 2026",
        seed_view_en="high oblique overview of the full roof level",
        scale_note_en="a living room, two bedrooms and a bathroom",
    )
    assert "the same structure seen from the side alley" in p
    assert "EXT. SAMPLE 주택 - 낮" in p
    assert "contemporary SAMPLE region" in p
    assert "CONFIRMED STRUCTURE PLATE" in p    # seed_clause
    assert "no people" in p                    # no_people
    assert "brick facade" in p                 # stable features 주입
    # v3 (육안 피드백+재리뷰): 씨드 시점 설명 + target dedup(일반화) + 규모 SOT
    assert "high oblique overview of the full roof level" in p
    assert "TARGET STRUCTURE" in p
    assert "exactly ONE instance" in p
    assert "a living room, two bedrooms and a bathroom" in p
    assert "SCALE SPEC" in p
    assert "rooftop" not in p.lower().replace(
        "high oblique overview of the full roof level", "")  # 중립(입력 제외)
    root = _cluster("CL1", ["S3_Shot1"], None, "")
    with pytest.raises(ValueError):
        build_cluster_bg_prompt(
            cluster=root, place_text="x", world_anchor="",
            seed_view_en="v", scale_note_en="")


def test_root_plate_prompt_rephotograph_contract():
    """root plate 교정 생성 — 씨드 look 유지+SCALE SPEC 이 규모 SOT."""
    from app.modules.pipeline.outdoor_view_cluster import (
        build_root_plate_prompt,
    )
    root = _cluster("CL1", ["S3_Shot1"], None, "")
    p = build_root_plate_prompt(
        cluster=root, place_text="EXT. SAMPLE 주택 - 낮",
        world_anchor=" — contemporary SAMPLE region, 2026",
        scale_note_en="a living room, two bedrooms and a bathroom",
    )
    assert "RE-PHOTOGRAPH" in p
    assert p.count("SCALE SPEC") == 1          # 정확히 1회 (재리뷰 H2)
    assert "a living room, two bedrooms and a bathroom" in p
    assert "front yard and facade" in p        # root scope 주입
    assert "brick facade" in p                 # features
    assert "no people" in p
    nonroot = _cluster("CL2", ["S5_Shot1"], "CL1", "side view")
    with pytest.raises(ValueError):
        build_root_plate_prompt(
            cluster=nonroot, place_text="x", world_anchor="",
            scale_note_en="")


def test_root_plate_prompt_no_dangling_scale_without_note():
    """scale_note 없으면 SCALE SPEC/INTERIOR 언급 0 (재리뷰 H2 —
    dangling 참조가 임의 규모·내부 발명 유도)."""
    from app.modules.pipeline.outdoor_view_cluster import (
        build_root_plate_prompt,
    )
    root = _cluster("CL1", ["S3_Shot1"], None, "")
    p = build_root_plate_prompt(
        cluster=root, place_text="EXT. SAMPLE 주택 - 낮",
        world_anchor="", scale_note_en="",
    )
    assert "SCALE SPEC" not in p
    assert "INTERIOR" not in p
    assert "interior" not in p
    assert "RE-PHOTOGRAPH" in p


def test_bg_prompt_selector_backward_compat():
    """명시적 v1/v2 selector 실행 계약 (재리뷰 NARROW_1 —
    FileNotFoundError 회귀 잠금). v1 child=원 조립(seed_view/dedup/
    scale 절 없음), v2=single_structure, v3 default=target_structure."""
    from app.modules.pipeline.outdoor_view_cluster import (
        build_cluster_bg_prompt,
        build_root_plate_prompt,
    )
    cl = _cluster("CL2", ["S5_Shot1"], "CL1", "side alley view")
    root = _cluster("CL1", ["S3_Shot1"], None, "")
    # v1 child — f5441f2a 이전 호출 시그니처 그대로(seed_view/scale
    # 인자 생략) 성공, 신규 절 없음 (재리뷰: 시그니처도 원 계약)
    p1 = build_cluster_bg_prompt(
        cluster=cl, place_text="EXT. SAMPLE", world_anchor="",
        prompt_version="1")
    assert "CONFIRMED STRUCTURE PLATE" in p1
    assert "SEED VIEWPOINT" not in p1
    assert "TARGET STRUCTURE" not in p1
    assert "SCALE SPEC" not in p1
    # v2/v3 에서 seed_view 결손은 여전히 ValueError (계약 유지)
    with pytest.raises(ValueError):
        build_cluster_bg_prompt(
            cluster=cl, place_text="EXT. SAMPLE", world_anchor="",
            prompt_version="3")
    # v2 child/root — single_structure 로 성공
    p2 = build_cluster_bg_prompt(
        cluster=cl, place_text="EXT. SAMPLE", world_anchor="",
        seed_view_en="overview", scale_note_en="", prompt_version="2")
    assert "EXACTLY ONE STRUCTURE" in p2
    pr2 = build_root_plate_prompt(
        cluster=root, place_text="EXT. SAMPLE", world_anchor="",
        scale_note_en="", prompt_version="2")
    assert "RE-PHOTOGRAPH" in pr2
    assert "EXACTLY ONE STRUCTURE" in pr2
    # v3 default — target_structure (기존 테스트와 정합)
    p3 = build_cluster_bg_prompt(
        cluster=cl, place_text="EXT. SAMPLE", world_anchor="",
        seed_view_en="overview", scale_note_en="")
    assert "TARGET STRUCTURE" in p3
    # v1 root — 원래 미존재 경로: FileNotFound 아닌 명시 ValueError
    with pytest.raises(ValueError):
        build_root_plate_prompt(
            cluster=root, place_text="x", world_anchor="",
            scale_note_en="", prompt_version="1")


def test_bg_packs_are_scenario_neutral():
    """v3 팩 파일에 구조물 종류 하드코딩 0 (재리뷰 BLOCKING —
    'rooftop' 등 canary 증상 어휘가 공용 팩을 오염 금지)."""
    from pathlib import Path

    from app.modules.pipeline.outdoor_view_cluster import (
        BG_PACK_VERSION_MAP,
        PROMPT_VERSION_MAP,
    )
    repo = Path(__file__).resolve().parents[3]
    for module, ver in (
        ("cluster_bg", BG_PACK_VERSION_MAP["3"]),
        ("outdoor_view_cluster", PROMPT_VERSION_MAP["3"]),
    ):
        for f in (repo / "prompts" / "_base" / module / ver).glob("*.md"):
            text = f.read_text(encoding="utf-8").lower()
            for word in ("rooftop", "compact", "tiny", "small hut"):
                assert word not in text, f"{f.name} 에 {word!r} 하드코딩"


def test_run_passes_interior_block_to_prompt():
    """INTERIOR SPEC(규모 SOT)이 user 프롬프트에 실림 (육안 피드백 v2)."""
    captured = {}

    def fake(step, system, user, schema, **kw):
        captured["user"] = user
        return _llm_plan()

    from app.modules.pipeline.outdoor_view_cluster import (
        run_view_cluster_plan_group,
    )
    kw = _run_kwargs(fake)
    kw["interior_block"] = "- 거실, 안방, 딸의 방, 욕실로 구성된 실내"
    run_view_cluster_plan_group(**kw)
    texts = "\n".join(p.get("text", "") for p in captured["user"]
                      if p.get("type") == "text")
    assert "거실, 안방, 딸의 방, 욕실" in texts


def test_bg_refs_order_and_optional_parent():
    from pathlib import Path

    from app.modules.pipeline.outdoor_view_cluster import (
        build_cluster_bg_refs,
    )
    refs = build_cluster_bg_refs(
        root_plate=Path("/tmp/SAMPLE_root.png"), parent_bg=None,
        root_label="CONFIRMED PLATE", parent_label="ADJACENT VIEW",
    )
    assert [r[0] for r in refs] == ["CONFIRMED PLATE"]
    refs2 = build_cluster_bg_refs(
        root_plate=b"png", parent_bg=b"png2",
        root_label="CONFIRMED PLATE", parent_label="ADJACENT VIEW",
    )
    assert [r[0] for r in refs2] == ["CONFIRMED PLATE", "ADJACENT VIEW"]
