"""육안 8건 wave (2026-08-13, #106) — 심각도 게이트·ab 변주·팩 v18/v10 계약.

결정론 층만 잠근다(LLM 품질 아님): ①critical 만 fix 를 발동시키는 코드
게이트(S39sh4: major 소파 지적이 fix 를 발동시켜 인물이 바뀐 실측이 근거)
②2롤 ab 변주 맵이 백엔드 불문 둘째 롤부터 변주 절을 동반 ③신규 팩 스템의
실재·로드·주입.
"""
from __future__ import annotations

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

from app.modules.pipeline.multiroll_gemini import (
    QK_CRITIQUE_PACK_VERSION,
    STILL_FIX_REJUDGE_HEADER_PACK_VERSION,
    load_fix_rejudge_header,
    resolve_judge_pack_version,
)
from app.modules.pipeline.multiroll_select import (
    build_critique_schema,
    run_multiroll_select,
)
from app.modules.pipeline.still_recipe import (
    IDENTITY_ROLE_PROMPT_VERSION,
    SUPPORT_STILLNESS_PROMPT_VERSION,
    build_ab_roll_prompt_map,
    build_identity_ref_role_clause,
    build_still_prompt,
    resolve_prompt_version,
)

FIX_HEAD = "Edit this photograph. Apply ONLY the corrections listed below:"
FIX_TAIL = "PRESERVE EVERYTHING ELSE EXACTLY."


class FakeGen:
    def __init__(self):
        self.calls: List[Any] = []

    def __call__(self, tag, prompt, labeled_refs, out_path: Path) -> Path:
        self.calls.append((tag, prompt, list(labeled_refs), out_path))
        out_path.parent.mkdir(parents=True, exist_ok=True)
        key = out_path.stem.rsplit("_", 1)[-1]
        out_path.write_bytes(f"img:{key}".encode())
        return out_path


def _judge(tag, prompt, labeled_refs, cand_paths, labels):
    # 라벨 계약=대문자(roll_labels) — B 를 단독 최고점으로 선정.
    return {
        "winner": "B",
        "ranking": list(reversed(list(labels))),
        "verdicts": [
            {"label": lab, "score": (9 if lab == "B" else 5),
             "verdict_ko": ""} for lab in labels
        ],
    }


def _run(tmp_path: Path, issues: List[Dict[str, Any]]):
    fix_gen = FakeGen()

    def critique_fn(tag, prompt, labeled_refs, image_path):
        return {"issues": issues}

    (tmp_path / "ref.png").write_bytes(b"ref")
    sel, record = run_multiroll_select(
        tag="t1",
        prompt="PROMPT",
        labeled_refs=[("REF", tmp_path / "ref.png")],
        out_stem=tmp_path / "out" / "s1",
        gen_fn=FakeGen(),
        judge_fn=_judge,
        critique_fn=critique_fn,
        fix_gen_fn=fix_gen,
        roll_count=2,
        critique_enabled=True,
        fix_head=FIX_HEAD,
        fix_tail=FIX_TAIL,
        fix_label="PHOTOGRAPH TO EDIT",
        record=None,
    )
    return sel, record, fix_gen


# ── 심각도 게이트 ──────────────────────────────────────────────────────


def test_severity_gate_skips_fix_when_nothing_critical(tmp_path):
    """S39sh4 재발 방지: major/minor 뿐이면 fix 자체가 없다 — 원본 확정."""
    sel, record, fix_gen = _run(tmp_path, [
        {"issue_ko": "가구 소실", "fix_en": "Restore the sofa.",
         "severity": "major"},
        {"issue_ko": "바닥 무늬", "fix_en": "Match the floor grain.",
         "severity": "minor"},
    ])
    assert fix_gen.calls == []
    assert sel.read_bytes() == b"img:b"
    assert record["fix_skipped"] is True
    assert record["fix_skip_reason"] == "no_critical_issue"
    assert record["fix_severity_skipped_count"] == 2


def test_severity_gate_fixes_only_critical(tmp_path):
    sel, record, fix_gen = _run(tmp_path, [
        {"issue_ko": "다른 인물", "fix_en": "Replace with the referenced man.",
         "severity": "critical"},
        {"issue_ko": "가구 소실", "fix_en": "Restore the sofa.",
         "severity": "major"},
    ])
    assert len(fix_gen.calls) == 1
    fix_prompt = fix_gen.calls[0][1]
    assert "Replace with the referenced man." in fix_prompt
    assert "Restore the sofa." not in fix_prompt
    assert record["fix_severity_skipped_count"] == 1


def test_severity_gate_absent_keeps_legacy_behavior(tmp_path):
    """severity 필드가 전무한 구 팩 경로(v7 critique·GQ v8)는 기존 그대로."""
    sel, record, fix_gen = _run(tmp_path, [
        {"issue_ko": "가구 소실", "fix_en": "Restore the sofa."},
    ])
    assert len(fix_gen.calls) == 1
    assert "Restore the sofa." in fix_gen.calls[0][1]
    assert "fix_severity_skipped_count" not in record


# ── critique 스키마 ────────────────────────────────────────────────────


def test_critique_schema_default_has_no_severity():
    item = build_critique_schema()["properties"]["issues"]["items"]
    assert "severity" not in item["properties"]
    assert item["required"] == ["issue_ko", "fix_en"]


def test_critique_schema_with_severity_requires_it():
    item = build_critique_schema(
        with_severity=True)["properties"]["issues"]["items"]
    assert item["properties"]["severity"]["enum"] == [
        "critical", "major", "minor"]
    assert "severity" in item["required"]
    # Codex R1 BLOCK-1: 관찰 인덱스 왕복도 스키마 강제
    assert item["properties"]["observation_index"] == {"type": "integer"}
    assert "observation_index" in item["required"]


def test_roll_count_validator_locks_two():
    """Codex R1 BLOCK-2: 낡은 env(3)가 과금 1.5배+b·c 동일 변주로 새는
    창 — 설정 층에서 2 외 전부 fail-fast."""
    import pytest

    from app.core.config import Settings

    assert Settings._still_recipe_roll_count_range(2) == 2
    for bad in (1, 3, 5):
        with pytest.raises(ValueError):
            Settings._still_recipe_roll_count_range(bad)


# ── 2롤 ab 변주 (전 백엔드) ────────────────────────────────────────────


def test_ab_roll_prompt_map_varies_second_roll():
    m = build_ab_roll_prompt_map("BASE PROMPT", ["a", "b"])
    assert m["a"] == "BASE PROMPT"
    assert m["b"].startswith("BASE PROMPT\n\n")
    assert "COMPOSITION VARIATION" in m["b"]


def test_ab_roll_prompt_map_single_label_is_base():
    assert build_ab_roll_prompt_map("BASE", ["a"]) == {"a": "BASE"}


# ── 팩 v18/v10 스템 실재·로드 ─────────────────────────────────────────


def test_still_recipe_v18_resolves_and_loads():
    assert resolve_prompt_version("18") == "18.202608131121"
    assert IDENTITY_ROLE_PROMPT_VERSION == "18"
    assert SUPPORT_STILLNESS_PROMPT_VERSION == "18"
    clause = build_identity_ref_role_clause(True)
    assert "CHARACTER REFERENCE ROLE" in clause
    assert "covered or hidden" in clause  # 얼굴 가림 우선 조항 (S65sh10)
    assert build_identity_ref_role_clause(False) == ""


def test_judge_v10_resolves_and_loads():
    assert resolve_judge_pack_version("10") == "10.202608131121"
    assert QK_CRITIQUE_PACK_VERSION == "10"
    assert STILL_FIX_REJUDGE_HEADER_PACK_VERSION == "10"
    header = load_fix_rejudge_header(STILL_FIX_REJUDGE_HEADER_PACK_VERSION)
    assert "IDENTITY IS NOT TRADABLE" in header  # S39sh4 인물 변형 축


def test_roll_prompts_fold_into_fingerprint(tmp_path):
    """Codex R2: b 변주 절은 roll_prompts 에 실린다 — 공용 지문이 라벨별
    전문 해시를 직접 접어, 변주 스템 개정이 그 절을 실은 샷만 정확히
    stale 시킨다. roll_prompts 미사용 소비자(배경 플레이트류)는 키 부재
    = 기존 지문 보존."""
    def one(sub: str, roll_prompts):
        (tmp_path / sub).mkdir()
        (tmp_path / sub / "ref.png").write_bytes(b"ref")
        kw = dict(
            tag="t1", prompt="PROMPT",
            labeled_refs=[("REF", tmp_path / sub / "ref.png")],
            out_stem=tmp_path / sub / "out" / "s1",
            gen_fn=FakeGen(), judge_fn=_judge,
            critique_fn=None, fix_gen_fn=None,
            roll_count=2, critique_enabled=False,
            fix_head=FIX_HEAD, fix_tail=FIX_TAIL,
            fix_label="PHOTOGRAPH TO EDIT", record=None,
        )
        if roll_prompts is not None:
            kw["roll_prompts"] = roll_prompts
        _, record = run_multiroll_select(**kw)
        return record["input_fingerprint"]

    fp_v1 = one("v1", {"A": "PROMPT", "B": "PROMPT\n\nVARIATION 1"})
    fp_v1_again = one("v1b", {"A": "PROMPT", "B": "PROMPT\n\nVARIATION 1"})
    fp_v2 = one("v2", {"A": "PROMPT", "B": "PROMPT\n\nVARIATION 2"})
    fp_none = one("v0", None)
    assert fp_v1 == fp_v1_again          # 결정론
    assert fp_v1 != fp_v2                # b 변주 개정 → stale
    assert fp_none not in (fp_v1, fp_v2)  # 키 부재 지문은 별개(구 소비자)


def test_recipe_stem_content_hash_exact_stem():
    """Codex R2: 스템 단위 해시 — 같은 팩의 동거 스템과 무관해야 한다."""
    import hashlib

    from app.modules.pipeline.still_recipe import (
        recipe_stem_content_hash,
        resolve_prompt_version,
    )

    got = recipe_stem_content_hash("18", "support_stillness_clause")
    p = (Path(__file__).resolve().parents[3] / "prompts" / "_base"
         / "still_recipe" / resolve_prompt_version("18")
         / "support_stillness_clause.md")
    assert got == hashlib.sha256(p.read_bytes()).hexdigest()[:16]
    import pytest

    with pytest.raises(ValueError):
        recipe_stem_content_hash("18", "no_such_stem")


def test_support_stillness_clause_injected_for_people_only():
    kw = dict(
        shot_desc="남자가 문을 연다",
        place_text="허름한 방.",
        time_of_day_en="night",
        world_anchor="",
        bg_only=False,
        prev_used=False,
        prev_usage_en="",
        pose_clauses=[],
        movement_en="",
        figures_en="",
        carried_en="",
        char_names=["남자 (male, 40s, stocky)"],
    )
    people = build_still_prompt(**kw)
    assert "BODY & SUPPORT" in people
    bg = build_still_prompt(**{**kw, "bg_only": True, "char_names": []})
    assert "BODY & SUPPORT" not in bg
