"""confined_fp 부품 — 팩 로드·스키마·브리프 절 처리·fail-closed·흐름.

설계 SOT: docs/superpowers/specs/2026-08-10-confined-interior-fp-readback-production-design.md
대역은 외부(gpt 이미지·VLM readback)만 — 절 발췌·제거·흐름 분기는 실물.
"""
from __future__ import annotations

import json
from typing import Any, Dict

import pytest

import app.modules.pipeline.confined_fp as cf

BRIEF = """HEAD LINE
SHOT TEXT (authoritative, Korean): 조종석의 인물이 계기반을 확인한다.
LOCATION (lock): The pilot's station inside the machine.
CAMERA & FRAME (follow exactly — composition authority):
- KEY BACKGROUND ELEMENTS: control yoke (Held) — angled away.
Compose the frame exactly as specified above — camera angle.
- LIGHTING & MOOD: Low-key interior.
TAIL LINE"""


def test_pack_stems_load_and_hash():
    # v1 (샷 단독 흐름 — 기록·재현용)
    for stem in ("fp_head", "fp_readback_sys", "fp_fix_head", "gen_head"):
        assert cf._load(stem, "1").strip()
    # v2 (현행 — base 공유+샷 마커)
    for stem in ("base_fp_head", "shot_fp_mark_head", "fp_readback_sys",
                 "fp_fix_head", "gen_head"):
        assert cf._load(stem).strip()
    # 범용 계약 — 공간 실체·조작 장치는 LOCATION 텍스트가 정하고, 나라
    # 의존 장치는 방향 하드코딩 없이 나라 유도 문구만. v2 핵심=간략 명문.
    head = cf._load("base_fp_head")
    assert "LOCATION" in head
    assert "country" in head          # 나라 의존 — 방향 하드코딩 없음
    assert "NOTHING else" in head     # 디테일 금지 명문
    assert "steering wheel, drawn attached" not in head  # v1 자동차 강제 없음
    h1 = cf.confined_fp_pack_content_hash("1")
    h2 = cf.confined_fp_pack_content_hash()
    assert len(h2) == 16 and h1 != h2
    int(h2, 16)


def test_unknown_pack_selector_fails():
    with pytest.raises(ValueError):
        cf.resolve_confined_fp_pack("999")


def test_extract_sections_and_context():
    sec = cf.extract_brief_sections(BRIEF)
    assert sec["shot_text"].startswith("SHOT TEXT")
    assert sec["location"].startswith("LOCATION")
    assert "control yoke" in sec["camera"]
    assert sec["camera"].rstrip().endswith("camera angle.")
    assert sec["lighting"].startswith("- LIGHTING")
    ctx = cf.fp_context(sec)
    assert "SHOT TEXT" in ctx and "LOCATION" in ctx


def test_strip_camera_block_removes_only_camera():
    out = cf.strip_camera_block(BRIEF)
    assert "CAMERA & FRAME" not in out
    assert "control yoke" not in out
    assert "Compose the frame" not in out
    # 나머지 절은 보존
    for keep in ("HEAD LINE", "SHOT TEXT", "LOCATION", "LIGHTING",
                 "TAIL LINE"):
        assert keep in out


def test_readback_schema_shape():
    s = cf.build_fp_readback_schema()
    assert set(s["properties"]["reads"]["properties"]) == {
        "controls", "mirrors", "camera", "occupants"}
    json.dumps(s)


def _wire(monkeypatch, tmp_path, mismatch_rounds):
    """gpt 작화·readback 대역 — 호출 횟수·지시 내용 검증용.

    mismatch_rounds: 각 readback 호출이 돌려줄 mismatches 목록의 목록.
    """
    calls = {"gen": [], "rb": 0}

    def fake_gpt(prompt, out_path, ref=None, capture_role="confined_fp"):
        calls["gen"].append({"ref": ref, "prompt": prompt})
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 8)

    def fake_readback(step_tag, fp_path, ctx, pc, om) -> Dict[str, Any]:
        i = min(calls["rb"], len(mismatch_rounds) - 1)
        mm = mismatch_rounds[i]
        calls["rb"] += 1
        return {"reads": {"controls": "-", "mirrors": "-", "camera": "-",
                          "occupants": "-"},
                "mismatches": mm,
                "scene_description_en": "layout description"}

    monkeypatch.setattr(cf, "_gpt_fp_image", fake_gpt)
    monkeypatch.setattr(cf, "_readback", fake_readback)
    return calls


def test_produce_flow_no_mismatch_single_gen(monkeypatch, tmp_path):
    calls = _wire(monkeypatch, tmp_path, [[]])
    fp, rb = cf.produce_confined_fp(
        tag="t1", brief=BRIEF, out_dir=tmp_path)
    assert fp.exists() and rb["fixed"] is False
    assert len(calls["gen"]) == 1 and calls["rb"] == 1


def test_produce_flow_mismatch_fixes_once_with_i2i(monkeypatch, tmp_path):
    calls = _wire(monkeypatch, tmp_path, [["camera wrong"], []])
    fp, rb = cf.produce_confined_fp(
        tag="t2", brief=BRIEF, out_dir=tmp_path)
    assert rb["fixed"] is True
    # 2회 작화 — 둘째는 i2i(ref 있음) + 지적이 지시에 실림
    assert len(calls["gen"]) == 2
    assert calls["gen"][1]["ref"] is not None
    assert "camera wrong" in calls["gen"][1]["prompt"]
    assert calls["rb"] == 2


def test_produce_fails_closed_without_sections(monkeypatch, tmp_path):
    _wire(monkeypatch, tmp_path, [[]])
    with pytest.raises(ValueError):
        cf.produce_confined_fp(
            tag="t3", brief="아무 헤더도 없는 본문", out_dir=tmp_path)


def test_produce_fails_closed_on_empty_description(monkeypatch, tmp_path):
    calls = _wire(monkeypatch, tmp_path, [[]])

    def empty_rb(step_tag, fp_path, ctx, pc, om):
        calls["rb"] += 1
        return {"reads": {"controls": "-", "mirrors": "-", "camera": "-",
                          "occupants": "-"},
                "mismatches": [], "scene_description_en": " "}

    import app.modules.pipeline.confined_fp as _cf
    _cf._readback, saved = empty_rb, _cf._readback
    try:
        with pytest.raises(ValueError):
            cf.produce_confined_fp(
                tag="t4", brief=BRIEF, out_dir=tmp_path)
    finally:
        _cf._readback = saved


def test_gen_prompt_composition():
    p = cf.build_confined_gen_prompt("DESC LAYOUT", BRIEF)
    assert p.index("SCENE LAYOUT") < p.index("DESC LAYOUT")
    assert "CAMERA & FRAME" not in p  # 배치 문자 제거
    assert "SHOT TEXT" in p           # 브리프 본문은 보존
    assert "floor plan" in p.lower()


def test_flag_default_off():
    # 코드 기본값 검사 — 라이브 settings 인스턴스는 운영 .env 를 읽으므로
    # (2026-08-11 이후 STILL_CONFINED_FP_ENABLED=true 로 운용) 환경에
    # 따라 흔들린다. 계약은 "필드 default=OFF" 이다.
    from app.core.config import Settings

    assert Settings.model_fields["still_confined_fp_enabled"].default is False


# ── v2 — base 공유 도면 + 샷 마커 + 적용 판별 ─────────────────────────

V2_BRIEF = BRIEF + """
CARRIED STATE (persist exactly): the second person remains bound on
the rear bench, unconscious.
PEOPLE: the SHOT TEXT alone decides visibility.
NATURAL PERFORMANCE (default only): keep it real."""


def test_extract_sections_carried_people_blocks():
    sec = cf.extract_brief_sections(V2_BRIEF)
    assert sec["carried"].startswith("CARRIED STATE")
    assert "rear bench" in sec["carried"]
    # 다음 대문자 헤더(PEOPLE:)에서 블록이 끊겨야 한다
    assert "PEOPLE" not in sec["carried"].replace("PEOPLE:", "")
    assert sec["people"].startswith("PEOPLE:")
    assert "NATURAL PERFORMANCE" not in sec["people"]
    # fp_context 에 인물 절이 실린다 (readback 오탐 방지)
    assert "rear bench" in cf.fp_context(sec)


def test_applicability_schema_shape():
    s = cf.build_applicability_schema()
    assert set(s["properties"]) == {"applies", "reason_ko"}


def test_produce_base_fp_fails_closed_without_location(tmp_path):
    with pytest.raises(ValueError):
        cf.produce_base_fp("sp", "  ", tmp_path / "b.png")


def test_produce_shot_fp_marks_then_fixes(monkeypatch, tmp_path):
    calls = {"gen": [], "rb": 0}

    def fake_gpt(prompt, out_path, ref=None, capture_role=""):
        calls["gen"].append({"ref": ref, "prompt": prompt,
                             "role": capture_role})
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"0" * 8)

    rounds = [["camera misplaced"], []]

    def fake_rb(step_tag, fp_path, ctx, pc, om):
        mm = rounds[min(calls["rb"], 1)]
        calls["rb"] += 1
        # readback 대조 입력에 인물 절이 실려야 한다 (오탐 방지 계약)
        assert "rear bench" in ctx
        return {"reads": {"controls": "-", "mirrors": "-", "camera": "-",
                          "occupants": "-"},
                "mismatches": mm, "scene_description_en": "layout"}

    monkeypatch.setattr(cf, "_gpt_fp_image", fake_gpt)
    monkeypatch.setattr(cf, "_readback", fake_rb)
    base = tmp_path / "base.png"
    base.write_bytes(b"\x89PNG\r\n\x1a\n")
    sec = cf.extract_brief_sections(V2_BRIEF)
    fp, rb = cf.produce_shot_fp("t1", base, sec, tmp_path / "shot.png")
    assert rb["fixed"] is True and calls["rb"] == 2
    # 1차=마커(i2i, base 참조 + 인물 절이 지시에 실림), 2차=수정(지적 실림)
    assert calls["gen"][0]["ref"] == base
    assert "rear bench" in calls["gen"][0]["prompt"]
    assert "camera misplaced" in calls["gen"][1]["prompt"]


def test_strip_confined_brief_removes_prev_ref_blocks():
    brief = (V2_BRIEF
             + "\nTHIS SHOT CONTINUES THE PREVIOUS SHOT: keep the place.\n"
             + "continuation detail line\n"
             + "PREVIOUS STILL USAGE (follow exactly): take the interior.\n"
             + "usage detail line\n"
             + "PROPS FACE THE RIGHT WAY: keep orientation.")
    out = cf.strip_confined_brief(brief)
    # 첨부되지 않는 prev 참조 전제 절은 통째로 제거 (Codex BLOCK-4)
    assert "PREVIOUS SHOT" not in out and "continuation detail" not in out
    assert "PREVIOUS STILL USAGE" not in out and "usage detail" not in out
    assert "CAMERA & FRAME" not in out
    # 나머지 절은 보존
    for keep in ("SHOT TEXT", "CARRIED STATE", "PROPS FACE THE RIGHT WAY"):
        assert keep in out


def test_strip_removes_all_non_attached_authorities_real_assembly():
    """Codex 재리뷰 BLOCK-2 회귀 — 실제 build_still_prompt 3모드 조립에서
    첨부되지 않는 사진 권위 문구가 strip 후 잔존 0 이어야 한다."""
    from app.modules.pipeline.still_recipe import build_still_prompt

    base_kw: Dict[str, Any] = dict(
        shot_desc="누군가 조종석에서 계기반을 확인한다",
        place_text="The pilot station inside the machine.",
        time_of_day_en="night", bg_only=False, prev_used=False)
    briefs = {
        "default": build_still_prompt(**base_kw),
        "structure_seed": build_still_prompt(
            **base_kw, structure_seed_attached=True,
            prompt_version="4"),  # structure_look 계약은 v4 전용 팩
        "seed_bg": build_still_prompt(
            **base_kw, seed_bg_mode=True,
            prompt_version="5"),  # seed-bg 단일 권위 계약은 v5 전용 팩
    }
    for mode, brief in briefs.items():
        out = cf.strip_confined_brief(brief)
        for bad in ("LOCATION PHOTOGRAPH", "STRUCTURE LOOK",
                    "LOCATION STRUCTURE PHOTOGRAPH"):
            assert bad not in out, (mode, bad)
        # 장소 텍스트 자체는 보존 — 꼬리만 잘린다
        assert "The pilot station inside the machine." in out, mode


def test_fingerprints_move_on_input_change(monkeypatch, tmp_path):
    """존재 캐시 금지(Codex BLOCK-2) — 재료가 바뀌면 지문이 움직인다."""
    from app.core.config import settings

    a1 = cf.apt_fingerprint("brief A")
    assert a1 != cf.apt_fingerprint("brief B")
    monkeypatch.setattr(settings, "gemini_text_model", "other-physical")
    assert a1 != cf.apt_fingerprint("brief A")

    b1 = cf.base_fingerprint("place A")
    assert b1 != cf.base_fingerprint("place B")
    monkeypatch.setattr(settings, "openai_image_model", "other-gpt")
    assert b1 != cf.base_fingerprint("place A")

    base = tmp_path / "b.png"
    base.write_bytes(b"\x89PNG\r\n\x1a\nAAAA")
    sec = cf.extract_brief_sections(V2_BRIEF)
    s1 = cf.shot_fingerprint(base, sec)
    base.write_bytes(b"\x89PNG\r\n\x1a\nBBBB")  # base bytes 변경
    assert s1 != cf.shot_fingerprint(base, sec)


def test_produce_shot_fp_fails_closed_without_mark_ctx(monkeypatch, tmp_path):
    monkeypatch.setattr(cf, "_gpt_fp_image", lambda *a, **k: None)
    base = tmp_path / "base.png"
    base.write_bytes(b"\x89PNG\r\n\x1a\n")
    with pytest.raises(ValueError):
        cf.produce_shot_fp("t2", base, {"shot_text": ""},
                           tmp_path / "s.png")
