"""수리 수단 — 편집 대신 **다시 한 장** (2026-08-29 사용자 지시).

> "지적 받은 내용을 바로 이미지를 i2i로 수정하는게 아니고 프롬프트에
>  지적 받은 내용 강화해서 다시 1장만 생성하는 형태로"
> "regenerate 을 default 로" · "env 로 바꿀수 있어야해"

## 재는 것 — 말이 아니라 동작

  ① 기본값이 `regenerate` 이고 **`.env`/환경변수로 바뀐다**
  ② `regenerate` 면 편집 함수가 **안 불리고** 재생성 함수가 **한 번** 불린다
  ③ 재생성 프롬프트에 **선정 롤이 실제 쓴 브리프**와 **지적 전체**가 든다
  ④ 재생성에는 **원본 산출이 참조로 안 붙는다** (붙으면 같은 결함이 온다)
  ⑤ ★`unfixable` 지적도 **재생성에서는 실린다** — 그 뜻이 「i2i 로 못 고침」
     이라 새로 그리는 자리에서는 뺄 이유가 없다
  ⑥ severity 게이트는 그대로 — critical 만
  ⑦ 구조물 문안(`STRUCTURAL FAULTS`)이 스틸에 **안 새어 든다**
  ⑧ 수단이 바뀌면 **샷 지문이 움직인다**, `edit` 은 종전과 같다
  ⑨ 재생성을 골라 놓고 그릴 함수가 없으면 **편집으로 안 내려간다**
"""
from __future__ import annotations

from pathlib import Path
from typing import Any, Dict, List

import pytest

from app.modules.pipeline.multiroll_select import (
    REPAIR_METHOD_EDIT,
    REPAIR_METHOD_REGENERATE,
    build_regen_prompt,
    compute_input_fingerprint,
    run_multiroll_select,
)

PNG = b"\x89PNG\r\n\x1a\n"


def _verdict(winner: str, labels: List[str]) -> Dict[str, Any]:
    return {
        "winner": winner,
        "ranking": [winner] + [x for x in labels if x != winner],
        "verdicts": [{"label": x, "score": 9 if x == winner else 3,
                      "verdict_ko": ""} for x in labels],
        "readings": [{"label": x, "hard_violations": []} for x in labels],
        "all_candidates_fail": False,
    }


class _Run:
    """한 판을 돌리고 **누가 무엇으로 불렸는지** 다 남긴다."""

    def __init__(self, tmp_path: Path, issues, **kw):
        self.gen_calls: List[str] = []
        self.fix_calls: List[Dict[str, Any]] = []
        self.regen_calls: List[Dict[str, Any]] = []
        self.issues = issues
        self.tmp = tmp_path

        def gen_fn(tag, prompt, refs, out_path):
            out_path.parent.mkdir(parents=True, exist_ok=True)
            out_path.write_bytes(PNG + tag.encode())
            self.gen_calls.append(tag)
            if tag.endswith("_regen"):
                self.regen_calls.append(
                    {"tag": tag, "prompt": prompt,
                     "ref_labels": [lab for lab, _ in refs]})
            return out_path

        def fix_gen_fn(tag, prompt, refs, out_path):
            out_path.parent.mkdir(parents=True, exist_ok=True)
            out_path.write_bytes(PNG + b"FIXED")
            self.fix_calls.append(
                {"tag": tag, "prompt": prompt,
                 "ref_labels": [lab for lab, _ in refs]})
            return out_path

        def judge_fn(tag, prompt, refs, cands, labels):
            return _verdict("A", list(labels))

        def critique_fn(tag, prompt, refs, image_path):
            return {"issues": list(self.issues)}

        self.sel, self.record = run_multiroll_select(
            tag="t1", prompt="원 브리프 전문", labeled_refs=[
                ("배경판", tmp_path / "bg.png"),
                ("인물 정본", tmp_path / "char.png"),
            ],
            out_stem=tmp_path / "out" / "s1",
            gen_fn=gen_fn, judge_fn=judge_fn,
            critique_fn=critique_fn, fix_gen_fn=fix_gen_fn,
            roll_count=2, critique_enabled=True,
            fix_head="FIXHEAD", fix_tail="FIXTAIL", fix_label="EDIT THIS",
            record=None,
            # ★재생성은 **롤 생성기**로 그린다 — 프로덕션 배선과 같다
            #  (`run_branch_select` 가 `regen_gen_fn=gen` 을 넘긴다).
            # ★재생성엔 **재판정이 필수**다 — 기존 계약
            #  (`validate_regeneration_contract`). 무판정 확정을 막는다.
            **{"regen_gen_fn": gen_fn,
               "fix_rejudge_fn": judge_fn, **kw})


@pytest.fixture
def refs(tmp_path):
    for n in ("bg.png", "char.png"):
        (tmp_path / n).write_bytes(PNG + n.encode())
    return tmp_path


def _regen_kw():
    from app.modules.pipeline.multiroll_gemini import (
        resolve_still_regen_texts,
    )
    t = resolve_still_regen_texts()
    return {
        "repair_method": REPAIR_METHOD_REGENERATE,
        "regen_head": t["regen_head"],
        "regen_tail": t["regen_tail"],
        "regen_faults_header": t["regen_faults_header"],
    }


# ── ① 기본값과 env ──────────────────────────────────────────────

def test_the_code_default_is_regenerate():
    """★`.env` 가 아니라 **코드 기본값**을 본다.

    `Settings()` 로 읽으면 `.env` 를 보게 되어, 코드를 되돌려도 시험이
    통과한다 — 오늘 이미 한 번 겪었다.
    """
    from app.core.config import Settings

    assert Settings.model_fields["still_repair_method"].default \
        == "regenerate"


def test_env_can_change_it_and_a_wrong_value_is_refused(monkeypatch):
    """사용자 지시: "env 로 바꿀수 있어야해"."""
    from app.core.config import Settings

    monkeypatch.setenv("STILL_REPAIR_METHOD", "edit")
    assert Settings(_env_file=None).still_repair_method == "edit"
    monkeypatch.setenv("STILL_REPAIR_METHOD", "regenerate")
    assert Settings(_env_file=None).still_repair_method == "regenerate"
    # ★오타를 삼키면 조용히 종전 동작으로 돌아간다
    monkeypatch.setenv("STILL_REPAIR_METHOD", "regenrate")
    with pytest.raises(Exception):
        Settings(_env_file=None)


# ── ②③④ 편집 대신 재생성 ───────────────────────────────────────

def test_regenerate_calls_the_roll_generator_once_and_never_edits(refs):
    r = _Run(refs, [{"issue_ko": "손이 어색하다", "fix_en": "fix the hand",
                     "severity": "critical"}], **_regen_kw())
    assert r.fix_calls == [], "재생성인데 i2i 편집이 불렸다"
    assert len(r.regen_calls) == 1, (
        f"재생성이 {len(r.regen_calls)}번 — 사용자 계약은 「다시 1장만」")
    assert r.record["repair_mode"] == "regenerate"
    assert r.record["repair_method"] == REPAIR_METHOD_REGENERATE


def test_the_regen_prompt_carries_the_selected_brief_and_the_issues(refs):
    r = _Run(refs, [{"issue_ko": "손이 어색하다", "fix_en": "fix the hand",
                     "severity": "critical"},
                    {"issue_ko": "간판이 읽힌다", "fix_en": "hide the sign",
                     "severity": "critical"}], **_regen_kw())
    p = r.regen_calls[0]["prompt"]
    assert "원 브리프 전문" in p, "선정 롤이 쓴 브리프가 안 실렸다"
    assert "fix the hand" in p and "hide the sign" in p, (
        "지적이 전부 안 실렸다 — 하나라도 빠지면 그것만 안 고쳐진다")
    assert "FAULTS OF THE REJECTED ATTEMPT" in p


def test_the_original_output_is_not_a_reference_but_the_roll_refs_are(refs):
    """★원본을 참조로 물리면 같은 결함이 그대로 온다."""
    r = _Run(refs, [{"issue_ko": "x", "fix_en": "y",
                     "severity": "critical"}], **_regen_kw())
    labs = r.regen_calls[0]["ref_labels"]
    assert "EDIT THIS" not in labs, "원본이 편집 대상 라벨로 붙었다"
    assert set(labs) == {"배경판", "인물 정본"}, (
        f"참조가 {labs} — 선정 롤이 쓴 세트 그대로여야 한다. 하나라도 빼면 "
        "새로 그리는 자리에서 그 권위가 사라진다")


# ── ⑤ unfixable 도 재생성에서는 실린다 ──────────────────────────

def test_unfixable_issues_are_carried_into_regeneration(refs):
    """`unfixable` 의 뜻은 「i2i 로 못 고침」이다 — 새로 그리면 고쳐진다."""
    issue = {"issue_ko": "카메라를 옮겨야 한다", "fix_en": "move the camera",
             "severity": "critical", "unfixable": True}
    r = _Run(refs, [issue], **_regen_kw())
    assert len(r.regen_calls) == 1, (
        "unfixable 만 있다고 재생성을 건너뛰었다 — 사용자가 바로 그것을 "
        "고치려고 수단을 바꾼 것이다")
    assert "move the camera" in r.regen_calls[0]["prompt"]

    # ★반대편 — edit 에서는 종전대로 빠진다
    r2 = _Run(refs, [issue], repair_method=REPAIR_METHOD_EDIT)
    assert r2.fix_calls == []
    assert r2.record.get("fix_skip_reason") == "all_issues_unfixable"


# ── ⑥ severity 게이트는 그대로 ──────────────────────────────────

def test_below_critical_issues_still_do_not_trigger_repair(refs):
    r = _Run(refs, [{"issue_ko": "사소", "fix_en": "minor",
                     "severity": "major"}], **_regen_kw())
    assert r.regen_calls == [] and r.fix_calls == []
    assert r.record["fix_skip_reason"] == "no_critical_issue"


# ── ⑦ 구조물 문안이 스틸에 안 샌다 ─────────────────────────────

def test_the_structure_wording_never_reaches_a_still():
    """구조물 기본 문안은 층수·매스 전용이라 스틸 지적의 대상이 뒤바뀐다."""
    from app.modules.pipeline.multiroll_gemini import (
        resolve_still_regen_texts,
    )
    t = resolve_still_regen_texts()
    still = build_regen_prompt(
        [{"fix_en": "fix the hand"}], "브리프",
        t["regen_head"], t["regen_tail"],
        faults_header=t["regen_faults_header"])
    assert "STRUCTURAL" not in still
    assert "storey" not in still, (
        "구조물 문안이 스틸에 샜다 — 지적의 대상이 층수·매스로 뒤바뀐다")

    # ★기존 소비자(구조물)는 **한 글자도 안 바뀐다**
    legacy = build_regen_prompt([{"fix_en": "add a storey"}], "브리프")
    assert "STRUCTURAL FAULTS OF THE REJECTED ATTEMPT:" in legacy


# ── ⑧ 지문 ──────────────────────────────────────────────────────

def test_changing_the_method_moves_the_shot_fingerprint(tmp_path):
    """수단이 바뀌면 완료 샷은 **반드시** 다시 만들어져야 한다."""
    base = dict(prompt="p", labeled_refs=[], roll_count=2,
                critique_enabled=True)
    edit = compute_input_fingerprint(**base, extra=None)
    regen = compute_input_fingerprint(**base, extra={
        "repair_method": "regenerate",
        "repair_method_policy": "repair_method_v1_regenerate",
        "repair_regen_pack_sha": "deadbeefdeadbeef",
    })
    assert edit != regen, (
        "수단을 바꿔도 지문이 그대로다 — 재주행이 편집본을 안고 간다")

    # ★문안만 고쳐도 움직여야 한다 (selector 만 접으면 안 움직인다)
    regen2 = compute_input_fingerprint(**base, extra={
        "repair_method": "regenerate",
        "repair_method_policy": "repair_method_v1_regenerate",
        "repair_regen_pack_sha": "0000000000000000",
    })
    assert regen != regen2, "팩 문안을 고쳤는데 지문이 안 움직인다"


def test_edit_contributes_nothing_to_the_fingerprint(refs):
    """★종전 산출이 byte-identical 이어야 한다."""
    a = _Run(refs, [], repair_method=REPAIR_METHOD_EDIT)
    b = _Run(refs, [])          # 인자 자체를 안 넘긴 종전 호출
    assert a.record["input_fingerprint"] == b.record["input_fingerprint"]


# ── ⑨ 내려가지 않는다 ──────────────────────────────────────────

def test_regenerate_without_a_generator_is_refused_not_downgraded(refs):
    """★편집으로 조용히 내려가면 기록에는 새 수단으로 남는다."""
    from app.core.errors import AppError

    kw = _regen_kw()
    with pytest.raises(AppError) as e:
        _Run(refs, [{"issue_ko": "x", "fix_en": "y",
                     "severity": "critical"}],
             **{**kw, "regen_gen_fn": None})
    assert "regenerate" in str(e.value.code)


# ── ⑩ 영속 **계약** — 재생성 승자를 「편집본」으로 적지 않는다 ──
#
# ★이름을 정확히 둔다 (2026-08-29 Codex 정정, 수용). 아래 셋은
#  `save_single_scene_asset` 까지 태우는 **끝점 시험이 아니다** —
#  순수 함수 둘(`resolve_generation_model`·`describe_cine_source`) +
#  조립 조건 AST 하나다. 「끝점에서 쟀다」고 부르면 내가 안 잰 것을
#  잰 것처럼 읽게 된다. 영속 호출부가 이 값들을 실제로 쓰는지는
#  코드를 직접 읽어 확인했고, 그 확인은 시험이 아니라 리뷰다.
#
# ★2026-08-29 Codex BLOCK. 하류가 「수리 승자 = i2i 편집」을 가정하고
#  있었다. 재생성은 **선정 롤을 입력으로 받지 않고** 브랜치 롤 생성기로
#  새로 그린다 — 그런데도 그 롤을 「수리 호출의 직접 입력」으로 적으면
#  파일과 sha 까지 실물이라 **그럴듯한 거짓 기록**이 된다.

def test_cine_source_names_regenerate_not_fix():
    """변환의 직접 입력이 재생성 산출이면 그렇게 적는다."""
    from app.services.still_recipe_service import describe_cine_source

    edit = describe_cine_source(
        source_file="S1sh3_sel.png", selected="A", fix_won=True,
        repair_mode="edit")
    assert edit["stage"] == "fix"

    regen = describe_cine_source(
        source_file="S1sh3_sel.png", selected="A", fix_won=True,
        repair_mode="regenerate")
    assert regen["stage"] == "regenerate", (
        "재생성 산출을 「편집본」이라 적는다 — 다른 계보다")
    assert "regen" in regen["unresolved_reason"]

    # ★수리가 안 이겼으면 수단과 무관하게 롤이다
    roll = describe_cine_source(
        source_file="S1sh3_sel.png", selected="A", fix_won=False,
        repair_mode="regenerate")
    assert roll["stage"] == "roll"


def test_non_cine_model_follows_the_actual_call_not_the_gg46_rule():
    """★재생성 승자는 **롤 생성기**가 만들었다 — Grok 이 아니다."""
    from app.services.still_recipe_service import resolve_generation_model

    base = dict(cine_applied=False, cine_model="", ladder_model="",
                still_image_backend="nb2", gg46_judge_on=True,
                grok_model="x-ai/grok-imagine-image-2.0",
                gemini_model="gemini-3.1-flash-image")

    # 승자 호출의 실제 모델이 있으면 **그것이 답이다**
    assert resolve_generation_model(
        **base, fix_won=True,
        base_call_model="gemini-3.1-flash-image") \
        == "gemini-3.1-flash-image", (
        "재생성 승자를 Grok 으로 적는다 — 그 그림은 Gemini 가 그렸다")

    # ★기존 갈래는 한 글자도 안 바뀐다 — 조회가 비면 종전 규칙
    assert resolve_generation_model(**base, fix_won=True) \
        == "x-ai/grok-imagine-image-2.0"
    assert resolve_generation_model(**base, fix_won=False) \
        == "gemini-3.1-flash-image"
    # 변환이 적용되면 이 값을 아예 안 본다
    assert resolve_generation_model(
        **{**base, "cine_applied": True, "cine_call_model": "reve/2.1/edit"},
        fix_won=True, base_call_model="gemini-3.1-flash-image") \
        == "reve/2.1/edit"


def test_the_selected_roll_is_not_claimed_as_the_repair_input_on_regenerate():
    """★`fix_source_roll` 은 **편집일 때만** 붙어야 한다 — AST 로 본다.

    이 값은 「수리 호출의 직접 입력」이라는 뜻이고 파일·sha 까지 실물이라,
    재생성에 붙으면 **그럴듯한 거짓 계보**가 된다. 재생성 호출은 그 롤을
    아예 안 받았다.

    조립을 함수로 뺄 수 없는 자리(샷 루프 안쪽 지역 변수)라, 여기서는
    **그 값을 만드는 조건**과 **쓰는 자리**를 코드에서 직접 읽는다 —
    조립부를 손으로 흉내내면 붙이는 줄이 통째로 빠져도 초록이 된다.
    """
    import ast

    src = (Path(__file__).resolve().parents[2]
           / "app" / "services" / "still_recipe_service.py").read_text()
    tree = ast.parse(src)

    guards = []
    for node in ast.walk(tree):
        if not isinstance(node, ast.If):
            continue
        body = ast.unparse(node)
        # ★따옴표로 찾지 않는다 — `ast.unparse` 가 큰따옴표를 작은따옴표로
        #  바꿔서 `'"role": "fix_source_roll"'` 로 찾으면 **0건**이 나온다.
        #  실제로 그렇게 썼다가 위 「0건은 못 찾은 것」 가드에 걸렸다.
        if "fix_source_roll" not in body:
            continue
        # 이 if 의 **직접 body** 안에서 만들어진 것만 — 바깥 if 는 제외
        if not any("fix_source_roll" in ast.unparse(c) for c in node.body):
            continue
        guards.append(ast.unparse(node.test))

    assert guards, (
        "`fix_source_roll` 을 만드는 조건문을 못 찾았다 — 조립이 바뀌었으면 "
        "이 시험을 먼저 고쳐라. 0건이 조용히 초록이 되면 안 된다")
    for g in guards:
        assert "regenerate" in g, (
            f"조건이 {g!r} — 수단을 안 본다. 재생성 승자에도 선정 롤이 "
            "「수리 입력」으로 붙어 거짓 계보가 된다")
