"""투영은 **옮기기만** 한다. 의무는 조사 앞에서 선다. ★유료 0.

★★이 파일은 **뒤집은 시험**이다 (Codex BLOCK · 2026-09-01).
앞 판은 「투영이 맥락 멤버를 만든다」를 잠갔는데, 그것이 바로 결함이었다 —
조사 끝난 뒤에 만들면 **맥락 사진을 따로 조사한 적이 없다**. 쓰임만 다시
적은 것이다. 그래서 지우지 않고 **반대로** 잠근다.

의무 쪽 계약은 `test_reference_obligations.py` 가 잠근다.
"""
from __future__ import annotations

import ast
import inspect

import pytest

from app.modules.pipeline import grounding_bundle_projection as bp
from app.modules.pipeline import grounding_reference_bundle as rb
from app.modules.pipeline.grounding_host_context import (PURPOSE_CONTEXT,
                                                         PURPOSE_DETAIL)

_DEFAULT = object()


def _researched(final_id, purpose, *, covers=None, parent=None,
                ident=_DEFAULT, outcome=rb.OUTCOME_SELECTED, owner=None,
                fidelity="verified"):
    """중앙 조사기가 낸 줄 — 그 `ledger_row` 는 **의무**다.

    ★★★2026-09-02: `selected` 만으로는 **안 붙는다**. coarse 심판은
    「무엇인가·보이는가」만 봤고, 그 사진이 그 시대·그 지역 것인지는 아무도
    안 봤다. 소비자 문이 **두 축을 다** 본다 (Codex BLOCK). 그래서 기본을
    `verified` 로 두되, **미확인이 안 붙는 것**을 따로 잠근다 — 안 그러면
    이 fixture 가 결함을 숨긴다.
    """
    return {"research_subject_id": f"{final_id}#{purpose}" if purpose
            else f"rs_{final_id}",
            "identity": (f"ident_{final_id}_{purpose}" if ident is _DEFAULT
                         else ident),
            "outcome": outcome, "why": "", "why_unbought": None,
            "grounding_fidelity": {"state": fidelity},
            "ledger_row": {"owner_type": owner or "location_part",
                           "final_id": final_id, "parent_final_id": parent,
                           "purpose": purpose,
                           "covers": covers or [final_id]}}


def _sha(row):
    return f"sha_{row['ledger_row']['final_id']}_{row['ledger_row']['purpose']}"


def _coord(row):
    fid = row["ledger_row"]["final_id"]
    return {"source": rb.SOURCE_FILE, "path": f"refs/{fid}.jpg"}


def _members(rows):
    return bp.members_from_rows(rows, content_sha_of=_sha,
                                coordinate_of=_coord)


class TestItInventsNothing:
    """★★투영이 **의무를 만들면 안 된다** — 조사 앞에서 서야 한다."""

    def test_it_makes_no_member_the_obligation_did_not_ask_for(self):
        rows = [_researched("LP01", PURPOSE_DETAIL, parent="L01")]
        got = _members(rows)
        assert [m["purpose"] for m in got] == [PURPOSE_DETAIL], \
            "★맥락 멤버를 지어냈다 — 조사한 적이 없는 사진이다"

    def test_it_does_not_decide_who_owns_the_context(self):
        """★주인 판단은 **의무 계획**의 일이다. 여기서 다시 하면 두 곳이다."""
        src = inspect.getsource(bp)
        tree = ast.parse(src)
        names = {n.name for n in ast.walk(tree)
                 if isinstance(n, ast.FunctionDef)}
        assert "context_ownership" not in names, \
            "★투영이 아직 주인을 정한다"
        calls = {ast.unparse(n.func) for n in ast.walk(tree)
                 if isinstance(n, ast.Call)}
        assert not [c for c in calls if "plan" == c.split(".")[-1]]

    def test_the_purpose_comes_from_the_obligation_row(self):
        rows = [_researched("L01", PURPOSE_CONTEXT, covers=["L01", "LP01"],
                            owner="location")]
        got = _members(rows)
        assert got[0]["purpose"] == PURPOSE_CONTEXT
        assert got[0]["covers"] == ["L01", "LP01"]

    def test_a_lane_with_no_purpose_is_not_projected(self):
        """★인물·소품·아웃룩은 배경 자리가 아니다 — 의무에 쓰임이 없다."""
        rows = [_researched("C01", None, owner="character"),
                _researched("P01", None, owner="prop")]
        assert _members(rows) == []


class TestWhatWasNotFoundStillSurvives:
    """★★못 구한 것도 **논리 의무로 남는다** — UI 가 「무엇이 없었나」를 읽는다."""

    ROWS = [_researched("LP01", PURPOSE_DETAIL, parent="L01"),
            _researched("L01", PURPOSE_CONTEXT, covers=["L01", "LP01"],
                        owner="location", outcome=rb.OUTCOME_UNAVAILABLE)]

    def test_the_unavailable_obligation_is_kept(self):
        got = _members(self.ROWS)
        assert len(got) == 2
        assert bp.unavailable_members(got)
        miss = bp.unavailable_members(got)[0]
        assert miss["subject_final_id"] == "L01"
        assert miss["purpose"] == PURPOSE_CONTEXT
        assert miss["covers"] == ["L01", "LP01"]

    def test_it_invents_no_identity_or_hash_for_it(self):
        miss = bp.unavailable_members(_members(self.ROWS))[0]
        assert miss["member_identity"] is None
        assert miss["content_sha256"] is None

    def test_it_makes_no_image_required(self):
        plan, required = rb.plan_bundle(_members(self.ROWS))
        assert [p["subject_final_ids"] for p in plan] == [["LP01"]]
        assert len(required) == 1

    def test_a_missing_context_does_not_block_the_detail(self):
        plan, _req = rb.plan_bundle(_members(self.ROWS))
        assert plan and PURPOSE_DETAIL in plan[0]["purposes"]


class TestTheMissingOneReachesTheSidecar:
    """★★기록은 **durable 산출까지** 가야 UI 가 나중에 고칠 수 있다.

    Codex (2026-09-01) — 「참조를 찾지 못한 항목도 기록에서 지우지 않고
    『참조 없음』으로 남겨야 나중에 UI 에서 수정할 수 있습니다.」
    """

    ROWS = [_researched("LP01", PURPOSE_DETAIL, parent="L01"),
            _researched("L01", PURPOSE_CONTEXT, covers=["L01", "LP01"],
                        owner="location", outcome=rb.OUTCOME_UNAVAILABLE)]

    def _sidecar(self):
        rpc: dict = {}
        required = rb.write_sidecar(rpc, _members(self.ROWS))
        return rpc, required

    def test_both_obligations_are_written(self):
        rpc, _req = self._sidecar()
        got = rpc[rb.RPC_MEMBERS_KEY]
        assert len(got) == 2
        assert {m["outcome"] for m in got} == {rb.OUTCOME_SELECTED,
                                               rb.OUTCOME_UNAVAILABLE}

    def test_only_the_found_one_becomes_required(self):
        _rpc, required = self._sidecar()
        assert len(required) == 1

    def test_reading_it_back_still_shows_what_was_missing(self):
        rpc, _req = self._sidecar()
        back = rb.members_from_rpc(rpc)
        miss = bp.unavailable_members(back)
        assert [m["subject_final_id"] for m in miss] == ["L01"]
        assert miss[0]["covers"] == ["L01", "LP01"]

    def test_the_sidecar_is_json(self):
        import json

        rpc, _req = self._sidecar()
        assert json.loads(json.dumps(rpc, ensure_ascii=False))


class TestOnePhotoMayServeSeveralSubjects:
    """★★물리 dedupe 는 **내용 해시**로 — 주인이 달라도 같은 사진이면 한 장."""

    def _same_photo(self):
        rows = [_researched("LP01", PURPOSE_DETAIL, parent="L01"),
                _researched("L01", PURPOSE_CONTEXT, covers=["L01", "LP01"],
                            owner="location")]
        return bp.members_from_rows(rows, content_sha_of=lambda r: "SAME",
                                    coordinate_of=_coord)

    def test_the_same_bytes_land_once(self):
        plan, required = rb.plan_bundle(self._same_photo())
        assert len(plan) == 1, "★같은 사진이 두 번 들어간다"
        assert plan[0]["subject_final_ids"] == ["L01", "LP01"]
        assert sorted(plan[0]["purposes"]) == [PURPOSE_CONTEXT, PURPOSE_DETAIL]
        assert plan[0]["covered_final_ids"] == ["L01", "LP01"]
        assert len(required) == 1

    def test_different_bytes_stay_two(self):
        """★음성 대조 — 내용이 다르면 **두 장 그대로**다."""
        rows = [_researched("LP01", PURPOSE_DETAIL, parent="L01"),
                _researched("L01", PURPOSE_CONTEXT, covers=["L01", "LP01"],
                            owner="location")]
        plan, _req = rb.plan_bundle(_members(rows))
        assert len(plan) == 2
        assert {p["role"] for p in plan} == {
            rb.ROLE_BY_PURPOSES[(PURPOSE_CONTEXT,)],
            rb.ROLE_BY_PURPOSES[(PURPOSE_DETAIL,)]}

    def test_the_merged_role_is_the_combined_one(self):
        plan, _req = rb.plan_bundle(self._same_photo())
        assert plan[0]["role"] == rb.ROLE_BY_PURPOSES[
            (PURPOSE_CONTEXT, PURPOSE_DETAIL)]


class TestItRefusesToGuess:
    def test_a_selected_row_without_an_identity_stops(self):
        rows = [_researched("LP01", PURPOSE_DETAIL, ident="")]
        with pytest.raises(bp.ProjectionError, match="획득 신원"):
            _members(rows)

    def test_a_selected_row_without_a_hash_stops(self):
        rows = [_researched("LP01", PURPOSE_DETAIL)]
        with pytest.raises(bp.ProjectionError, match="해시"):
            bp.members_from_rows(rows, content_sha_of=lambda r: "",
                                 coordinate_of=_coord)

    def test_an_obligation_without_a_final_id_stops(self):
        row = _researched("LP01", PURPOSE_DETAIL)
        row["ledger_row"]["final_id"] = None
        with pytest.raises(bp.ProjectionError, match="final_id"):
            _members([row])
