"""outdoor_place_canon 모듈 결정론 테스트 (W22 ②).

이미지/VLM 완성도는 검증하지 않는다 — 프롬프트 조립/ID-free 봉인/판정 합산/
결함 concat/체인 분기(fix 생략)만. fixture 전부 시나리오 중립 SAMPLE.
"""

import pytest

from app.modules.pipeline.outdoor_place_canon import (
    CANDIDATE_LABELS,
    CanonError,
    PROMPT_VERSION_MAP,
    aggregate_judgement,
    build_elements_block,
    build_fix_prompt,
    build_judge_user,
    build_map_prompt,
    build_markers_block,
    build_photo_prompt,
    collect_issues,
    resolve_prompt_version,
    run_outdoor_place_canon_group,
)


def _spec():
    return {
        "layout_narration_en": (
            "A walled front yard sits before the main structure; a side "
            "path leads to the rear service area."
        ),
        "zone_labels_en": ["Front Yard", "Rear Path"],
        "items": [
            {"code": "P1", "kind": "gate", "name_en": "front entry gate",
             "placement_en": "set into the south wall of the yard",
             "inferred": False,
             "evidence": {"scene_index": 3, "quote_ko": "대문"}},
            {"code": "P2", "kind": "yard", "name_en": "walled front yard",
             "placement_en": "enclosed area inside the entry gate",
             "inferred": False,
             "evidence": {"scene_index": 3, "quote_ko": "마당"}},
            {"code": "Q1", "kind": "approach", "name_en": "rear service path",
             "placement_en": "narrow path along the west side",
             "inferred": True, "evidence": None},
        ],
    }


# ── selector / 블록 조립 ─────────────────────────────────────────────


def test_resolve_prompt_version():
    assert resolve_prompt_version("1") == PROMPT_VERSION_MAP["1"]
    with pytest.raises(ValueError):
        resolve_prompt_version("99")


def test_elements_block_is_id_free():
    block = build_elements_block(_spec())
    assert "front entry gate" in block
    for code in ("P1", "P2", "Q1"):
        assert code not in block


def test_markers_block_contains_codes():
    block = build_markers_block(_spec())
    assert "(P1)" in block and "(Q1)" in block


def test_photo_prompt_assembles_and_seals_id_free():
    template = (
        "{layout_narration}|{elements_block}|{scale_block}|{world_facts_block}"
    )
    prompt = build_photo_prompt(template, _spec(), "three storeys", "steel stair")
    assert "walled front yard" in prompt
    assert "three storeys" in prompt and "steel stair" in prompt
    assert "P1" not in prompt


def test_photo_prompt_raises_on_code_leak():
    spec = _spec()
    spec["layout_narration_en"] += " near P1."  # 코드 누출 시뮬레이션
    with pytest.raises(CanonError) as ei:
        build_photo_prompt("{layout_narration}|{elements_block}", spec)
    assert "id_free_violation" in ei.value.code


def test_photo_prompt_code_check_uses_word_boundary():
    """'P10' 같은 다른 토큰 속 부분 일치는 false-positive 아님 (Codex MINOR)."""
    spec = _spec()
    spec["layout_narration_en"] += " The lot is numbered P10 in records."
    prompt = build_photo_prompt("{layout_narration}|{elements_block}", spec)
    assert "P10" in prompt  # P1 코드 누출로 오인하지 않음


def test_photo_prompt_empty_blocks_get_neutral_fallback():
    prompt = build_photo_prompt("{scale_block}|{world_facts_block}", _spec())
    assert "plausible" in prompt and "(none)" in prompt


def test_map_prompt_has_markers_zones_and_style_toggle():
    template = "{markers_block}|{zone_labels_block}|{style_ref_note}"
    with_style = build_map_prompt(template, _spec(), style_ref_attached=True)
    assert "(P1)" in with_style and "- Front Yard" in with_style
    assert "floor-plan drawing" in with_style
    without = build_map_prompt(template, _spec(), style_ref_attached=False)
    assert "floor-plan drawing" not in without


def test_fix_prompt_bullets_skip_empty():
    prompt = build_fix_prompt(
        "{issues_block}",
        [{"issue_ko": "a", "fix_en": "Remove the extra door."},
         {"issue_ko": "b", "fix_en": "  "}],
    )
    assert prompt == "- Remove the extra door."


# ── 판정 합산 (s29 계약) ─────────────────────────────────────────────


def _judge(scores, ranking):
    return {
        "winner": ranking[0],
        "ranking": ranking,
        "verdicts": [
            {"label": lab, "score": s, "verdict_ko": "판정"}
            for lab, s in zip(CANDIDATE_LABELS, scores)
        ],
    }


def test_aggregate_sum_winner():
    agg = aggregate_judgement({
        "gpt": _judge([8, 5, 3], ["A", "B", "C"]),
        "gemini-pro": _judge([7, 6, 2], ["A", "B", "C"]),
    })
    assert agg["winner"] == "A"
    assert agg["totals"] == {"A": 15, "B": 11, "C": 5}


def test_aggregate_tie_prefers_gemini_ranking():
    # A/B 동점(12) — gemini ranking 이 B 우선이면 B 선택
    agg = aggregate_judgement({
        "gpt": _judge([7, 5, 1], ["A", "B", "C"]),
        "gemini-pro": _judge([5, 7, 2], ["B", "A", "C"]),
    })
    assert agg["totals"]["A"] == agg["totals"]["B"] == 12
    assert agg["winner"] == "B"


def test_collect_issues_concat():
    issues = collect_issues({
        "gpt": {"issues": [{"issue_ko": "g1", "fix_en": "f1"}]},
        "gemini-pro": {"issues": [{"issue_ko": "m1", "fix_en": "f2"}]},
    })
    assert [i["issue_ko"] for i in issues] == ["m1", "g1"]  # 모델 키 정렬 순
    assert collect_issues({"gpt": {"issues": []}, "gemini-pro": {"issues": []}}) == []


def test_build_judge_user_pairs_labels_and_images():
    parts = build_judge_user("PROMPT", [("A", b"a"), ("B", b"b")])
    texts = [p["text"] for p in parts if p["type"] == "text"]
    assert texts[0].startswith("GENERATION PROMPT:")
    assert "Candidate A:" in texts and "Candidate B:" in texts
    assert sum(1 for p in parts if p["type"] == "image_url") == 2


# ── 그룹 체인 (fakes) ────────────────────────────────────────────────


def _make_fakes(issues_gpt=None, winner_scores=(9, 5, 3)):
    """판정=A 승리 기본. call 순서: judge gpt→gemini, critique gpt→gemini."""
    nb2_calls = []
    edit_calls = []

    def nb2(prompt, labeled_references=None, aspect_ratio="1:1"):
        nb2_calls.append({"prompt": prompt, "refs": labeled_references})
        if labeled_references:
            return b"FIXED"
        return f"CAND{len(nb2_calls)}".encode()

    def gpt_edit(prompt, ref_paths):
        edit_calls.append({"prompt": prompt, "refs": list(ref_paths)})
        return b"MAP"

    def call_structured(step, system, user, schema, *, project_config=None, **kw):
        model = (project_config or {}).get(step, {}).get("model", "")
        if "judge" in step:
            return _judge(list(winner_scores), ["A", "B", "C"])
        if model == "gpt":
            return {"issues": issues_gpt or []}
        return {"issues": []}

    return nb2, gpt_edit, call_structured, nb2_calls, edit_calls


def test_chain_happy_path_no_issues_skips_fix(tmp_path):
    nb2, gpt_edit, cs, nb2_calls, edit_calls = _make_fakes()
    out = run_outdoor_place_canon_group(
        group_id="sample_site", spec=_spec(),
        scale_block="", world_facts_block="",
        out_dir=tmp_path, nb2_generate_fn=nb2, gpt_edit_fn=gpt_edit,
        call_structured_fn=cs,
    )
    assert out["status"] == "ok"
    assert out["selected_candidate"] == "A"
    assert out["fix_applied"] is False and out["issues_count"] == 0
    # 후보 3롤 + fix 0 = nb2 3콜
    assert len(nb2_calls) == 3 and all(c["refs"] is None for c in nb2_calls)
    # 마스터 = 승자 A 원본 (수정 생략)
    assert (tmp_path / "canon_sample_site_master.png").read_bytes() == b"CAND1"
    # 맵 edit 참조 = [마스터] (스타일 FP 없음)
    assert edit_calls[0]["refs"] == [tmp_path / "canon_sample_site_master.png"]
    assert (tmp_path / "canon_sample_site_map.png").read_bytes() == b"MAP"


def test_chain_with_issues_applies_fix_and_style_ref(tmp_path):
    style_fp = tmp_path / "style_fp.png"
    style_fp.write_bytes(b"FP")
    nb2, gpt_edit, cs, nb2_calls, edit_calls = _make_fakes(
        issues_gpt=[{"issue_ko": "결함", "fix_en": "Remove the extra door."}]
    )
    out = run_outdoor_place_canon_group(
        group_id="g2", spec=_spec(),
        scale_block="", world_facts_block="",
        out_dir=tmp_path, nb2_generate_fn=nb2, gpt_edit_fn=gpt_edit,
        style_fp_path=style_fp, call_structured_fn=cs,
    )
    assert out["fix_applied"] is True and out["issues_count"] == 1
    # 4번째 nb2 콜 = i2i 수정 (labeled ref 1장 = 승자 사진)
    assert len(nb2_calls) == 4
    fix_call = nb2_calls[3]
    assert fix_call["refs"] is not None and len(fix_call["refs"]) == 1
    assert "Remove the extra door." in fix_call["prompt"]
    assert (tmp_path / "canon_g2_master.png").read_bytes() == b"FIXED"
    # 맵 참조 = [마스터, 스타일 FP]
    assert edit_calls[0]["refs"] == [tmp_path / "canon_g2_master.png", style_fp]
    assert out["style_fp_attached"] is True


def test_chain_captures_candidates(tmp_path):
    nb2, gpt_edit, cs, _, _ = _make_fakes()
    captured = []

    def capture(png, **kw):
        captured.append(kw)

    run_outdoor_place_canon_group(
        group_id="g3", spec=_spec(),
        scale_block="", world_facts_block="",
        out_dir=tmp_path, nb2_generate_fn=nb2, gpt_edit_fn=gpt_edit,
        call_structured_fn=cs, capture_fn=capture,
    )
    assert [c["candidate_index"] for c in captured] == [0, 1, 2]
    assert all(c["role"] == "outdoor_canon_candidate" for c in captured)


def test_chain_roll_failure_raises_canon_error(tmp_path):
    def nb2_fail(prompt, labeled_references=None, aspect_ratio="1:1"):
        raise RuntimeError("SAMPLE 생성 실패")

    _, gpt_edit, cs, _, _ = _make_fakes()
    with pytest.raises(CanonError) as ei:
        run_outdoor_place_canon_group(
            group_id="g4", spec=_spec(),
            scale_block="", world_facts_block="",
            out_dir=tmp_path, nb2_generate_fn=nb2_fail, gpt_edit_fn=gpt_edit,
            call_structured_fn=cs,
        )
    assert ei.value.code == "step.outdoor_place_canon.roll"
