"""검색 그라운딩 참조 v3 — 결정론 계약 유닛 (2026-07-30).

Codex 리뷰 #8("신규 심볼 테스트 0건") 대응. 여기서 다루는 것은 **수학적으로
결과가 보장되는 영역만**이다 — 합산 산술 / 해시 경계 / 팩 버전 해석.
검색·판정의 *완성도*(어떤 사진이 좋은 참조인가)는 유닛으로 증명할 수 없고
실험 + 육안으로만 올라간다.

대상 심볼:
    - ``combine_pick_verdicts`` — 이중 판정 평균 합산
    - ``search_contract_sha``   — 계약 바이트 해시와 예외 경계
    - ``resolve_ref_pack_version`` — 미배선 팩 명시 실패
    - ``PICK_JUDGES`` / ``TARGET_POLICY_VERSION`` — 계약 상수
"""
from __future__ import annotations

import pytest

from app.modules.pipeline.search_grounded_ref import (
    PICK_JUDGES,
    TARGET_POLICY_VERSION,
    combine_pick_verdicts,
    resolve_ref_pack_version,
    search_contract_sha,
)


def _verdict(*items) -> dict:
    """(index, usable, score) 튜플들을 pick 스키마 산출로."""
    return {
        "verdicts": [
            {"index": i, "usable": u, "score": s, "reason_ko": "사유"}
            for (i, u, s) in items
        ],
        "chosen_index": 0,
        "chosen_reason_ko": "심판 자체 선택(합산이 대체한다)",
    }


# ─────────────────────────────────────────────────────────────────────
# 1. 이중 판정 합산 — 평균이 높은 쪽
# ─────────────────────────────────────────────────────────────────────
def test_picks_candidate_with_higher_average_not_higher_single_score():
    """한 심판만 보면 2번이 최고지만 평균은 1번이 높다 → 1번.

    사용자 확정 계약: "둘다 보고 **평균 높은 쪽**으로".
    """
    per_judge = {
        "gemini-pro": _verdict((1, True, 80), (2, True, 60)),
        "gpt": _verdict((1, True, 60), (2, True, 75)),
    }
    out = combine_pick_verdicts(per_judge, candidate_count=2)
    assert out["averages"] == {"1": 70.0, "2": 67.5}
    assert out["chosen_index"] == 1
    assert out["single_judge"] is False
    assert out["judges_used"] == ["gemini-pro", "gpt"]


def test_usable_false_is_forced_to_zero_even_with_high_score():
    """usable=false 인데 점수를 후하게 준 심판이 선택을 끌어가지 못한다.

    1번: 0(강제) + 100 → 평균 50. 2번: 60 + 60 → 평균 60. → 2번.
    """
    per_judge = {
        "gemini-pro": _verdict((1, False, 95), (2, True, 60)),
        "gpt": _verdict((1, True, 100), (2, True, 60)),
    }
    out = combine_pick_verdicts(per_judge, candidate_count=2)
    assert out["averages"] == {"1": 50.0, "2": 60.0}
    assert out["chosen_index"] == 2
    # 강제된 0 이 심판별 상세에도 그대로 드러나야 감사에서 추적된다.
    assert out["per_candidate"]["1"]["gemini-pro"]["score"] == 0.0
    assert out["per_candidate"]["1"]["gemini-pro"]["usable"] is False


def test_all_unusable_yields_no_choice_fail_closed():
    """두 심판 모두 쓸 수 없다고 하면 선택 없음 — 억지로 고르지 않는다."""
    per_judge = {
        "gemini-pro": _verdict((1, False, 0), (2, False, 0)),
        "gpt": _verdict((1, False, 40), (2, False, 30)),
    }
    out = combine_pick_verdicts(per_judge, candidate_count=2)
    assert out["chosen_index"] == 0
    assert out["averages"] == {"1": 0.0, "2": 0.0}


def test_single_surviving_judge_proceeds_but_is_disclosed():
    """한 심판이 죽어도 진행하되 ``single_judge`` 로 드러낸다(조용한 격하 금지)."""
    out = combine_pick_verdicts(
        {"gpt": _verdict((1, True, 55), (2, True, 70))}, candidate_count=2)
    assert out["chosen_index"] == 2
    assert out["single_judge"] is True
    assert out["judges_used"] == ["gpt"]


def test_tie_prefers_lower_index():
    """동점이면 낮은 index(=검색 상위)를 쓴다 — 결정론 고정."""
    per_judge = {
        "gemini-pro": _verdict((1, True, 70), (2, True, 70)),
        "gpt": _verdict((1, True, 70), (2, True, 70)),
    }
    assert combine_pick_verdicts(
        per_judge, candidate_count=2)["chosen_index"] == 1


def test_a_judge_with_any_malformed_index_is_excluded_entirely():
    """★계약 변경 (A6) — 개별 verdict 만 버리던 것을 **심판 단위 제외**로.

    이전 계약은 후보 수 밖 index·비정수 index 를 **그 항목만** 버리고 나머지는
    합산에 넣었다. 그러면 후보마다 심판 수가 달라져 **분모가 다른 평균**을
    비교하게 된다 — 어떤 후보는 2명 평균, 어떤 후보는 1명 평균이다.
    게다가 index 를 틀리게 낸 심판의 나머지 판정을 신뢰할 근거도 없다.

    새 계약: 심판 응답은 **원자 단위**다. indices 가 1..N 정확한 순열이 아니면
    (범위 밖·비정수·중복·누락 무엇이든) **그 심판 전체를 제외**한다.
    """
    per_judge = {
        "gemini-pro": {
            "verdicts": [
                {"index": 1, "usable": True, "score": 99, "reason_ko": "x"},
                {"index": 3, "usable": True, "score": 99, "reason_ko": "x"},
            ],
        },
        "gpt": {
            "verdicts": [
                {"index": 1, "usable": True, "score": 10, "reason_ko": "x"},
                {"index": 2, "usable": True, "score": 40, "reason_ko": "x"},
            ],
        },
    }
    out = combine_pick_verdicts(per_judge, candidate_count=2)
    assert out["judges_used"] == ["gpt"], "index 를 틀린 심판은 통째로 빠진다"
    assert out["rejected_judges"] and "gemini-pro" in out["rejected_judges"]
    assert out["averages"] == {"1": 10.0, "2": 40.0}
    assert out["chosen_index"] == 2
    assert out["single_judge"] is True


def test_duplicate_index_excludes_the_judge():
    per_judge = {"gpt": {"verdicts": [
        {"index": 1, "usable": True, "score": 50, "reason_ko": "x"},
        {"index": 1, "usable": True, "score": 90, "reason_ko": "x"},
    ]}}
    out = combine_pick_verdicts(per_judge, candidate_count=2)
    assert out["judges_used"] == []
    assert out["chosen_index"] == 0


def test_missing_index_excludes_the_judge():
    """후보 하나를 빠뜨린 심판도 제외 — 분모가 달라지는 원인이다."""
    per_judge = {"gpt": {"verdicts": [
        {"index": 1, "usable": True, "score": 50, "reason_ko": "x"},
    ]}}
    out = combine_pick_verdicts(per_judge, candidate_count=2)
    assert out["judges_used"] == []
    assert out["chosen_index"] == 0


def test_every_candidate_shares_the_same_denominator():
    """살아남은 심판은 **모든 후보**에 같은 수로 기여한다."""
    per_judge = {
        "gemini-pro": {"verdicts": [
            {"index": 1, "usable": True, "score": 80, "reason_ko": "x"},
            {"index": 2, "usable": True, "score": 20, "reason_ko": "x"}]},
        "gpt": {"verdicts": [
            {"index": 1, "usable": True, "score": 60, "reason_ko": "x"},
            {"index": 2, "usable": True, "score": 40, "reason_ko": "x"}]},
    }
    out = combine_pick_verdicts(per_judge, candidate_count=2)
    assert out["averages"] == {"1": 70.0, "2": 30.0}
    assert out["judge_count_per_candidate"] == 2


def test_zero_valid_judges_is_fail_closed():
    """★valid 0명이면 고르지 않는다 — 조용히 1번을 집지 않는다."""
    per_judge = {
        "gemini-pro": {"verdicts": [{"index": 9, "usable": True, "score": 99}]},
        "gpt": {"verdicts": []},
    }
    out = combine_pick_verdicts(per_judge, candidate_count=2)
    assert out["chosen_index"] == 0
    assert out["judges_used"] == []
    assert out["averages"] == {}
    assert set(out["rejected_judges"]) == {"gemini-pro", "gpt"}


def test_empty_or_missing_verdicts_do_not_crash():
    """심판이 빈 산출을 내도 예외 없이 '선택 없음' 으로 귀결한다."""
    out = combine_pick_verdicts(
        {"gemini-pro": {}, "gpt": {"verdicts": []}}, candidate_count=3)
    assert out["chosen_index"] == 0
    assert out["averages"] == {}
    assert out["single_judge"] is False


def test_score_is_clamped_to_0_100():
    """스키마 밖 점수가 와도 0~100 으로 잘린다(평균이 튀지 않는다)."""
    per_judge = {
        "gemini-pro": _verdict((1, True, 5000), (2, True, 100)),
        "gpt": _verdict((1, True, -50), (2, True, 100)),
    }
    out = combine_pick_verdicts(per_judge, candidate_count=2)
    assert out["averages"] == {"1": 50.0, "2": 100.0}
    assert out["chosen_index"] == 2


def test_judges_contract_is_gemini_and_gpt():
    """사용자 확정 심판 구성 — 순서까지 고정(감사 기록 안정성)."""
    assert PICK_JUDGES == ("gemini-pro", "gpt")


# ─────────────────────────────────────────────────────────────────────
# 2. 검색 계약 해시 — 무엇이 들어가고 무엇이 안 들어가는가
# ─────────────────────────────────────────────────────────────────────
def test_v2_and_v3_share_the_same_contract_sha():
    """v3 는 v2 의 검색·선택 파일을 바이트 그대로 승계했다.

    같아야 **팩 승격만으로 이미 잘 뽑아 둔 참조를 다시 받지 않는다**
    (그룹 재사용 근거). v3 에만 있는 ``scope_system`` 은 대상 집합 정책이지
    한 그룹의 검색·선택 결과에 기여하지 않으므로 해시 밖이다.
    """
    assert search_contract_sha("2") == search_contract_sha("3")


def test_v4_changes_the_contract_because_the_scale_changed():
    """v4 는 pick_system 에 점수 척도를 넣었다 — 선택 결과를 바꾸므로 해시도 다르다.

    실측(주유소 1그룹, 팩 v3): 척도 지시가 없어 Gemini 는 0~100, GPT 는
    사실상 0~10 을 썼고 평균이 Gemini 에 지배됐다. 계약이 바뀌었으니
    이전 선택을 재사용해서는 안 된다.
    """
    assert search_contract_sha("4") != search_contract_sha("3")


def test_active_pack_carries_the_dual_judge_scale_contract():
    """활성 팩은 척도 계약(v4)이 들어온 뒤 버전이어야 한다.

    ★버전 번호를 고정하지 않는다 — 팩은 계속 발행되고(v5=어휘 중립화),
    번호를 박으면 발행마다 깨진다. 계약이 유지되는지만 본다.
    """
    from app.modules.pipeline.search_grounded_ref import (
        REF_PACK_VERSION,
        REF_PACK_VERSION_MAP,
    )

    assert int(REF_PACK_VERSION) >= 4
    assert REF_PACK_VERSION in REF_PACK_VERSION_MAP


def test_pick_schema_score_field_states_the_scale():
    """척도는 팩 본문과 스키마 양쪽에 있어야 한다(둘 중 하나만 보는 심판 방지)."""
    from app.modules.pipeline.search_grounded_ref import build_pick_schema

    desc = (build_pick_schema()["properties"]["verdicts"]["items"]
            ["properties"]["score"].get("description") or "")
    assert "0-100" in desc
    assert "averaged" in desc.lower()


def test_v1_contract_differs_and_absorbs_its_missing_file():
    """v1 에는 ``narrow_retry_hint`` 자체가 없다 — 흡수하되 해시는 달라진다."""
    v1 = search_contract_sha("1")          # FileNotFoundError 를 흡수
    assert len(v1) == 16
    assert v1 != search_contract_sha("3")


def test_non_filenotfound_errors_propagate(monkeypatch):
    """로더 손상·권한·DB 오류를 삼키면 서로 다른 고장이 같은 해시가 된다.

    Codex 리뷰 6b — 흡수는 ``FileNotFoundError`` 로만 좁혀져 있어야 한다.
    """
    def _boom(*a, **k):
        raise RuntimeError("prompt loader 손상")

    monkeypatch.setattr("app.modules.prompt_loader.load_prompt", _boom)
    with pytest.raises(RuntimeError):
        search_contract_sha("3")


def test_contract_sha_is_stable_across_calls():
    assert search_contract_sha("3") == search_contract_sha("3")


# ─────────────────────────────────────────────────────────────────────
# 3. 팩 버전 해석 — 미배선 팩 명시 실패
# ─────────────────────────────────────────────────────────────────────
def test_unwired_pack_version_fails_loudly():
    """조용한 fallback 은 사문화된 팩을 만든다 — 없는 버전은 세운다."""
    with pytest.raises(ValueError):
        resolve_ref_pack_version("99")


def test_version_resolves_by_key_and_by_full_name():
    full = resolve_ref_pack_version("3")
    assert full.startswith("3.")
    assert resolve_ref_pack_version(full) == full


def test_target_policy_is_seed_parity():
    """대상 집합 정책 = 판정 없는 seed exact parity (v3)."""
    assert TARGET_POLICY_VERSION == "3-seed-parity"


def test_pick_combine_contract_version_moves_the_step_config_hash():
    """★계약을 고쳐도 완료 CP 가 재사용되면 실행에 도달하지 않는다.

    지난 wave 실측 — 관할절을 통째로 바꿔도 config_hash 가 같아 완료 CP 가
    그대로 쓰였다. 판정 합산 계약도 같은 축에 실어야 한다.
    """
    from unittest.mock import patch

    from app.core.steps import outdoor_structure_form_reference_step as mod

    step = mod.OutdoorStructureFormReferenceStep.__new__(
        mod.OutdoorStructureFormReferenceStep)
    step.project_id, step.episode_id, step.project_config = "P", "E", {}
    before = step._config_hash()
    with patch.object(mod, "PICK_COMBINE_CONTRACT_VERSION", "999"):
        after = step._config_hash()
    assert before != after


def test_no_valid_judge_is_reported_as_its_own_cause():
    """"평균 0" 과 "심판이 하나도 유효하지 않음" 은 다른 사건이다.

    같은 문구로 뭉치면 감사에서 원인을 되짚을 수 없다.
    """
    out = combine_pick_verdicts(
        {"gpt": {"verdicts": [{"index": 9, "usable": True, "score": 99}]}},
        candidate_count=2)
    assert out["chosen_index"] == 0
    assert out["judge_count_per_candidate"] == 0
    assert out["rejected_judges"]["gpt"]


def test_judge_physical_models_are_in_the_step_config_hash():
    """★심판 별칭만 접으면 모델을 갈아 끼워도 완료 CP 가 옛 판정을 재사용한다.

    라우팅은 gemini-pro→settings.gemini_text_model, gpt→settings.openai_model
    이다. 별칭은 그대로인데 뒤의 물리 모델만 바뀌는 것이 실제 운영에서 일어나는
    변화다 — 그게 hash 에 안 실리면 "판정 계약이 바뀌었는데 실행에 미도달"이
    된다(A6 가 막으려던 것과 같은 종류).
    """
    from unittest.mock import patch

    from app.core.steps import outdoor_structure_form_reference_step as mod

    step = mod.OutdoorStructureFormReferenceStep.__new__(
        mod.OutdoorStructureFormReferenceStep)
    step.project_id, step.episode_id, step.project_config = "P", "E", {}
    base = step._config_hash()
    with patch.multiple("app.core.config.settings",
                        gemini_text_model="SAMPLE-OTHER-GEMINI"):
        assert step._config_hash() != base, "Gemini 물리 모델 교체가 안 실린다"
    with patch.multiple("app.core.config.settings",
                        openai_model="SAMPLE-OTHER-GPT"):
        assert step._config_hash() != base, "OpenAI 물리 모델 교체가 안 실린다"
