"""수정 편집에 붙는 참조를 **지적이 요구한 것만**으로 좁힌다.

## 왜 이 시험이 있나 (2026-08-19 실측)

수정본을 만들면 원본의 자세·구도가 변질되고 사람이 화면 가운데로 옮겨져
차렷 자세로 서는 일이 잦았다. 이번 판 실측으로 수정본 139장면 중
**82장면(59%)** 이 「더 나빠졌다」로 퇴짜를 맞았다.

원인은 문구가 아니었다. 보존을 요구하는 문장은 지시문 머리·꼬리·참조
라벨 세 겹에 이미 들어 있고, 재판정도 동점이면 원본을 유지한다. 모델도
아니었다(nb2·grok 둘 다 같았고, 지시문 길이·항목 수는 퇴짜와 채택을
가르지 못했다 — 중앙 782 대 788). **갈린 것은 참조 장수뿐이었다**
(퇴짜 중앙 4장, 채택 3장).

코드에 조건이 없었다. 편집 호출은 결함 검사가 본 참조를 전부 동봉했고,
붙는 것 다수가 인물 정본이라 그 라벨의 「얼굴·머리·체형을 정확히 맞춰라」
가 함께 나갔다 — 시계 바늘 하나 고치라는 지적에 사람을 다시 그리라는
지시가 붙는 셈이다. 더 근본은 지적 한 건에 **어느 참조가 필요한지 말하는
칸이 없었다**는 것이라, 코드가 고를 방법 자체가 없었다.

그래서 칸을 신설하고(`needs_ref_indices` — 판단은 결함 검사 모델 몫,
글자 대조가 아니다) 그 목록으로 거른다. 지키는 계약은 넷이다.

1. **OFF 는 바이트 동일** — 스키마도 조립도 종전 그대로여야 한다.
   이 판이 깨지면 완성된 에피소드의 지문이 흔들려 전량 재생성이 걸린다.
2. 요구된 참조만 붙고, 아무도 요구하지 않으면 편집 대상 원본 한 장만
   간다.
3. 칸이 없는 지적이 섞이면 **거르지 않는다** — 물은 적 없는 지적에게서
   참조를 빼면 글자로만 지시하던 2026-08-07 이전으로 되돌아간다(그때
   실측 4건이 실패했다).
4. 없는 것을 새로 넣으라는 지적이면 그 절이 지시문에 끼고, 없으면 안 낀다.
"""

import pytest

from app.modules.pipeline.multiroll_select import (
    build_critique_schema,
    build_fix_prompt,
    collect_missing_entities,
    run_multiroll_select,
    select_fix_refs,
)

HEAD = "Edit this photograph."
TAIL = "PRESERVE EVERYTHING ELSE EXACTLY."
MISS_HEAD = "ADD WHAT IS MISSING:"
MISS_TAIL = "These are not in the photograph at all."

# 참조는 라벨과 내용만 있으면 된다 — 작품 고유명사는 쓰지 않는다.
REFS = [
    ("person canon — first", b"a"),
    ("person canon — second", b"b"),
    ("previous shot of this place", b"c"),
    ("prop canon — a stand", b"d"),
]


def _issue(fix_en, needs=None, missing="", **extra):
    out = {"issue_ko": "지적", "fix_en": fix_en}
    if needs is not None:
        out["needs_ref_indices"] = list(needs)
        out["adds_missing_entity"] = bool(missing)
        out["missing_entity_name"] = missing
    out.update(extra)
    return out


# ── 계약 1: OFF 는 바이트 동일 ──────────────────────────────────────

def test_schema_off_is_unchanged():
    """칸을 안 켜면 스키마가 종전과 같다 — 지문이 안 움직인다."""
    off = build_critique_schema(with_severity=True)
    item = off["properties"]["issues"]["items"]
    assert "needs_ref_indices" not in item["properties"]
    assert "adds_missing_entity" not in item["properties"]
    assert item["required"] == [
        "issue_ko", "fix_en", "severity", "observation_index"]


def test_schema_on_adds_three_required_fields():
    on = build_critique_schema(with_severity=True, with_ref_gate=True)
    item = on["properties"]["issues"]["items"]
    for key in ("needs_ref_indices", "adds_missing_entity",
                "missing_entity_name"):
        assert key in item["properties"], key
        assert key in item["required"], key
    # 번호 배열이어야 코드가 참조를 되찾을 수 있다.
    assert on["properties"]["issues"]["items"]["properties"][
        "needs_ref_indices"]["items"]["type"] == "integer"


def test_fix_prompt_without_missing_is_byte_identical():
    """없는 것을 넣으라는 지적이 없으면 조립이 종전과 1비트도 다르지 않다."""
    issues = [_issue("Straighten the clock hands.")]
    legacy = build_fix_prompt(issues, HEAD, TAIL)
    gated = build_fix_prompt(
        issues, HEAD, TAIL, missing_names=[],
        missing_head=MISS_HEAD, missing_tail=MISS_TAIL)
    assert legacy == gated
    assert MISS_HEAD not in gated


# ── 계약 2: 요구된 것만 붙는다 ──────────────────────────────────────

def test_only_requested_refs_are_kept():
    issues = [
        _issue("Match the knitted cap to the person reference.", needs=[1]),
        _issue("Straighten the clock hands.", needs=[]),
    ]
    kept, rec = select_fix_refs(issues, REFS)
    assert [lab for lab, _ in kept] == ["person canon — first"]
    assert rec["mode"] == "gated"
    assert rec["available"] == 4 and rec["kept"] == 1
    assert rec["requested_indices"] == [1]


def test_nobody_asks_means_no_reference_at_all():
    """자족적인 지적만 있으면 편집 대상 원본 한 장만 간다."""
    issues = [
        _issue("Erase the duplicated shadow on the floor.", needs=[]),
        _issue("Blur the distant window glare.", needs=[]),
    ]
    kept, rec = select_fix_refs(issues, REFS)
    assert kept == []
    assert rec["kept"] == 0


def test_requested_refs_keep_attachment_order():
    """번호는 붙인 순서를 가리킨다 — 뒤섞여 오면 순서대로 되돌린다."""
    issues = [_issue("Restore both.", needs=[3, 1])]
    kept, _rec = select_fix_refs(issues, REFS)
    assert [lab for lab, _ in kept] == [
        "person canon — first", "previous shot of this place"]


def test_same_ref_requested_twice_attaches_once():
    issues = [
        _issue("Match the hair.", needs=[2]),
        _issue("Match the jacket.", needs=[2]),
    ]
    kept, rec = select_fix_refs(issues, REFS)
    assert len(kept) == 1 and rec["requested_indices"] == [2]


def test_out_of_range_and_non_integer_are_dropped_and_recorded():
    """발명한 번호로 엉뚱한 참조가 붙는 것이 안 붙는 것보다 나쁘다."""
    issues = [_issue("Match something.", needs=[0, 9, True, "2", 3])]
    kept, rec = select_fix_refs(issues, REFS)
    assert [lab for lab, _ in kept] == ["previous shot of this place"]
    assert rec["invalid_indices"] == [0, 9, True, "2"]


# ── 계약 3: 물은 적 없는 지적은 거르지 않는다 ───────────────────────

def test_issue_without_the_field_disables_the_gate():
    issues = [
        _issue("Match the cap.", needs=[1]),
        _issue("Something a different critique wrote."),   # 칸 없음
    ]
    kept, rec = select_fix_refs(issues, REFS)
    assert len(kept) == len(REFS)
    assert rec["mode"] == "all_ungated"
    assert rec["ungated_issue_count"] == 1


def test_no_issues_at_all_keeps_nothing():
    kept, rec = select_fix_refs([], REFS)
    assert kept == [] and rec["mode"] == "gated"


# ── 계약 4: 없는 것을 새로 넣으라는 지적 ────────────────────────────

def test_missing_entities_are_collected_in_order_without_duplicates():
    issues = [
        _issue("Add the second person.", needs=[2], missing="second person"),
        _issue("Add the stand.", needs=[4], missing="a thin metal stand"),
        _issue("Add the second person again.", needs=[2],
               missing="second person"),
        _issue("Straighten the clock.", needs=[]),
    ]
    assert collect_missing_entities(issues) == [
        "second person", "a thin metal stand"]


def test_missing_clause_is_added_only_when_something_is_missing():
    issues = [
        _issue("Add the stand.", needs=[4], missing="a thin metal stand")]
    prompt = build_fix_prompt(
        issues, HEAD, TAIL,
        missing_names=collect_missing_entities(issues),
        missing_head=MISS_HEAD, missing_tail=MISS_TAIL)
    assert MISS_HEAD in prompt
    assert "- a thin metal stand" in prompt
    assert MISS_TAIL in prompt
    # 순서 계약: 머리 → 고칠 것 → 새로 넣을 것 → 꼬리.
    assert prompt.index(HEAD) < prompt.index("CORRECTIONS:")
    assert prompt.index("CORRECTIONS:") < prompt.index(MISS_HEAD)
    assert prompt.index(MISS_HEAD) < prompt.index(TAIL)


def test_missing_clause_needs_its_reference_attached():
    """새로 넣으라는 지적은 그 참조를 함께 요구해야 한다 — 시험은 그
    요구가 실제로 참조를 붙이는지만 본다(요구 여부 판단은 모델 몫)."""
    issues = [
        _issue("Add the stand.", needs=[4], missing="a thin metal stand")]
    kept, _rec = select_fix_refs(issues, REFS)
    assert [lab for lab, _ in kept] == ["prop canon — a stand"]


# ── 끝단 배선: 선별 결과가 실제 편집 호출을 좁히는가 ─────────────────


class _RecordingGen:
    """gen_fn(tag, prompt, labeled_refs, out_path) — 호출을 적고 바이트만 쓴다.

    바깥 세계(그림 모델 호출)만 흉내 낸다. 참조를 고르고 라벨을 갈아 끼우고
    목록을 조립하는 일은 전부 실물 코드가 한다.
    """

    def __init__(self):
        self.calls = []

    def __call__(self, tag, prompt, labeled_refs, out_path):
        self.calls.append((tag, prompt, list(labeled_refs), out_path))
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_bytes(b"png:" + out_path.name.encode())
        return out_path


def _fake_judge(tag, prompt, labeled_refs, cand_paths, labels):
    return {
        "winner": "A",
        "ranking": list(labels),
        "verdicts": [{"label": lab, "score": 9 if lab == "A" else 1,
                      "verdict_ko": "ok"} for lab in labels],
    }


FIX_LABEL = "PHOTOGRAPH TO EDIT"
FIX_REF_LABEL = "REFERENCE (do not edit) —"


def test_fix_call_gets_only_the_requested_references(tmp_path):
    """★끝단 배선 — 고른 결과가 **실제 편집 호출**의 참조를 좁히는가.

    앞의 시험들은 `select_fix_refs` 만 본다. 고르기가 맞아도 그 결과를
    편집 호출에 안 넘기면 화면에는 종전대로 전부 붙고, 기록에는 「선별
    켜짐」으로 남아 조용히 무력해진다 — 이 저장소에서 흉내로 대신한 자리가
    안 지켜진 사례가 그 모양이었다. 그래서 공개 진입점부터 돌려
    `fix_gen_fn` 이 받은 목록을 직접 본다(그림 모델만 흉내).

    꺼짐도 함께 단언한다 — 켜짐만 보면 「원래 하나만 붙던 것」과 구분되지
    않는다.
    """
    issues = [
        _issue("Match the knitted cap to the person reference.", needs=[1]),
        _issue("Straighten the clock hands.", needs=[]),
    ]

    def _run(gate_on):
        root = tmp_path / ("on" if gate_on else "off")
        root.mkdir()
        refs = []
        for i, (label, body) in enumerate(REFS, 1):
            path = root / f"ref{i}.png"
            path.write_bytes(body)
            refs.append((label, path))
        gen = _RecordingGen()
        _sel, record = run_multiroll_select(
            tag="t1", prompt="PROMPT", labeled_refs=refs,
            out_stem=root / "out" / "s1",
            gen_fn=gen, judge_fn=_fake_judge,
            critique_fn=lambda *a: {"issues": issues},
            fix_gen_fn=gen, roll_count=2, critique_enabled=True,
            fix_head=HEAD, fix_tail=TAIL, fix_label=FIX_LABEL,
            fix_ref_label=FIX_REF_LABEL, fix_ref_gate=gate_on,
        )
        fix_calls = [c for c in gen.calls
                     if c[3].name.endswith("_fix.png")]
        assert len(fix_calls) == 1
        return fix_calls[0][2], record, refs

    # ── 켜짐: 1번을 요구한 지적 하나뿐 → 편집 대상 + 그 한 장 ──
    on_refs, on_record, refs = _run(True)
    assert [lab for lab, _ in on_refs] == [
        FIX_LABEL, f"{FIX_REF_LABEL} person canon — first"]
    # 라벨만 맞고 다른 파일이 붙는 것이 가장 나쁜 실패다 — 내용까지 본다.
    assert on_refs[1][1] == refs[0][1]
    assert on_record["fix_ref_count"] == 2
    assert on_record["fix_ref_gate"]["kept_labels"] == ["person canon — first"]
    assert on_record["fix_ref_gate"]["mode"] == "gated"

    # ── 꺼짐: 종전대로 넷 다 붙는다 ──
    off_refs, off_record, refs = _run(False)
    assert [lab for lab, _ in off_refs] == [FIX_LABEL] + [
        f"{FIX_REF_LABEL} {lab}" for lab, _ in REFS]
    assert [src for _lab, src in off_refs[1:]] == [p for _lab, p in refs]
    assert off_record["fix_ref_count"] == 5
    assert "fix_ref_gate" not in off_record


# 번호 목록이 붙는 자리는 **어댑터마다 다른 호출**이다 — 마지막에 지적을
# 쓰는 호출(단일 검사면 그 호출, 2단이면 취합)에 붙어야 한다.
REF_INDEX_ADAPTERS = [
    ("make_gemini_critique_fn", {"critique_sys": "SYS"},
     "multiroll_critique"),
    ("make_gq_critique_fn", {}, "multiroll_critique_compose"),
    ("make_qk_critique_fn", {}, "multiroll_critique_compose"),
    ("make_gg46_critique_fn", {}, "multiroll_critique_compose"),
    ("make_gpt_composition_critique_fn", {}, "multiroll_gpt_composition"),
]


@pytest.mark.parametrize("factory, extra_kwargs, final_tag",
                         REF_INDEX_ADAPTERS,
                         ids=[a[0] for a in REF_INDEX_ADAPTERS])
def test_compose_call_actually_carries_the_reference_numbering(
        factory, extra_kwargs, final_tag):
    """★번호가 무엇을 가리키는지 모델에게 실제로 나가야 한다.

    이 목록이 안 나가면 모델이 번호를 지어내고, 코드는 그 번호로 **엉뚱한
    참조**를 붙이거나 필요한 것을 빼 버린다. 결과가 조용히 틀어지는
    자리라 조립까지 잠근다. 꺼져 있으면 한 글자도 안 붙어야 한다.

    다섯 구도를 다 건다. 한 갈래만 잠가 두면 다른 구도로 갈아탄 날
    번호 없는 지적이 섞이고, 그러면 `select_fix_refs` 가 통째로 거르기를
    그만둔다(fail-open) — 켜 놓은 채로 아무 효과가 없는 상태가 된다.
    """
    from unittest.mock import patch

    from app.modules.pipeline import multiroll_gemini as mg

    refs = [("person canon — first", b"a"), ("previous shot", b"b")]
    seen = {}

    def _fake(tag, sys_prompt, parts, schema, **kw):
        seen[tag] = parts
        # 관찰 자리는 관찰을 돌려줘야 취합까지 내려간다(관찰 0건 = 즉시 반환).
        if "_observe" in tag:
            return {"observations": [{"issue_ko": "무엇이 틀렸다",
                                      "severity": "critical"}]}
        return {"issues": []}

    def _run(ref_gate):
        seen.clear()
        with patch("app.modules.llm.llm_client.call_structured",
                   side_effect=_fake), \
             patch("app.modules.llm.qwen_vlm_client.ask_qwen_structured",
                   side_effect=_fake), \
             patch("app.modules.llm.openrouter_vlm_client."
                   "ask_openrouter_structured", side_effect=_fake), \
             patch.object(mg, "png_part", lambda src: {"type": "image"}):
            fn = getattr(mg, factory)(
                critique_schema={}, ref_gate=ref_gate, **extra_kwargs)
            fn("t", "브리프", refs, "sel.png")
        assert final_tag in seen, f"{factory}: {final_tag} 호출이 없다"
        return "\n".join(p.get("text", "")
                         for p in seen[final_tag] if p.get("type") == "text")

    on_text = _run(True)
    assert "REFERENCE INDEX" in on_text
    assert "1. person canon — first" in on_text
    assert "2. previous shot" in on_text
    assert "REFERENCE INDEX" not in _run(False)


def test_outer_step_config_hash_splits_on_the_flag():
    """★바깥 스텝 지문에도 접혀야 한다 (Codex 지적, 수용).

    이 값이 바깥 지문에 없으면 완료된 스텝이 통째로 건너뛰어져 샷 지문
    대조까지 내려가지 않는다 — 선별 없이 만든 산출을 「선별 적용됨」으로
    읽게 된다. gg46·era 스탬프와 같은 관례다.
    """
    from unittest.mock import patch

    from app.core.config import settings
    from app.core.steps.image_steps import SceneImagePipelineStep

    step = SceneImagePipelineStep.__new__(SceneImagePipelineStep)
    step.project_config = {}

    # ★2026-08-29: 전제가 하나 늘었다 — **repair master 가 켜져 있을 때**다.
    #  참조 선별은 수정 단계 전용이라 master 가 꺼지면 그 계약이 산출에
    #  안 닿는다. 그래서 지문에서도 뺐다(안 도는 단계의 팩을 바꾼 것만으로
    #  선정 롤이 stale 이 되는 것을 막는다). 원래 이 시험이 막던 실패는
    #  **master ON 갈래에서 그대로 유효**하므로 그 갈래로 잰다.
    def _hash(gate_on, master=True):
        with patch.object(settings, "still_recipe_mode", "on", create=True), \
             patch.object(settings, "still_recipe_critique_enabled", master,
                          create=True), \
             patch.object(settings, "still_fix_ref_gate_enabled", gate_on,
                          create=True):
            return step._config_hash()

    # 원래 계약 — master ON 이면 선별 켜고 끄기가 지문을 가른다
    assert _hash(False) != _hash(True)
    # ★새 계약 — master OFF 면 선별 플래그는 지문을 안 움직인다
    #  (그 단계가 아예 안 돈다). 양쪽 갈래를 다 태운다.
    assert _hash(False, master=False) == _hash(True, master=False)


def test_outer_step_config_hash_follows_the_pack_content():
    """팩 v13 의 **내용**이 바뀌면 바깥 지문도 바뀌어야 한다.

    버전 문자열만 접으면 팩 파일이 바뀌어도 지문이 안 움직인다 —
    2026-08-08 에 최종 스틸 311장 중 253장이 판정 없이 재사용된 사고가
    그 모양이었다.
    """
    from unittest.mock import patch

    from app.core.config import settings
    from app.core.steps.image_steps import SceneImagePipelineStep

    step = SceneImagePipelineStep.__new__(SceneImagePipelineStep)
    step.project_config = {}

    # ★master ON 갈래로 잰다 (2026-08-29) — 참조 선별은 수정 단계 전용이라
    #  master 가 꺼지면 지문에서 빠진다. 이 시험이 막던 실패(팩 내용이 바뀌어도
    #  지문이 안 움직여 253장이 판정 없이 재사용된 것)는 그 갈래에서 유효하다.
    def _hash(pack_content):
        with patch.object(settings, "still_recipe_mode", "on", create=True), \
             patch.object(settings, "still_recipe_critique_enabled", True,
                          create=True), \
             patch.object(settings, "still_fix_ref_gate_enabled", True,
                          create=True), \
             patch("app.modules.pipeline.multiroll_gemini."
                   "judge_pack_content_hash",
                   side_effect=lambda sel: (
                       pack_content if sel == "13" else f"fixed-{sel}")):
            return step._config_hash()

    assert _hash("content-a") != _hash("content-b")


def test_missing_head_absent_means_no_clause():
    """문안을 못 읽었으면 절을 지어내지 않는다."""
    issues = [_issue("Add it.", needs=[4], missing="a thin metal stand")]
    prompt = build_fix_prompt(
        issues, HEAD, TAIL,
        missing_names=collect_missing_entities(issues),
        missing_head="", missing_tail="")
    assert "a thin metal stand" not in prompt.split("CORRECTIONS:")[0]
    assert prompt == build_fix_prompt(issues, HEAD, TAIL)
