"""shot_conti_light v2(원근 가이드 콘티) 결정론 테스트 (BGFIRST2 이식 ②)."""
from pathlib import Path

import pytest

from app.modules.pipeline.shot_conti_light import (
    CONTI_CHAR_REF_MAX,
    build_conti_prompt,
    build_conti_ref_header,
    run_shot_conti_light,
)

CLASSIFY = {
    "S1sh1": {"person_visible": False, "prev": None},
    "S1sh2": {"person_visible": True, "prev": None,
              "place_en": "storefront exterior wall"},
    "S1sh3": {"person_visible": True, "prev": "S1sh2"},
}


def _kw(**over):
    kw = dict(
        shot_desc="남자",
        place_text="골목",
        has_plate=True,
        pose_clauses=[],
        carried_en="",
        movement_en="",
        figures_en="",
    )
    kw.update(over)
    return kw


def test_v1_rejects_camera_frame():
    with pytest.raises(ValueError, match="v2"):
        build_conti_prompt(
            **_kw(camera_frame_en="CAMERA/FRAME ...", prompt_version="1"))


def test_v1_assembly_unchanged_without_new_inputs():
    # 신규 kwargs default("") = 기존 조립 그대로 (byte-identical 게이트)
    assert build_conti_prompt(**_kw()) == build_conti_prompt(
        **_kw(camera_frame_en=""))


def test_v2_appends_camera_then_guides_at_tail():
    p = build_conti_prompt(
        **_kw(camera_frame_en="CAM-CLAUSE-X", prompt_version="2"))
    parts = p.split("\n\n")
    # 정본 순서: [v1 조립…no_text] → CAMERA/FRAME → PERSPECTIVE GUIDES
    assert parts[-2] == "CAM-CLAUSE-X"
    assert parts[-1].startswith("PERSPECTIVE & SCALE GUIDES")
    idx_no_text = next(
        i for i, s in enumerate(parts) if s.startswith("ABSOLUTELY NO TEXT"))
    assert idx_no_text == len(parts) - 3


def test_v2_without_camera_still_has_guides():
    p = build_conti_prompt(**_kw(prompt_version="2"))
    assert "PERSPECTIVE & SCALE GUIDES" in p
    assert "CAM-CLAUSE-X" not in p


def test_ref_header_variants():
    assert build_conti_ref_header(
        has_char_refs=False, prompt_version="1") == ""
    h_plate = build_conti_ref_header(
        has_char_refs=False, prompt_version="2")
    h_char = build_conti_ref_header(
        has_char_refs=True, prompt_version="2")
    assert "LOCATION PHOTOGRAPH" in h_plate
    assert "CHARACTER" not in h_plate
    assert "CHARACTER" in h_char


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

    def __call__(self, tag, prompt, labeled_refs, out_path: Path):
        self.calls.append(
            {"tag": tag, "prompt": prompt, "refs": list(labeled_refs)})
        out_path.parent.mkdir(parents=True, exist_ok=True)
        out_path.write_bytes(b"c")
        return out_path


def _run(tmp_path, gen, **over):
    plate = tmp_path / "plate.png"
    plate.write_bytes(b"p")
    kw = dict(
        shots=[
            {"scene_index": 1, "shot_index": 1, "description": "빈 방"},
            {"scene_index": 1, "shot_index": 2, "description": "남자"},
            {"scene_index": 1, "shot_index": 3, "description": "이어짐"},
        ],
        classify_shots=CLASSIFY,
        continuity={"pose_canon": [], "carried": {}, "pose_fix": {}},
        location_by_scene={1: "골목"},
        plate_map={"1_2": plate},
        out_dir=tmp_path / "conti",
        gen_fn=gen,
    )
    kw.update(over)
    return run_shot_conti_light(**kw)


def test_v1_run_refs_plate_only_and_scene_place(tmp_path):
    gen = _Gen()
    _run(tmp_path, gen)
    call = gen.calls[0]
    assert [lab for lab, _ in call["refs"]] == ["plate"]
    assert "THE LOCATION: 골목" in call["prompt"]
    # v2 계약 미노출
    assert "PERSPECTIVE & SCALE GUIDES" not in call["prompt"]


def test_v2_run_shot_place_staging_and_char_refs(tmp_path):
    gen = _Gen()
    char1 = tmp_path / "c1.png"
    char2 = tmp_path / "c2.png"
    char3 = tmp_path / "c3.png"
    for p in (char1, char2, char3):
        p.write_bytes(b"x")
    staging = {
        "1_2": {"camera_direction": "eye-level side view",
                "framing_scale": "", "scene_index": 1, "shot_index": 2},
    }
    _run(
        tmp_path, gen,
        prompt_version="2",
        classify_scenes={"1": {"place_en": "scene-level place"}},
        staging_by_key=staging,
        char_refs_by_tag={
            "S1sh2": [("남자", char1), ("여자", char2), ("행인", char3)],
        },
    )
    call = gen.calls[0]
    # 샷별 place_en 최우선 (scenes/location fallback 이 아님)
    assert "THE LOCATION: storefront exterior wall" in call["prompt"]
    # staging CAMERA/FRAME 절 렌더 주입
    assert "eye-level side view" in call["prompt"]
    assert "PERSPECTIVE & SCALE GUIDES" in call["prompt"]
    # GPT ref 서두 설명 (캐릭터 참조 존재 분기)
    assert call["prompt"].startswith("The FIRST attached image")
    # 참조 = 플레이트 + 캐릭터 상한 2
    labels = [lab for lab, _ in call["refs"]]
    assert labels == ["plate", "char:남자", "char:여자"]
    assert len(labels) - 1 == CONTI_CHAR_REF_MAX


def test_v2_place_fallback_to_scene_then_location(tmp_path):
    gen = _Gen()
    classify = {
        "S1sh1": {"person_visible": False, "prev": None},
        "S1sh2": {"person_visible": True, "prev": None},  # place_en 없음
        "S1sh3": {"person_visible": True, "prev": "S1sh2"},
    }
    _run(
        tmp_path, gen,
        classify_shots=classify,
        prompt_version="2",
        classify_scenes={"1": {"place_en": "scene-level place"}},
    )
    assert "THE LOCATION: scene-level place" in gen.calls[0]["prompt"]


def test_v2_fingerprint_covers_char_refs(tmp_path):
    """캐릭터 참조 내용 변경 = 지문 변화 → 재생성."""
    char1 = tmp_path / "c1.png"
    char1.write_bytes(b"x")
    refs = {"S1sh2": [("남자", char1)]}
    gen = _Gen()
    _run(tmp_path, gen, prompt_version="2", char_refs_by_tag=refs)
    assert len(gen.calls) == 1
    # 동일 입력 재실행 = 재사용 (생성 0)
    gen2 = _Gen()
    _run(tmp_path, gen2, prompt_version="2", char_refs_by_tag=refs)
    assert len(gen2.calls) == 0
    # 참조 내용 변경 = 재생성
    char1.write_bytes(b"CHANGED")
    gen3 = _Gen()
    _run(tmp_path, gen3, prompt_version="2", char_refs_by_tag=refs)
    assert len(gen3.calls) == 1


# ── Codex 리뷰 3 반영: v2 실제 입력 전체가 lineage 로 이어진다 ────────


def test_v2_entries_record_ref_paths(tmp_path):
    gen = _Gen()
    char1 = tmp_path / "c1.png"
    char1.write_bytes(b"x")
    out = _run(
        tmp_path, gen, prompt_version="2",
        char_refs_by_tag={"S1sh2": [("남자", char1)]},
    )
    entry = out["contis"]["S1sh2"]
    assert [lab for lab, _p in entry["ref_paths"]] == ["plate", "char:남자"]
    assert entry["ref_paths"][1][1] == str(char1)


def test_v1_entries_have_no_ref_paths_key(tmp_path):
    gen = _Gen()
    out = _run(tmp_path, gen)
    assert "ref_paths" not in out["contis"]["S1sh2"]  # CP shape 불변


def test_register_uses_ref_paths_for_inputs(tmp_path):
    """v2 콘티 등록 input_image_ids = 플레이트 + 캐릭터 참조 전부."""
    from app.modules.pipeline.shot_conti_light import (
        register_intermediate_assets,
    )

    conti_png = tmp_path / "conti_S1sh2.png"
    conti_png.write_bytes(b"c")
    plate = tmp_path / "plate.png"
    plate.write_bytes(b"p")
    char1 = tmp_path / "c1.png"
    char1.write_bytes(b"x")

    class _A:
        def __init__(self, aid):
            self.id = aid
            self.generation_model = None
            self.prompt_used = None

    assets = {
        str(plate): _A("plate-uuid"),
        str(char1): _A("char-uuid"),
    }
    annotated = {}

    def annotate_fn(asset, *, role, input_ids, meta):
        annotated[asset.id] = {"input_ids": input_ids, "meta": meta}

    new_ids = []

    def new_asset(*, rel, tag, asset_type, model, prompt):
        a = _A(f"new-{tag}")
        assets[rel] = a
        new_ids.append(a.id)
        return a

    entry = {
        "image_path": str(conti_png),
        "plate_path": str(plate),
        "prompt": "P",
        "skipped_reason": None,
        "error": None,
        "ref_paths": [["plate", str(plate)], ["char:남자", str(char1)]],
    }
    register_intermediate_assets(
        contis={"S1sh2": entry},
        map_plates={},
        rel_fn=lambda p: str(p),
        find_asset_by_rel=lambda rel: assets.get(rel),
        new_asset=new_asset,
        annotate_fn=annotate_fn,
        conti_model="gpt-image-2",
        plate_model="gpt-image-2",
    )
    assert entry["asset_id"] == "new-S1sh2"
    assert annotated["new-S1sh2"]["input_ids"] == ["plate-uuid", "char-uuid"]


def test_register_without_ref_paths_keeps_plate_only(tmp_path):
    from app.modules.pipeline.shot_conti_light import (
        register_intermediate_assets,
    )

    conti_png = tmp_path / "conti_S1sh2.png"
    conti_png.write_bytes(b"c")
    plate = tmp_path / "plate.png"
    plate.write_bytes(b"p")

    class _A:
        def __init__(self, aid):
            self.id = aid
            self.generation_model = None
            self.prompt_used = None

    assets = {str(plate): _A("plate-uuid")}
    annotated = {}

    def annotate_fn(asset, *, role, input_ids, meta):
        annotated[asset.id] = input_ids

    entry = {
        "image_path": str(conti_png), "plate_path": str(plate),
        "prompt": "P", "skipped_reason": None, "error": None,
    }
    register_intermediate_assets(
        contis={"S1sh2": entry}, map_plates={},
        rel_fn=lambda p: str(p),
        find_asset_by_rel=lambda rel: assets.get(rel),
        new_asset=lambda *, rel, tag, asset_type, model, prompt: (
            assets.setdefault(rel, _A(f"new-{tag}"))),
        annotate_fn=annotate_fn,
        conti_model="gpt-image-2", plate_model="gpt-image-2",
    )
    assert annotated["new-S1sh2"] == ["plate-uuid"]


# ── no_plate 콘티 (E2E10 fix④ 전제) ──────────────────────────────────


def test_no_plate_conti_default_off_keeps_skip(tmp_path):
    def gen(tag, prompt, labeled_refs, out_path):
        out_path.write_bytes(b"png")

    res = _run(tmp_path, gen, plate_map={})
    assert res["contis"]["S1sh2"]["skipped_reason"] == "no_plate"


def test_no_plate_conti_generates_without_plate_ref(tmp_path):
    seen = {}

    def gen(tag, prompt, labeled_refs, out_path):
        seen[tag] = {"prompt": prompt, "refs": list(labeled_refs)}
        out_path.write_bytes(b"png")

    char = tmp_path / "char.png"
    char.write_bytes(b"c")
    res = _run(
        tmp_path, gen, plate_map={}, no_plate_conti=True,
        prompt_version="3",
        char_refs_by_tag={"S1sh2": [("수리", char)]},
    )
    e = res["contis"]["S1sh2"]
    assert e["skipped_reason"] is None
    assert e["no_plate"] is True
    assert e["image_path"] and e["plate_path"] is None
    refs = seen["S1sh2"]["refs"]
    assert all(lab != "plate" for lab, _ in refs)
    assert any(lab.startswith("char:") for lab, _ in refs)
    prompt = seen["S1sh2"]["prompt"]
    assert "LOCATION PHOTOGRAPH" not in prompt  # bg_ref/플레이트 헤더 부재
    assert "CHARACTER REFERENCE" in prompt  # char 전용 헤더


def test_no_plate_conti_requires_v3_pack(tmp_path):
    def gen(tag, prompt, labeled_refs, out_path):
        out_path.write_bytes(b"png")

    with pytest.raises(ValueError):
        _run(tmp_path, gen, plate_map={}, no_plate_conti=True,
             prompt_version="2")


def test_no_plate_conti_plated_shot_unchanged(tmp_path):
    """플레이트 실재 샷은 no_plate_conti ON 에서도 기존 조립 그대로."""
    seen = {}

    def gen(tag, prompt, labeled_refs, out_path):
        seen[tag] = list(labeled_refs)
        out_path.write_bytes(b"png")

    plate = tmp_path / "plate.png"
    plate.write_bytes(b"p")
    res = _run(tmp_path, gen, plate_map={"1_2": plate},
               no_plate_conti=True, prompt_version="3")
    e = res["contis"]["S1sh2"]
    assert e["skipped_reason"] is None and not e.get("no_plate")
    tag = next(k for k in seen)
    assert any(lab == "plate" for lab, _ in seen[tag])


# ── 팩 v4 (E2E11 fix②⑤): 소품 방향+자연 연기 절 ──────────────────────


def test_v4_appends_prop_orientation_and_naturalism():
    p = build_conti_prompt(**_kw(prompt_version="4"))
    assert "PROPS FACE THE RIGHT WAY" in p
    assert "NATURAL PERFORMANCE" in p
    # 순서: 두 절 → no_text → (persp) 가이드
    assert p.index("PROPS FACE") < p.index("NATURAL PERFORMANCE")
    assert p.index("NATURAL PERFORMANCE") < p.index("ABSOLUTELY NO TEXT")
    assert "PERSPECTIVE & SCALE GUIDES" in p  # v4=persp 팩 유지


def test_v3_assembly_unchanged_by_v4_stems():
    p = build_conti_prompt(**_kw(prompt_version="3"))
    assert "PROPS FACE THE RIGHT WAY" not in p
    assert "NATURAL PERFORMANCE" not in p


def test_v4_supports_no_plate_header():
    h = build_conti_ref_header(
        has_char_refs=True, prompt_version="4", has_plate=False)
    assert "CHARACTER REFERENCE" in h and "no location photograph" in h.lower()


# ── 팩 v5 (E2E13 fix①⑦): 키샷 레이아웃 가치 절 ──────────────────────


def test_v5_appends_keyshot_clause():
    p = build_conti_prompt(**_kw(prompt_version="5"))
    assert "KEYSHOT LAYOUT VALUE" in p
    # v4 계약 승계 + 순서: conduct 절들 뒤, no_text 앞
    assert "PROPS FACE THE RIGHT WAY" in p
    assert "NATURAL PERFORMANCE" in p
    assert p.index("NATURAL PERFORMANCE") < p.index("KEYSHOT LAYOUT VALUE")
    assert p.index("KEYSHOT LAYOUT VALUE") < p.index("ABSOLUTELY NO TEXT")
    assert "PERSPECTIVE & SCALE GUIDES" in p  # v5=persp 팩 유지


def test_v4_assembly_unchanged_by_v5_stems():
    p = build_conti_prompt(**_kw(prompt_version="4"))
    assert "KEYSHOT LAYOUT VALUE" not in p


def test_v5_supports_no_plate_header():
    h = build_conti_ref_header(
        has_char_refs=True, prompt_version="5", has_plate=False)
    assert "CHARACTER REFERENCE" in h
