"""outlook 을 phase3 뒤에 **구조로** 잇는다. ★유료 0 · 참조를 사지 않는다.

왜 뒤인가 — 스텝 차례가 이유다(실측): `grounding_screen` 13.68 ·
`reference_acquisition` 13.8 인데 `outlook_phase1~3` 은 19~19.2 다. 앞단이
참조를 살 때 outlook 실물은 **아직 없다**.
"""
from __future__ import annotations

import pytest

from app.modules.pipeline import grounding_outlook_binding as ob
from app.modules.pipeline.grounding_entity_contract import FACET_BINDING


def _row(rsid="rs1", segs=("scene-2",), payload=None, parent="C01",
         screen="obligation"):
    got = {"research_subject_id": rsid, "owner_type": "outlook",
           "screen": screen,
           # ★좌표는 **구조 덩어리로만** 온다 — 따로 실은 값은 production 이
           #  안 받는다(뒷문을 없앴다). 그래서 시험도 그 모양으로 만든다.
           **({FACET_BINDING: {"local_id": "c0#2", "final_id": "O01",
                               "owner_type": "outlook",
                               "parent_local_id": "c0#1",
                               "parent_owner_type": "character",
                               "parent_final_id": parent}} if parent else {}),
           "source_evidence": {
               "surface_form": "긴 겉옷", "source_quote": "발목까지 오는 긴 겉옷",
               "occurrences": [{"source_span": {"segment_id": s, "start": 1,
                                                "end": 4}} for s in segs]}}
    if payload:
        got["grounding_producer_payload"] = payload
    return got


def _p3(pairs=(("O01", "C01", 2),), names=("O01",)):
    return {"outlooks": [{"outlook_id": n, "name": f"이름 {n}"} for n in names],
            "scene_assignments": [
                {"scene_index": sc,
                 "assignments": [{"character_id": c, "outlook_id": o}]}
                for o, c, sc in pairs]}


class TestItBindsByStructureOnly:
    def test_a_shared_scene_and_id_binds(self):
        got = ob.bind([_row()], _p3())
        r = got["rows"][0]
        assert r["status"] == ob.BOUND
        assert r["outlook_id"] == "O01" and r["character_id"] == "C01"

    def test_the_module_never_reads_a_name(self):
        """★★이름·부분문자열로 잇지 않는다 — **AST 로** 본다."""
        import ast
        import inspect

        tree = ast.parse(inspect.getsource(ob))
        for n in ast.walk(tree):
            if isinstance(n, ast.Attribute) and n.attr in (
                    "startswith", "endswith", "lower", "upper", "find"):
                raise AssertionError(f"★글자를 본다: {n.attr}")
            if isinstance(n, ast.Call) and getattr(n.func, "attr", "") == "sub":
                raise AssertionError("★정규식을 쓴다")

    def test_a_different_scene_does_not_bind(self):
        """★음성 대조 — 이름이 같아도 **구간이 다르면** 안 붙는다."""
        got = ob.bind([_row(segs=("scene-9",))], _p3())
        r = got["rows"][0]
        assert r["status"] == ob.UNRESOLVED and r["why"] == ob.WHY_NO_MATCH

    def test_no_evidence_means_no_binding(self):
        row = _row()
        row["source_evidence"] = {}
        got = ob.bind([row], _p3())
        assert got["rows"][0]["why"] == ob.WHY_NO_MATCH


class TestItRefusesInsteadOfGuessing:
    def test_two_outlooks_in_one_scene_are_unresolved(self):
        p3 = _p3(pairs=(("O01", "C01", 2), ("O02", "C01", 2)),
                 names=("O01", "O02"))
        got = ob.bind([_row()], p3)
        r = got["rows"][0]
        assert r["status"] == ob.UNRESOLVED and r["why"] == ob.WHY_AMBIGUOUS
        assert r["candidates"] == ["O01", "O02"]

    def test_another_character_in_the_scene_does_not_blur_it(self):
        """★★**뒤집힌 시험**이다 (09-01). 앞에는 「같은 씬에 인물이 둘이면
        소유 충돌」로 잠갔는데, 그 규칙 자체가 **씬 공존을 소유로 읽는 것**의
        잔재였다. 기대 부모 문을 세운 뒤로는 그쪽 인물이 몇이든 상관없다 —
        **내 부모의 배정**만 본다.
        """
        p3 = {"outlooks": [{"outlook_id": "O01", "name": "x"}],
              "scene_assignments": [{"scene_index": 2, "assignments": [
                  {"character_id": "C01", "outlook_id": "O01"},
                  {"character_id": "C02", "outlook_id": "O01"}]}]}
        got = ob.bind([_row(parent="C01")], p3)
        r = got["rows"][0]
        assert r["status"] == ob.BOUND
        assert r["outlook_id"] == "O01" and r["character_id"] == "C01"

    def test_an_id_outside_the_catalog_is_unresolved(self):
        p3 = _p3(pairs=(("O99", "C01", 2),), names=("O01",))
        got = ob.bind([_row()], p3)
        assert got["rows"][0]["why"] == ob.WHY_NOT_IN_CATALOG

    def test_a_duplicated_catalog_entry_stops(self):
        p3 = {"outlooks": [{"outlook_id": "O01", "name": "가"},
                           {"outlook_id": "O01", "name": "나"}]}
        with pytest.raises(ob.OutlookBindingError):
            ob.bind([_row()], p3)


class TestMergedSourcesMayShareOneOutlook:
    def test_two_rows_can_bind_to_the_same_outlook(self):
        """★한 outlook 이 **merge 된 여러 source id** 를 받는 것은 정상이다."""
        got = ob.bind([_row("rs1"), _row("rs2")], _p3())
        assert [r["status"] for r in got["rows"]] == [ob.BOUND, ob.BOUND]
        assert {r["outlook_id"] for r in got["rows"]} == {"O01"}

    def test_one_source_never_gets_two_outlooks(self):
        p3 = _p3(pairs=(("O01", "C01", 2), ("O02", "C01", 2)),
                 names=("O01", "O02"))
        got = ob.bind([_row()], p3)
        assert "outlook_id" not in got["rows"][0]


class TestNothingIsLostAndNobodyWaits:
    def test_every_row_comes_out(self):
        rows = [_row(f"rs{i}", segs=("scene-2",) if i % 2 else ("scene-9",))
                for i in range(6)]
        got = ob.bind(rows, _p3())
        assert len(got["rows"]) == 6
        assert sum(got["counts"].values()) == 6

    def test_the_evidence_and_payload_survive_verbatim(self):
        """★★중앙 획득이 쓸 것 — **원형 그대로** 들고 간다."""
        payload = {"coarse_type_label": "두루마기", "visual_brief": "긴 겉옷",
                   "search_terms_native": ["두루마기"],
                   "language_lock_native": "ko"}
        row = _row(payload=payload)
        got = ob.bind([row], _p3())
        r = got["rows"][0]
        assert r["source_evidence"] == row["source_evidence"]
        assert r["grounding_producer_payload"] == payload

    def test_only_bound_rows_go_to_the_buyer(self):
        rows = [_row("rs1"), _row("rs2", segs=("scene-9",))]
        got = ob.bind(rows, _p3())
        targets = ob.acquisition_targets(got)
        assert [t["research_subject_id"] for t in targets] == ["rs1"]

    def test_an_unresolved_row_completes_itself(self):
        """★★사람을 안 기다린다 — `reference_unavailable` 로 자동 완료."""
        from app.modules.pipeline import reference_acquisition as ra

        got = ob.bind([_row(segs=("scene-9",))], _p3())
        r = got["rows"][0]
        assert ob.unresolved_outcome(r) == ra.STATUS_UNAVAILABLE
        assert ra.downstream_blocked(ra.STATUS_UNAVAILABLE) is False


class TestItIsNotWiredYet:
    def test_no_step_calls_it(self):
        """★배선은 **D cutover 몫**이다 — 지금은 아무도 안 부른다."""
        import ast
        from pathlib import Path

        root = Path(__file__).resolve().parents[2] / "app"
        hits = []
        for f in root.rglob("*.py"):
            if f.name == "grounding_outlook_binding.py":
                continue
            try:
                tree = ast.parse(f.read_text(encoding="utf-8"))
            except SyntaxError:                     # noqa: PERF203
                continue
            for n in ast.walk(tree):
                if (isinstance(n, ast.ImportFrom)
                        and "grounding_outlook_binding" in str(n.module or "")):
                    hits.append(f.name)
        assert hits == [], f"★벌써 배선됐다: {hits}"

    def test_it_buys_nothing(self):
        """★참조를 **사지 않는다** — 구매자는 중앙 획득 하나다."""
        import ast
        import inspect

        tree = ast.parse(inspect.getsource(ob))
        for n in ast.walk(tree):
            if isinstance(n, ast.Call):
                name = getattr(n.func, "attr", "") or getattr(n.func, "id", "")
                assert "acquire" not in name and "search" not in name, name


class TestASharedSceneIsNotOwnership:
    """★★★Codex BLOCK (09-01) — 앞 판은 **씬 공존을 소유로 읽었다**.

    occurrence 의 씬에 배정이 하나뿐이면 그 배정에 붙였다 — 그러면 그 씬의
    **다른 인물 몫** 근거가 엉뚱한 사람 것으로 붙는다. 앞단이 낸 **기대 부모
    좌표**(`grounding_facet_binding.parent_final_id`)로만 고른다.
    """

    def test_a_different_expected_parent_never_binds(self):
        got = ob.bind([_row(parent="C02")], _p3())
        r = got["rows"][0]
        assert r["status"] == ob.UNRESOLVED
        assert r["why"] == ob.WHY_PARENT_NOT_IN_SCENE
        assert r["candidates"] == ["C01"]

    def test_two_sources_in_one_scene_each_get_their_own(self):
        """★같은 씬의 두 인물이 각자 제 outlook 으로."""
        p3 = {"outlooks": [{"outlook_id": "O01"}, {"outlook_id": "O02"}],
              "scene_assignments": [{"scene_index": 2, "assignments": [
                  {"character_id": "C01", "outlook_id": "O01"},
                  {"character_id": "C02", "outlook_id": "O02"}]}]}
        got = ob.bind([_row("rs1", parent="C01"),
                       _row("rs2", parent="C02")], p3)
        assert [(r["status"], r["outlook_id"]) for r in got["rows"]] == [
            (ob.BOUND, "O01"), (ob.BOUND, "O02")]

    def test_no_parent_coordinate_means_no_guessing(self):
        got = ob.bind([_row(parent=None)], _p3())
        r = got["rows"][0]
        assert r["status"] == ob.UNRESOLVED
        assert r["why"] == ob.WHY_NO_PARENT
        assert r["expected_parent_final_id"] is None


class TestTheScreenVerdictGatesTheBuying:
    """★★★Codex 재현 (09-01) — 판별이 「참조 불필요」라고 한 줄도 결속만 되면
    **그대로 샀다**. 판별을 버리고 결속만 보면 돈 쓰는 까닭이 사라진다."""

    @pytest.mark.parametrize("screen,buys,auto", [
        ("obligation", 1, 0),
        ("not_target", 0, 0),
        ("unresolved", 0, 0),
        ("capped", 0, 0),
    ])
    def test_only_an_obligation_is_bought(self, screen, buys, auto):
        led = ob.bind([_row(screen=screen)], _p3())
        assert led["rows"][0]["status"] == ob.BOUND
        assert len(ob.acquisition_targets(led)) == buys
        assert len(ob.auto_completed(led)) == auto

    def test_an_obligation_that_cannot_bind_completes_itself(self):
        led = ob.bind([_row(segs=("scene-9",))], _p3())
        assert ob.acquisition_targets(led) == []
        auto = ob.auto_completed(led)
        assert len(auto) == 1
        from app.modules.pipeline import reference_acquisition as ra

        assert ob.unresolved_outcome(auto[0]) == ra.STATUS_UNAVAILABLE

    def test_a_non_target_that_cannot_bind_is_not_a_missing_reference(self):
        """★비대상은 애초에 살 것이 아니었다 — 「못 구했다」가 아니다."""
        led = ob.bind([_row(segs=("scene-9",), screen="not_target")], _p3())
        assert ob.acquisition_targets(led) == []
        assert ob.auto_completed(led) == []

    def test_the_screen_verdict_is_kept_verbatim(self):
        led = ob.bind([_row(screen="not_target")], _p3())
        assert led["rows"][0]["screen"] == "not_target"

    def test_the_screen_names_come_from_the_screen_module(self):
        """★상태 이름을 **여기서 다시 적지 않는다**."""
        from app.modules.pipeline import grounding_screen as gs

        assert gs.SCREEN_OBLIGATION == "obligation"
        assert {gs.SCREEN_NOT_TARGET, gs.SCREEN_UNRESOLVED,
                gs.SCREEN_CAPPED} == {"not_target", "unresolved", "capped"}


class TestEveryRowLandsInExactlyOneLane:
    """★★들어온 줄 수 = 세 자리의 합. 남으면 **우리가 모르는 일**이 생긴다."""

    def test_the_three_lanes_cover_everything(self):
        rows = [_row("a", screen="obligation"),
                _row("b", screen="obligation", segs=("scene-9",)),
                _row("c", screen="not_target"),
                _row("d", screen="capped"),
                _row("e", screen="unresolved", parent=None)]
        led = ob.bind(rows, _p3())
        acc = ob.accounting(led)
        assert acc["rows"] == 5
        assert sum(acc["counts"].values()) == 5
        assert acc["counts"] == {ob.LANE_BUY: 1, ob.LANE_AUTO_DONE: 1,
                                 ob.LANE_NOT_APPLICABLE: 3}

    def test_the_buy_lane_equals_the_buyer_list(self):
        """★회계와 **실제 구매 목록**이 같아야 한다 — 두 벌이면 갈린다."""
        rows = [_row("a"), _row("b", screen="not_target")]
        led = ob.bind(rows, _p3())
        acc = ob.accounting(led)
        assert ([r["research_subject_id"] for r in acc["lanes"][ob.LANE_BUY]]
                == [r["research_subject_id"]
                    for r in ob.acquisition_targets(led)])
        assert ([r["research_subject_id"]
                 for r in acc["lanes"][ob.LANE_AUTO_DONE]]
                == [r["research_subject_id"] for r in ob.auto_completed(led)])

    def test_a_row_with_an_unknown_screen_stops(self):
        """★★**뒤집힌 시험**이다 (Codex · 09-01).

        앞에는 「모르는 판별 값은 안 산다」로 잠갔는데, 그것이 바로 **잘못된
        접힘을 정답으로 못박는 것**이었다 — 계약 drift 가 「비대상」으로
        조용히 정상 처리된다. 이제 **선다**.
        """
        led = ob.bind([_row(screen="무엇인지 모름")], _p3())
        with pytest.raises(ob.LedgerContractError, match="판별"):
            ob.lane_of(led["rows"][0])
        with pytest.raises(ob.LedgerContractError):
            ob.accounting(led)

    @pytest.mark.parametrize("status", ["corrupt", None, ""])
    def test_a_row_with_an_unknown_status_stops(self, status):
        """★결속 상태도 마찬가지다 — 모르는 것을 자동 완료로 접지 않는다."""
        row = {"research_subject_id": "x", "screen": "obligation",
               "status": status}
        with pytest.raises(ob.LedgerContractError, match="결속"):
            ob.lane_of(row)

    def test_a_row_missing_both_fields_stops(self):
        with pytest.raises(ob.LedgerContractError):
            ob.lane_of({"research_subject_id": "x"})

    def test_a_broken_non_obligation_row_is_not_hidden(self):
        """★★비대상이라고 **깨진 줄을 숨기지 않는다**."""
        with pytest.raises(ob.LedgerContractError, match="결속"):
            ob.lane_of({"research_subject_id": "x", "screen": "not_target",
                        "status": "corrupt"})

    def test_the_known_names_come_from_the_screen_module(self):
        """★판별 값 목록을 **여기서 다시 적지 않는다**."""
        from app.modules.pipeline import grounding_screen as gs

        assert ob.known_screens() == frozenset(gs.SCREENS)
        assert ob.STATUSES == (ob.BOUND, ob.UNRESOLVED)


class TestTheCoordinateComesFromTheRealChain:
    """★★★Codex BLOCK (09-01) — `expected_parent_final_id` 를 **내는 곳이
    없었다.** 제 시험이 `_row(parent=…)` 로 값을 지어 넣어 통과했고, 실제 D
    경로에서는 모든 outlook 이 `no_expected_parent_coordinate` 로 떨어졌다.

    그래서 여기서는 **공개 사슬 전체**로 잰다 —
        reduced(+part_of+registered) → `grounding_facet_binding.bind`
        → `project_rows_and_candidates` → `build_subjects`
        → `build_population` → `screen_subjects` → `outlook_binding.bind`
    """

    TEXT = "이발사가 긴 겉옷을 입고 있었다."

    def _reduced(self):
        from app.modules.pipeline import grounding_chunk_merge as cm

        def R(lid, owner, s, e):
            return {"local_id": lid, "owner_type": owner,
                    "surface_form": self.TEXT[s:e],
                    "occurrences": [{"source_span": {"segment_id": "scene-1",
                                                     "start": s, "end": e},
                                     "source_quote": self.TEXT[s:e]}],
                    "shot_binding_status": "bound_complete",
                    "shot_appearance_ids": ["s1#1", "s1#2"],
                    "hard_to_generate": True, "viewers_would_notice": True,
                    "coarse_type_label": "두루마기", "visual_brief": "긴 겉옷",
                    "search_terms_native": ["두루마기"],
                    "language_lock_native": "ko"}

        a = self.TEXT.index("이발사")
        b = self.TEXT.index("긴 겉옷")
        return cm.reduce_episode(
            [R("c0#1", "character", a, a + 3), R("c0#2", "outlook", b, b + 4)],
            [{"remove_local_id": "c0#2", "keep_local_id": "c0#1",
              "relation": cm.REL_PART_OF}], segments={"scene-1": self.TEXT})

    def _screen_rows(self):
        from app.modules.pipeline import era_research as era
        from app.modules.pipeline import grounding_carry as gc
        from app.modules.pipeline import grounding_chunk_adapter as ad
        from app.modules.pipeline import grounding_screen as gs

        red = self._reduced()
        got = ad.project_rows_and_candidates(red, project_id="p",
                                             episode_id="e")
        built = gc.build_subjects(
            {k: list(v) for k, v in got["rows"].items()}, project_id="p",
            episode_id="e", source_step="entity_merge",
            a0_candidates=got["candidates"])
        disp = dict(built.get("subject_dispositions") or {})
        disp.update({r["research_subject_id"]: r["disposition"]
                     for r in built["candidate_ledger"]["rows"]})
        pop = gs.build_population(built["subjects"], got["candidates"], disp)
        sc = gs.screen_subjects(
            pop, dispositions=disp, world_facts_block="세계 사실",
            cache_get=lambda k: None, cache_put=lambda k, v: v,
            assess_fn=lambda **kw: {"status": era.PLAN_OK,
                                    "plan": {"assess_sha": "s",
                                             "subjects": [{"q": "x"}]}})
        return got, [r for r in sc["rows"] if r["owner_type"] == "outlook"]

    def _p3(self, cid="C01"):
        return {"outlooks": [{"outlook_id": "O01"}],
                "scene_assignments": [{"scene_index": 1, "assignments": [
                    {"character_id": cid, "outlook_id": "O01"}]}]}

    def test_the_emitter_puts_the_facet_coordinate_on_the_candidate(self):
        from app.modules.pipeline import grounding_chunk_adapter as ad
        from app.modules.pipeline import grounding_entity_contract as ec

        got, _rows = self._screen_rows()
        ol = [c for c in got["candidates"] if c["owner_type"] == "outlook"]
        assert len(ol) == 1
        fb = ol[0][ec.FACET_BINDING]
        # ★한 칸이 아니라 **덩어리** — 출처와 계약을 되짚을 수 있어야 한다
        assert fb["local_id"] == "c0#2" and fb["final_id"] == "O01"
        assert fb["parent_local_id"] == "c0#1"
        assert fb["parent_final_id"] == "C01"
        assert fb["parent_owner_type"] == "character"
        assert ad.LINK_KEY not in ol[0], "★entity row 가 없는데 링크를 달았다"

    def test_it_survives_to_the_screen_row(self):
        from app.modules.pipeline import grounding_entity_contract as ec

        _got, rows = self._screen_rows()
        assert rows and rows[0][ec.FACET_BINDING]["parent_final_id"] == "C01"

    def test_the_real_chain_actually_binds(self):
        """★★끝점 — 손으로 지은 값 없이 **결속이 실제로 일어난다**."""
        _got, rows = self._screen_rows()
        led = ob.bind(rows, self._p3())
        r = led["rows"][0]
        assert r["status"] == ob.BOUND
        assert r["outlook_id"] == "O01" and r["character_id"] == "C01"

    def test_a_different_character_in_phase3_refuses(self):
        """★음성 대조 — 그 씬 배정이 **다른 인물**이면 안 붙는다."""
        _got, rows = self._screen_rows()
        led = ob.bind(rows, self._p3(cid="C99"))
        r = led["rows"][0]
        assert r["status"] == ob.UNRESOLVED
        assert r["why"] == ob.WHY_PARENT_NOT_IN_SCENE

    def test_a_debt_row_gets_no_coordinate_at_all(self):
        """★부모가 없으면 **좌표를 만들지 않는다** — 짐작의 씨앗이다."""
        from app.modules.pipeline import grounding_chunk_adapter as ad
        from app.modules.pipeline import grounding_chunk_merge as cm
        from app.modules.pipeline import grounding_entity_contract as ec

        b = self.TEXT.index("긴 겉옷")
        lone = cm.reduce_episode([{
            "local_id": "c0#2", "owner_type": "outlook",
            "surface_form": self.TEXT[b:b + 4],
            "occurrences": [{"source_span": {"segment_id": "scene-1",
                                             "start": b, "end": b + 4},
                             "source_quote": self.TEXT[b:b + 4]}],
            "shot_binding_status": "bound_complete",
            "shot_appearance_ids": ["s1#1", "s1#2"],
            "hard_to_generate": True, "viewers_would_notice": True}],
            [], segments={"scene-1": self.TEXT})
        got = ad.project_rows_and_candidates(lone, project_id="p",
                                             episode_id="e")
        ol = [c for c in got["candidates"] if c["owner_type"] == "outlook"]
        assert ol and ec.FACET_BINDING not in ol[0]
        assert got["facet_debt"], "★빚을 안 남겼다"
        led = ob.bind([{**ol[0], "screen": "obligation",
                        "source_evidence": {"occurrences":
                                            ol[0]["occurrences"]}}],
                      self._p3())
        assert led["rows"][0]["why"] == ob.WHY_NO_PARENT
