"""중앙 조사 결과 → 이 컷의 sidecar → 조립. ★유료 0 · 활성화 0.

Codex D-inert 3/3 (2026-09-01) —
  중앙 결과 → `members_from_rows` → `write_sidecar`
  scene assembly 의 `attach_from_rpc` 및 정확 부착 검사
  location/location_part owner→background kind + 역할 투영
  **sidecar 없음 반환 무변** · 있음 context/detail/combined 역할 끝점
"""
from __future__ import annotations

import copy

import pytest

from app.modules.pipeline import grounding_reference_bundle as rb
from app.modules.pipeline import grounding_sidecar_writer as sw
from app.modules.pipeline.grounding_entity_contract import (
    REFERENCE_KIND_BY_OWNER)
from app.modules.pipeline.grounding_host_context import (PURPOSE_CONTEXT,
                                                         PURPOSE_DETAIL)
from app.services.prompt_service import REF_ROLE_VALUES


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


CP = {"data": {"rows": [
    _row("L01", PURPOSE_CONTEXT, covers=["L01", "LP01", "LP02"],
         owner="location"),
    _row("LP01", PURPOSE_DETAIL),
    _row("LP02", PURPOSE_DETAIL),
]}}


def _sha(r):
    return r["_sha"]


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


def _write(rpc, cp, visible):
    return sw.write_for_shot(rpc, cp, visible, content_sha_of=_sha,
                             coordinate_of=_coord)


class TestNoSidecarMeansNoChange:
    """★★없으면 **한 글자도 안 바뀐다** — 그것이 이 설계의 문이다."""

    @pytest.mark.parametrize("cp", [None, {}, {"data": {}},
                                    {"data": {"rows": []}}])
    def test_an_absent_central_output_writes_nothing(self, cp):
        rpc = {"asset_requirements": {"required_refs": []}}
        base = copy.deepcopy(rpc)
        assert _write(rpc, cp, ["LP01"]) == 0
        assert rpc == base

    def test_a_shot_that_sees_none_of_them_writes_nothing(self):
        rpc = {"other": 1}
        base = copy.deepcopy(rpc)
        assert _write(rpc, CP, ["C99", "P07"]) == 0
        assert rpc == base

    def test_an_empty_visible_list_writes_nothing(self):
        rpc = {}
        assert _write(rpc, CP, []) == 0
        assert rpc == {}


class TestOnlyWhatThisShotSeesLands:
    def test_a_sibling_pulls_in_the_shared_parent_context(self):
        """★부모 맥락은 **덮는 의무**로 딸려 온다 — 이름으로 안 고른다."""
        got = sw.members_for_shot(CP, ["LP01"], content_sha_of=_sha,
                                  coordinate_of=_coord)
        assert sorted(m["subject_final_id"] for m in got) == ["L01", "LP01"]

    def test_the_other_sibling_is_not_dragged_in(self):
        got = sw.members_for_shot(CP, ["LP01"], content_sha_of=_sha,
                                  coordinate_of=_coord)
        assert "LP02" not in {m["subject_final_id"] for m in got}

    def test_both_siblings_share_one_parent_member(self):
        got = sw.members_for_shot(CP, ["LP01", "LP02"], content_sha_of=_sha,
                                  coordinate_of=_coord)
        ctx = [m for m in got if m["purpose"] == PURPOSE_CONTEXT]
        assert len(ctx) == 1, "★부모가 두 번 실렸다"
        assert len(got) == 3

    def test_it_matches_by_final_id_not_by_name(self):
        assert sw.visible_final_ids([{"short_id": "LP01"}, "LP02",
                                     {"id": "L01"}, {"name": "회전 간판"}]) == \
            ["L01", "LP01", "LP02"]


class TestTheRolesAreTheDeclaredOnes:
    def test_context_alone_and_detail_alone(self):
        rpc = {}
        assert _write(rpc, CP, ["LP01", "LP02"]) == 3
        assert sw.roles_in(rpc) == sorted({
            rb.ROLE_BY_PURPOSES[(PURPOSE_CONTEXT,)],
            rb.ROLE_BY_PURPOSES[(PURPOSE_DETAIL,)]})

    def test_one_photo_serving_both_gets_the_combined_role(self):
        """★같은 bytes 면 한 장이고 역할은 **결합**이다."""
        same = {"data": {"rows": [
            _row("L01", PURPOSE_CONTEXT, covers=["L01", "LP01"],
                 owner="location", sha="SAME"),
            _row("LP01", PURPOSE_DETAIL, sha="SAME"),
        ]}}
        rpc = {}
        assert _write(rpc, same, ["LP01"]) == 1, "★같은 사진이 두 번 실렸다"
        assert sw.roles_in(rpc) == [
            rb.ROLE_BY_PURPOSES[(PURPOSE_CONTEXT, PURPOSE_DETAIL)]]

    def test_every_role_it_can_use_is_already_declared(self):
        rpc = {}
        _write(rpc, CP, ["LP01", "LP02"])
        sw.assert_roles_declared(rpc, REF_ROLE_VALUES)
        assert rb.undeclared_roles(REF_ROLE_VALUES) == []

    def test_an_undeclared_role_stops(self):
        """★양성 대조 — 선언 밖이면 **선다**."""
        rpc = {}
        _write(rpc, CP, ["LP01"])
        with pytest.raises(sw.SidecarWriterError, match="선언"):
            sw.assert_roles_declared(rpc, ("전혀 다른 역할",))

    def test_both_place_lanes_use_the_existing_background_slot(self):
        assert REFERENCE_KIND_BY_OWNER["location"] == rb.BUNDLE_KIND
        assert REFERENCE_KIND_BY_OWNER["location_part"] == rb.BUNDLE_KIND
        rpc = {}
        _write(rpc, CP, ["LP01"])
        assert {k for k, _v in
                [(x["kind"], x["value"]) for x in rpc[rb.RPC_REQUIRED_KEY]]} \
            == {rb.BUNDLE_KIND}


class TestTheInvisibleSiblingNeverLeaks:
    """★★안 보이는 형제의 **상세**가 이 컷에 섞이면 안 된다 (Codex 09-01).

    부모 맥락은 **덮는 의무** 때문에 딸려 오는 것이 맞다 — 그 컷의 장소가
    그 장소이기 때문이다. 하지만 다른 부분의 **상세 사진**은 다른 물건이다.
    """

    def test_only_the_visible_part_detail_lands(self):
        got = sw.members_for_shot(CP, ["LP01"], content_sha_of=_sha,
                                  coordinate_of=_coord)
        det = [m for m in got if m["purpose"] == PURPOSE_DETAIL]
        assert [m["subject_final_id"] for m in det] == ["LP01"]

    def test_the_required_list_names_no_invisible_sibling(self):
        rpc = {}
        _write(rpc, CP, ["LP01"])
        vals = [v["value"] for v in rpc[rb.RPC_REQUIRED_KEY]]
        parsed = [rb.parse_meta_value(v) for v in vals]
        subjects = {x for p in parsed for x in p["subject_final_ids"]}
        assert "LP02" not in subjects, "★안 보이는 형제가 요구에 들어갔다"

    def test_the_parent_context_is_still_pulled_in(self):
        """★음성 대조 — 막기만 하면 맥락이 죽는다."""
        rpc = {}
        _write(rpc, CP, ["LP01"])
        subjects = {x for v in rpc[rb.RPC_REQUIRED_KEY]
                    for x in rb.parse_meta_value(v["value"])[
                        "subject_final_ids"]}
        assert subjects == {"L01", "LP01"}

    def test_a_shot_seeing_only_the_place_gets_no_part_detail(self):
        got = sw.members_for_shot(CP, ["L01"], content_sha_of=_sha,
                                  coordinate_of=_coord)
        assert [m["purpose"] for m in got] == [PURPOSE_CONTEXT]


class TestWhatWasNotFoundIsStillRecorded:
    MISSING = {"data": {"rows": [
        _row("L01", PURPOSE_CONTEXT, covers=["L01", "LP01"], owner="location",
             outcome=rb.OUTCOME_UNAVAILABLE),
        _row("LP01", PURPOSE_DETAIL),
    ]}}

    def test_it_is_written_but_makes_no_requirement(self):
        from app.modules.pipeline import grounding_bundle_projection as bp

        rpc = {}
        n = _write(rpc, self.MISSING, ["LP01"])
        assert n == 1, "★못 구한 것이 요구가 됐다"
        back = rb.members_from_rpc(rpc)
        assert len(back) == 2
        miss = bp.unavailable_members(back)
        assert [m["subject_final_id"] for m in miss] == ["L01"]

    def test_the_detail_still_lands(self):
        rpc = {}
        _write(rpc, self.MISSING, ["LP01"])
        assert sw.roles_in(rpc) == [
            rb.ROLE_BY_PURPOSES[(PURPOSE_DETAIL,)]]


class TestItReachesTheRealAssembly:
    """★★조립은 **읽고 대조만** 한다 — 쓰는 쪽은 여기다."""

    def test_the_assembly_attaches_exactly_what_was_written(self, tmp_path):
        rpc = {}
        _write(rpc, CP, ["LP01"])
        by_path = {}
        for m in rb.members_from_rpc(rpc):
            if m.get("content_sha256"):
                by_path[m["path"]] = m["subject_final_id"].encode()

        def _load(coord):
            import hashlib

            raw = by_path[coord["path"]]
            return raw, dict(coord)

        # ★해시가 실제 bytes 와 맞아야 하므로 그 모양으로 다시 적는다
        rpc2 = {}
        rows = [{**r, "_sha": __import__("hashlib").sha256(
            r["ledger_row"]["final_id"].encode()).hexdigest()}
            for r in CP["data"]["rows"]]
        _write(rpc2, {"data": {"rows": rows}}, ["LP01"])
        lr, am, rr, rm = [], [], [], []
        n = rb.attach_from_rpc(lr, am, rr, rm, rpc2, load_bytes=_load)
        assert n == 2, "★쓴 것과 붙은 것이 다르다"
        assert {k for k, _v in am} == {rb.BUNDLE_KIND}
        rb.assert_from_rpc(rpc2, am)

    def test_an_empty_rpc_attaches_nothing(self):
        lr, am, rr, rm = [], [], [], []
        assert rb.attach_from_rpc(lr, am, rr, rm, {},
                                  load_bytes=lambda c: (b"", {})) == 0
        assert (lr, am, rr, rm) == ([], [], [], [])
