"""`location_part` 의 **맥락 상태** 계약. ★유료 0.

Codex DESIGN BLOCK 4 (2026-08-31) — 「글로만 적혀 있다」를 닫는다.

    bound_parent            parent_local_id 필수 · rows 에 존재 ·
                            owner=location · part_of 정확히 1개
    explicit_context_only   parent_local_id 금지 · search_subject/evidence 필수
    unresolved              의미 칸을 몰래 쓰지 않음

★어기면 「그럴듯한 기본값」으로 접지 않고 **`unresolved` + 사유**로 내린다.
★같은 LP 가 구간마다 다르게 말하면 **한쪽을 고르지 않는다** — conflict.
"""
from __future__ import annotations

import pytest

from app.modules.pipeline import grounding_host_context as hc

ROWS = {"c0#5": {"owner_type": "location", "surface_form": "국밥집"},
        "c0#9": {"owner_type": "prop", "surface_form": "됫박"}}
PART_OF = [{"part": "c0#2", "whole": "c0#5"}]


def _n(d, **kw):
    kw.setdefault("row_local_id", "c0#2")
    kw.setdefault("rows_by_id", ROWS)
    kw.setdefault("part_of", PART_OF)
    return hc.normalize(d, **kw)


class TestBoundParent:
    def test_a_good_one_passes(self):
        st, kept, why = _n({"state": hc.BOUND_PARENT,
                            "parent_local_id": "c0#5"})
        assert (st, kept["parent_local_id"], why) == (hc.BOUND_PARENT,
                                                      "c0#5", "")

    def test_no_parent_id_falls_to_unresolved(self):
        st, _k, why = _n({"state": hc.BOUND_PARENT})
        assert st == hc.HC_UNRESOLVED and "parent_local_id" in why

    def test_a_parent_not_in_the_rows_falls(self):
        st, _k, why = _n({"state": hc.BOUND_PARENT,
                          "parent_local_id": "c0#99"})
        assert st == hc.HC_UNRESOLVED and "없다" in why

    def test_a_parent_of_the_wrong_owner_falls(self):
        """★부모는 **장소**여야 한다 — 소품에 붙이면 안 된다."""
        st, _k, why = _n({"state": hc.BOUND_PARENT,
                          "parent_local_id": "c0#9"},
                         part_of=[{"part": "c0#2", "whole": "c0#9"}])
        assert st == hc.HC_UNRESOLVED and "prop" in why

    @pytest.mark.parametrize("links", [
        [],
        [{"part": "c0#2", "whole": "c0#5"}, {"part": "c0#2", "whole": "c0#7"}],
    ])
    def test_part_of_must_be_exactly_one(self, links):
        st, _k, why = _n({"state": hc.BOUND_PARENT,
                          "parent_local_id": "c0#5"}, part_of=links)
        assert st == hc.HC_UNRESOLVED and "part_of" in why

    def test_part_of_must_agree_with_the_declared_parent(self):
        st, _k, why = _n({"state": hc.BOUND_PARENT,
                          "parent_local_id": "c0#5"},
                         part_of=[{"part": "c0#2", "whole": "c0#7"}],
                         rows_by_id={**ROWS, "c0#7": {"owner_type": "location"}})
        assert st == hc.HC_UNRESOLVED and "다르다" in why


class TestExplicitContextOnly:
    def test_a_good_one_passes(self):
        st, kept, why = _n({"state": hc.CONTEXT_ONLY,
                            "search_subject": "머리를 깎는 가게",
                            "evidence": ["가게 앞에는"]})
        assert st == hc.CONTEXT_ONLY and kept["search_subject"] and why == ""

    def test_a_parent_id_is_forbidden(self):
        """★부모 행이 있으면 `bound_parent` 여야 한다 — 두 상태를 섞지 않는다."""
        st, _k, why = _n({"state": hc.CONTEXT_ONLY,
                          "parent_local_id": "c0#5",
                          "search_subject": "가게", "evidence": ["글"]})
        assert st == hc.HC_UNRESOLVED and "bound_parent" in why

    def test_no_search_subject_falls(self):
        st, _k, why = _n({"state": hc.CONTEXT_ONLY, "evidence": ["글"]})
        assert st == hc.HC_UNRESOLVED and "search_subject" in why

    def test_no_evidence_falls(self):
        st, _k, why = _n({"state": hc.CONTEXT_ONLY, "search_subject": "가게"})
        assert st == hc.HC_UNRESOLVED and "근거" in why

    def test_evidence_not_found_in_the_source_falls(self):
        """★근거는 **원문 그 자리**에 있어야 한다 — 대조는 부르는 쪽이 한다."""
        st, _k, why = _n({"state": hc.CONTEXT_ONLY, "search_subject": "가게",
                          "evidence": ["원문에 없는 말"]}, evidence_ok=False)
        assert st == hc.HC_UNRESOLVED and "지어냈" in why


class TestUnresolvedCarriesNothing:
    def test_a_clean_unresolved_passes(self):
        assert _n({"state": hc.HC_UNRESOLVED}) == (hc.HC_UNRESOLVED, {}, "")

    @pytest.mark.parametrize("field", ["parent_local_id", "search_subject"])
    def test_a_smuggled_semantic_field_falls(self, field):
        """★「모른다」면서 값을 남기면 하류가 그것을 믿는다."""
        st, kept, why = _n({"state": hc.HC_UNRESOLVED, field: "몰래"})
        assert st == hc.HC_UNRESOLVED and kept == {} and field in why

    def test_an_unknown_state_falls(self):
        st, _k, why = _n({"state": "지어낸상태"})
        assert st == hc.HC_UNRESOLVED and "모르는" in why

    def test_a_missing_host_context_falls(self):
        st, _k, why = _n(None)
        assert st == hc.HC_UNRESOLVED and why


class TestConflictIsNotResolvedBySilentlyPicking:
    """★★같은 LP 가 구간마다 다르게 말하면 **한쪽을 고르지 않는다**."""

    def test_the_same_answer_twice_agrees(self):
        st, kept, why = hc.reconcile([
            (hc.BOUND_PARENT, {"parent_local_id": "c0#5"}),
            (hc.BOUND_PARENT, {"parent_local_id": "c0#5"})])
        assert (st, kept["parent_local_id"], why) == (hc.BOUND_PARENT,
                                                      "c0#5", "")

    def test_different_states_become_unresolved(self):
        st, _k, why = hc.reconcile([
            (hc.BOUND_PARENT, {"parent_local_id": "c0#5"}),
            (hc.CONTEXT_ONLY, {"search_subject": "가게"})])
        assert st == hc.HC_UNRESOLVED and "conflict" in why

    def test_different_parents_become_unresolved(self):
        st, _k, why = hc.reconcile([
            (hc.BOUND_PARENT, {"parent_local_id": "c0#5"}),
            (hc.BOUND_PARENT, {"parent_local_id": "c0#7"})])
        assert st == hc.HC_UNRESOLVED and "conflict" in why

    def test_different_subjects_become_unresolved(self):
        st, _k, why = hc.reconcile([
            (hc.CONTEXT_ONLY, {"search_subject": "가게"}),
            (hc.CONTEXT_ONLY, {"search_subject": "다른 가게"})])
        assert st == hc.HC_UNRESOLVED and "conflict" in why

    def test_nothing_seen_is_unresolved(self):
        st, _k, why = hc.reconcile([])
        assert st == hc.HC_UNRESOLVED and why


class TestMemberIdentityKeepsContextAndDetailApart:
    """★쓰임을 빼면 부모를 재사용했다고 **상세까지 안 사는** 일이 난다."""

    def test_purpose_moves_the_identity(self):
        a = hc.member_identity(subject_final_id="LP01",
                               purpose=hc.PURPOSE_CONTEXT,
                               acquisition_identity="X")
        b = hc.member_identity(subject_final_id="LP01",
                               purpose=hc.PURPOSE_DETAIL,
                               acquisition_identity="X")
        assert a != b, "★맥락과 상세가 같은 신원이다"

    def test_the_outbound_identity_moves_it(self):
        a = hc.member_identity(subject_final_id="LP01",
                               purpose=hc.PURPOSE_DETAIL,
                               acquisition_identity="X")
        b = hc.member_identity(subject_final_id="LP01",
                               purpose=hc.PURPOSE_DETAIL,
                               acquisition_identity="Y")
        assert a != b, "★나가는 것이 달라도 같은 신원이다"

    def test_it_is_deterministic(self):
        kw = dict(subject_final_id="LP01", purpose=hc.PURPOSE_CONTEXT,
                  acquisition_identity="X")
        assert hc.member_identity(**kw) == hc.member_identity(**kw)

    @pytest.mark.parametrize("kw", [
        {"subject_final_id": "", "purpose": "context", "acquisition_identity": "X"},
        {"subject_final_id": "LP01", "purpose": "지어냄",
         "acquisition_identity": "X"},
        {"subject_final_id": "LP01", "purpose": "context",
         "acquisition_identity": ""},
    ])
    def test_a_missing_piece_stops(self, kw):
        with pytest.raises(ValueError):
            hc.member_identity(**kw)


class TestItDoesNotReadTheSourceItself:
    """★근거 대조는 **기존 함수**가 한다 — 두 벌로 만들지 않는다."""

    def test_the_module_does_not_search_the_text(self):
        import ast
        import inspect

        src = inspect.getsource(hc.normalize)
        tree = ast.parse(src.lstrip())
        called = {getattr(n.func, "attr", getattr(n.func, "id", ""))
                  for n in ast.walk(tree) if isinstance(n, ast.Call)}
        assert not (called & {"find", "index", "search", "_find_span",
                              "finditer"}), f"★원문을 여기서 뒤진다: {called}"


class TestReconcileKeepsEverythingItWasGiven:
    """★★★앞 판은 뜻이 같으면 **첫 구간만** 돌려주고 뒤 것을 버렸다.

    그리고 `normalize` 가 낸 실패 사유가 서명에 아예 없어서 **왜 떨어졌는지도
    잃었다**. 다구간에서는 이것이 **실제 기록 손실**이다 (Codex 2026-08-31).
    """

    E1 = {"segment_id": "s1", "start": 1, "end": 4}
    E2 = {"segment_id": "s2", "start": 5, "end": 9}

    def _rows(self, order):
        evs = {"a": [self.E1], "b": [self.E2]}
        return [(hc.CONTEXT_ONLY, {"search_subject": "가게",
                                   "evidence": evs[k]}) for k in order]

    def test_evidence_from_every_chunk_survives(self):
        st, kept, why = hc.reconcile(self._rows("ab"))
        assert st == hc.CONTEXT_ONLY and why == ""
        assert len(kept["evidence"]) == 2, \
            f"★뒤 구간 근거가 사라졌다: {kept.get('evidence')}"

    def test_the_order_of_the_chunks_does_not_change_the_bytes(self):
        """★같은 것을 합쳤으면 **같은 결과**여야 한다 — 신원이 흔들리면 안 된다."""
        assert hc.reconcile(self._rows("ab")) == hc.reconcile(self._rows("ba"))

    def test_the_same_span_twice_is_one(self):
        rows = [(hc.CONTEXT_ONLY, {"search_subject": "가게",
                                   "evidence": [self.E1]})] * 2
        _st, kept, _w = hc.reconcile(rows)
        assert len(kept["evidence"]) == 1, "★같은 자리를 두 번 셌다"

    def test_plain_string_evidence_also_merges(self):
        rows = [(hc.CONTEXT_ONLY, {"search_subject": "가게", "evidence": ["가"]}),
                (hc.CONTEXT_ONLY, {"search_subject": "가게", "evidence": ["나"]})]
        _st, kept, _w = hc.reconcile(rows)
        assert sorted(kept["evidence"]) == ["가", "나"]

    def test_failure_reasons_are_kept_per_chunk(self):
        st, kept, _w = hc.reconcile([
            (hc.HC_UNRESOLVED, {}, "근거가 없다"),
            (hc.HC_UNRESOLVED, {}, "부모가 판독 행에 없다")])
        assert st == hc.HC_UNRESOLVED
        assert len(kept.get("per_chunk") or []) == 2, \
            f"★사유가 사라졌다: {kept}"

    def test_reasons_survive_a_conflict_too(self):
        _st, kept, why = hc.reconcile([
            (hc.BOUND_PARENT, {"parent_local_id": "c0#5"}, ""),
            (hc.CONTEXT_ONLY, {"search_subject": "가게"}, "뭔가 이상했다")])
        assert "conflict" in why
        assert kept.get("per_chunk") == [{"chunk_id": "",
                                          "why": "뭔가 이상했다"}]

    def test_a_clean_run_carries_no_empty_audit_key(self):
        """★빈 칸을 만들지 않는다 — 빈 칸이 늘면 신원이 흔들린다."""
        _st, kept, _w = hc.reconcile(self._rows("ab"))
        assert "per_chunk" not in kept

    def test_the_old_two_tuple_shape_still_works(self):
        st, _k, _w = hc.reconcile([(hc.BOUND_PARENT,
                                    {"parent_local_id": "c0#5"})])
        assert st == hc.BOUND_PARENT

    def test_a_wrong_shape_stops(self):
        with pytest.raises(ValueError):
            hc.reconcile([(hc.BOUND_PARENT,)])


class TestTheAuditIsDeterministicAndPerChunk:
    """★★★앞 판은 **받은 차례 그대로** 담아서 `['A','B']` 와 `['B','A']` 가
    다른 결과였다(Codex 재현 2026-08-31). 그리고 **어느 구간 사유인지**도
    없었다 — 「per_chunk」라고 이름만 붙였지 구간을 안 적었다.
    """

    def test_the_order_of_the_chunks_does_not_change_it(self):
        a = hc.reconcile([(hc.HC_UNRESOLVED, {}, "A", "c0"),
                          (hc.HC_UNRESOLVED, {}, "B", "c1")])
        b = hc.reconcile([(hc.HC_UNRESOLVED, {}, "B", "c1"),
                          (hc.HC_UNRESOLVED, {}, "A", "c0")])
        assert a == b, "★차례가 결과를 바꾼다"

    def test_it_says_which_chunk(self):
        _st, kept, _w = hc.reconcile([(hc.HC_UNRESOLVED, {}, "근거 없음", "c3")])
        assert kept["per_chunk"] == [{"chunk_id": "c3", "why": "근거 없음"}]

    def test_the_same_reason_from_two_chunks_is_kept_apart(self):
        """★같은 사유라도 **구간이 다르면 다른 줄**이다 — 몇 곳인지 알아야 한다."""
        _st, kept, _w = hc.reconcile([
            (hc.HC_UNRESOLVED, {}, "근거 없음", "c0"),
            (hc.HC_UNRESOLVED, {}, "근거 없음", "c1")])
        assert len(kept["per_chunk"]) == 2

    def test_the_exact_same_line_twice_is_one(self):
        _st, kept, _w = hc.reconcile([
            (hc.HC_UNRESOLVED, {}, "근거 없음", "c0"),
            (hc.HC_UNRESOLVED, {}, "근거 없음", "c0")])
        assert len(kept["per_chunk"]) == 1

    def test_the_older_shapes_still_work(self):
        """★2·3-tuple 도 받는다 — 구간을 모르면 빈 이름으로 남는다."""
        assert hc.reconcile([(hc.HC_UNRESOLVED, {})])[0] == hc.HC_UNRESOLVED
        _st, kept, _w = hc.reconcile([(hc.HC_UNRESOLVED, {}, "X")])
        assert kept["per_chunk"] == [{"chunk_id": "", "why": "X"}]

    def test_a_wrong_shape_still_stops(self):
        with pytest.raises(ValueError):
            hc.reconcile([(hc.HC_UNRESOLVED, {}, "a", "b", "c")])
