"""대상 샷을 **구조 조건만으로 결정적으로** 고른다. ★유료 0.

Codex 2026-08-31 —

> 사람에게 후보를 골라 달라고 하지 마십시오(HITL 금지). preflight 가 실제
> completed checkpoint 에서 구조 조건만으로 결정적으로 고릅니다. 복수면
> (project_id,episode_id,scene_index,shot_index,still_id) 정본 정렬 첫 행.
> 0개면 억지 후보를 만들거나 다른 원고 자산을 섞지 말고 provider 0 으로
> 「eligible shot 없음」을 보고하십시오.

★★「0 이다」를 말하려면 **찾을 수 있다는 것부터** 보여야 한다
([[feedback-not-found-is-not-absent]]). 그래서 이 파일에는 **찾아내는 판**이
먼저 있고, 조건마다 **걸리는 판**이 뒤따른다.
"""
from __future__ import annotations

import hashlib
import json

import pytest

from app.modules.pipeline import grounding_reference_bundle as gb
from tools.grounding_audit import bundle_canary_preflight as pf

CTX = "그 장소가 어떤 곳인가".encode("utf-8")
DET = "그 부분이 어떻게 생겼나".encode("utf-8")


def _sha(b):
    return hashlib.sha256(b).hexdigest()


def _member(purpose, blob, path, *, source=gb.SOURCE_FILE, sha=None):
    return {"subject_final_id": "LP01", "purpose": purpose,
            "outcome": gb.OUTCOME_SELECTED,
            "member_identity": f"id-{purpose}", "label": f"{purpose} 사진",
            "source": source, "path": str(path),
            "content_sha256": sha or _sha(blob)}


def _shot(root, *, scene=3, shot=1, still="s-1", members=None, base=True):
    d = root / "img"
    d.mkdir(exist_ok=True)
    (d / "c.png").write_bytes(CTX)
    (d / "d.png").write_bytes(DET)
    ms = members if members is not None else [
        _member("context", CTX, d / "c.png"),
        _member("detail", DET, d / "d.png")]
    rpc = {gb.RPC_MEMBERS_KEY: ms,
           "asset_requirements": {
               "required_refs": ([{"kind": "background", "id": "L01B01",
                                   "policy": "required"}] if base else []),
               "readiness_policy": "na"}}
    return {"scene_index": scene, "_shot_index": shot, "still_id": still,
            "render_prompt_card": rpc}


def _write(root, pid, eid, rows):
    mf = (root / pid / "checkpoints" / "episodes" / eid / "scene_detail")
    mf.mkdir(parents=True, exist_ok=True)
    (mf / "manifest.json").write_text(
        json.dumps({"status": "completed", "data": {"scenes": rows}},
                   ensure_ascii=False), encoding="utf-8")


CAP = {"provider": "T", "model": "m", "supports_labeled_refs": True,
       "min_images": 0, "max_images": None}


def _assembler(n_by_shot=None):
    """조립이 **낸 것**을 흉내낸다. ★selector 는 이것의 장수를 믿는다."""
    def _go(cand):
        n = (n_by_shot or {}).get(cand["still_id"],
                                  cand["counted_reference_count"])
        return {"labeled_refs": [(f"ref{i}", b"x") for i in range(n)],
                "ref_roles": ["background_chain_ref"] * n,
                "prompt": "한 컷"}
    return _go


def _find(root, **kw):
    kw.setdefault("capability", CAP)
    kw.setdefault("assemble", _assembler())
    return pf.eligible_shots(projects_dir=str(root), **kw)


class TestItCanActuallyFindOne:
    """★양성 대조 — 이것이 없으면 「0 건」은 **측정 오류와 구분이 안 된다**."""

    def test_a_proper_shot_is_found(self, tmp_path):
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path)])
        got = _find(tmp_path)
        assert len(got["eligible"]) == 1, got["rejected_because"]
        c = got["chosen"]
        assert (c["project_id"], c["episode_id"]) == ("p-1", "e-1")
        assert c["scene_index"] == 3 and c["shot_index"] == 1
        # 기존 base ref 1 + 접힌 참조 2 = 3
        assert c["reference_count"] == 3

    def test_two_same_photos_fold_into_one(self, tmp_path):
        """★같은 사진이면 물리 한 장 — 장수는 조립이 정한다."""
        d = tmp_path / "img"
        d.mkdir()
        (d / "same.png").write_bytes(CTX)
        ms = [_member("context", CTX, d / "same.png"),
              _member("detail", CTX, d / "same.png")]
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path, members=ms)])
        assert _find(tmp_path)["chosen"]["reference_count"] == 2


class TestTheOrderIsCanonical:
    def test_the_first_row_of_the_canonical_sort_wins(self, tmp_path):
        _write(tmp_path, "p-2", "e-1", [_shot(tmp_path, scene=1, shot=9)])
        _write(tmp_path, "p-1", "e-2", [_shot(tmp_path, scene=9, shot=1)])
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path, scene=2, shot=2),
                                        _shot(tmp_path, scene=1, shot=5)])
        got = _find(tmp_path)
        assert len(got["eligible"]) == 4
        c = got["chosen"]
        assert (c["project_id"], c["episode_id"], c["scene_index"],
                c["shot_index"]) == ("p-1", "e-1", 1, 5)

    def test_it_is_deterministic(self, tmp_path):
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path, scene=2),
                                        _shot(tmp_path, scene=1)])
        assert _find(tmp_path)["eligible"] == _find(tmp_path)["eligible"]


class TestEachConditionActuallyBlocks:
    def test_no_sidecar_is_rejected(self, tmp_path):
        row = _shot(tmp_path)
        row["render_prompt_card"].pop(gb.RPC_MEMBERS_KEY)
        _write(tmp_path, "p-1", "e-1", [row])
        got = _find(tmp_path)
        assert got["chosen"] is None
        assert any("sidecar 없음" in k for k in got["rejected_because"])

    def test_only_one_purpose_is_rejected(self, tmp_path):
        d = tmp_path / "img"
        d.mkdir()
        (d / "d.png").write_bytes(DET)
        _write(tmp_path, "p-1", "e-1",
               [_shot(tmp_path, members=[_member("detail", DET, d / "d.png")])])
        got = _find(tmp_path)
        assert got["chosen"] is None
        assert any("context+detail" in k for k in got["rejected_because"])

    def test_no_base_ref_is_rejected(self, tmp_path):
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path, base=False)])
        got = _find(tmp_path)
        assert got["chosen"] is None
        assert any("base ref 없음" in k for k in got["rejected_because"])

    def test_an_asset_coordinate_is_rejected(self, tmp_path):
        d = tmp_path / "img"
        d.mkdir()
        (d / "c.png").write_bytes(CTX)
        (d / "d.png").write_bytes(DET)
        ms = [_member("context", CTX, d / "c.png", source=gb.SOURCE_ASSET),
              _member("detail", DET, d / "d.png")]
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path, members=ms)])
        got = _find(tmp_path)
        assert got["chosen"] is None
        assert any("file 좌표가 아님" in k for k in got["rejected_because"])

    def test_a_wrong_hash_is_rejected(self, tmp_path):
        d = tmp_path / "img"
        d.mkdir()
        (d / "c.png").write_bytes(CTX)
        (d / "d.png").write_bytes(DET)
        ms = [_member("context", CTX, d / "c.png"),
              _member("detail", DET, d / "d.png", sha=_sha(b"another"))]
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path, members=ms)])
        got = _find(tmp_path)
        assert got["chosen"] is None
        assert any("해시가 안 맞" in k for k in got["rejected_because"])

    def test_over_capability_is_rejected(self, tmp_path):
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path)])
        got = _find(tmp_path, capability={**CAP, "max_images": 1})
        assert got["chosen"] is None
        assert any("capability 밖" in k for k in got["rejected_because"])


class TestItSaysWhyWhenThereAreNone:
    def test_an_empty_result_carries_its_reason(self, tmp_path):
        """★★빈손을 **조용히** 내면 안 된다 — 무엇에 막혔는지 적는다."""
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path, base=False)])
        got = _find(tmp_path)
        assert got["chosen"] is None
        assert got["manifests_read"] == 1
        assert sum(got["rejected_because"].values()) == 1

    def test_no_projects_dir_is_not_a_silent_zero(self, tmp_path):
        got = _find(tmp_path / "없는-자리")
        assert got["manifests_read"] == 0 and got["chosen"] is None


class TestOnlyAFinishedCheckpointCounts:
    """★⓪ — 앞 판은 이것을 안 봐서 **부분 실패한 판**도 자격 샷이 됐다."""

    @pytest.mark.parametrize("status", ["partial", "failed", "running", ""])
    def test_an_unfinished_manifest_is_rejected(self, tmp_path, status):
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path)])
        mf = (tmp_path / "p-1" / "checkpoints" / "episodes" / "e-1"
              / "scene_detail" / "manifest.json")
        d = json.loads(mf.read_text())
        d["status"] = status
        mf.write_text(json.dumps(d, ensure_ascii=False), encoding="utf-8")
        got = _find(tmp_path)
        assert got["chosen"] is None
        assert any("completed 가 아님" in k for k in got["rejected_because"])

    def test_a_failed_count_above_zero_is_rejected(self, tmp_path):
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path)])
        mf = (tmp_path / "p-1" / "checkpoints" / "episodes" / "e-1"
              / "scene_detail" / "manifest.json")
        d = json.loads(mf.read_text())
        d["failed_count"] = 2
        mf.write_text(json.dumps(d, ensure_ascii=False), encoding="utf-8")
        got = _find(tmp_path)
        assert got["chosen"] is None
        assert any("failed_count" in k for k in got["rejected_because"])


class TestTheChoiceComesFromTheRealAssembly:
    """★⑥ — 센 장수와 유료 runner 가 보내는 장수가 **같은 근거**여야 한다."""

    def test_without_an_assembly_nothing_is_chosen(self, tmp_path):
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path)])
        got = pf.eligible_shots(projects_dir=str(tmp_path), capability=CAP)
        assert len(got["eligible"]) == 1
        assert got["chosen"] is None, "★조립 없이 골랐다"
        assert "why_no_choice" in got

    def test_a_disagreeing_assembly_is_rejected(self, tmp_path):
        """★조립이 센 것과 **다른 장수**를 내면 그 샷은 못 쓴다."""
        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path, still="s-1")])
        got = _find(tmp_path, assemble=_assembler({"s-1": 9}))
        assert got["chosen"] is None
        assert any("≠ 조립" in k for k in got["rejected_because"])

    def test_it_falls_through_to_the_next_shot(self, tmp_path):
        """★앞 행이 조립에서 서면 **다음 행**으로 간다 — 사람이 안 고른다."""
        _write(tmp_path, "p-1", "e-1",
               [_shot(tmp_path, scene=1, still="나쁜"),
                _shot(tmp_path, scene=2, still="좋은")])
        got = _find(tmp_path, assemble=_assembler({"나쁜": 9}))
        assert got["chosen"]["still_id"] == "좋은"
        assert got["assembled"]["prompt"] == "한 컷"

    def test_a_raising_assembly_is_recorded_not_swallowed(self, tmp_path):
        def _boom(_cand):
            raise RuntimeError("조립이 섰다")

        _write(tmp_path, "p-1", "e-1", [_shot(tmp_path)])
        got = _find(tmp_path, assemble=_boom)
        assert got["chosen"] is None
        assert any("조립이 섰다" in k for k in got["rejected_because"])


# ─────────────────────────────────────────────────────────────────────
# ★★★1씬 2샷 fixture 가 **정확히 하나**를 낸다 (Codex 2026-08-31)
#
# > 이 한 씬으로 selector 가 **exactly 1 eligible** 과 실제 assembly N 을
# > 내면 그대로 확정합니다.
#
# ★이것은 **producer 가 낼 모양**을 흉내낸 무료 확인이지, 실제 체크포인트에
#  손으로 정답을 넣은 것이 아니다. 진짜 sidecar 는 유료 주행에서
#  `write_sidecar` 한 벌이 적는다.
# ─────────────────────────────────────────────────────────────────────

from tests.grounding.fixtures import canary_one_scene as fx  # noqa: E402


def _fixture_manifest(root, *, drop=()):
    """★★원고의 **모든 씬·모든 샷**을 넣는다 (Codex 2026-09-01).

    앞 판은 `segments()[0]`·`shot_scenes()[0]` 만 넣어 **둘째 씬을 아예 안
    봤다** — 둘째 씬의 가까운 간판 샷을 통째로 빼도 초록이었으니 거짓 양성이다.

    Args:
        drop: 빼고 볼 `(scene_index, shot_index)` — 음성 대조에 쓴다.
    """
    d = root / "img"
    d.mkdir(exist_ok=True)
    (d / "ctx.png").write_bytes(CTX)
    (d / "det.png").write_bytes(DET)
    rows = []
    for sc in fx.shot_scenes():
        si = sc["scene_index"]
        for sh in sc["shots"]:
            hi = sh["shot_index"]
            if (si, hi) in drop:
                continue
            rpc = {"asset_requirements": {
                "required_refs": [{"kind": "background", "id": "L01B01",
                                   "policy": "required"}],
                "readiness_policy": "na"}}
            # ★**가까운 간판 샷**에만 부분 참조가 붙는다 — 전경 샷에는 없다
            if "간판" in sh["description"]:
                rpc[gb.RPC_MEMBERS_KEY] = [
                    _member("context", CTX, d / "ctx.png"),
                    _member("detail", DET, d / "det.png")]
            rows.append({"scene_index": si, "_shot_index": hi,
                         "still_id": f"still-{si}-{hi}",
                         "render_prompt_card": rpc})
    return rows


class TestTheOneSceneFixtureYieldsExactlyOne:
    def test_the_fixture_is_the_approved_size(self):
        fx.assert_shape()

    def test_both_sign_shots_are_eligible(self, tmp_path):
        """★간판이 가까운 샷 **둘**이 걸린다 — 전경 샷은 안 걸린다."""
        _write(tmp_path, "p-canary", "e-canary", _fixture_manifest(tmp_path))
        got = _find(tmp_path)
        assert len(got["eligible"]) == 2, got["rejected_because"]
        assert {(r["scene_index"], r["shot_index"])
                for r in got["eligible"]} == {(1, 2), (2, 1)}

    def test_the_canonical_first_row_is_chosen(self, tmp_path):
        """★사람이 안 고른다 — 정본 정렬 첫 행이다."""
        _write(tmp_path, "p-canary", "e-canary", _fixture_manifest(tmp_path))
        c = _find(tmp_path)["chosen"]
        assert (c["scene_index"], c["shot_index"]) == (1, 2)
        assert c["still_id"] == "still-1-2"
        # 기존 배경판 1 + 맥락·상세 2 = 3
        assert c["reference_count"] == 3

    def test_dropping_the_second_scene_shot_is_seen(self, tmp_path):
        """★★★음성 대조 — 둘째 씬의 간판 샷을 빼면 **수가 줄어야** 한다.

        앞 시험은 둘째 씬을 아예 안 봐서 빼도 초록이었다(거짓 양성).
        """
        _write(tmp_path, "p-canary", "e-canary",
               _fixture_manifest(tmp_path, drop=((2, 1),)))
        got = _find(tmp_path)
        assert len(got["eligible"]) == 1
        assert got["chosen"]["scene_index"] == 1

    def test_the_wide_shot_is_rejected_for_the_right_reason(self, tmp_path):
        """★전경 샷(부분이 안 보이는 것)만 ③에서 걸린다."""
        _write(tmp_path, "p-canary", "e-canary", _fixture_manifest(tmp_path))
        got = _find(tmp_path)
        assert got["rejected_because"].get("③sidecar 없음 (D 가 아직 inert)") == 1

    def test_every_shot_of_every_scene_is_examined(self, tmp_path):
        """★원고의 **모든 샷**이 표에 들어간다 — 하나라도 빠지면 못 잰다."""
        rows = _fixture_manifest(tmp_path)
        assert len(rows) == sum(len(s["shots"]) for s in fx.shot_scenes()) == 3
        assert {r["scene_index"] for r in rows} == {1, 2}

    def test_the_chosen_n_comes_from_the_assembly(self, tmp_path):
        """★selector 의 N 과 유료 runner 의 N 이 **같은 근거**여야 한다."""
        _write(tmp_path, "p-canary", "e-canary", _fixture_manifest(tmp_path))
        got = _find(tmp_path)
        assert len(got["assembled"]["labeled_refs"]) == \
            got["chosen"]["reference_count"]
