"""`location_part` 가 **어디에 달려 있나** — 세 상태 계약. ★유료 0.

## 왜 있나 (Codex DESIGN BLOCK 4, 2026-08-31)

부분만 홀로 찾으면 **어느 장소의 무엇인지**를 못 준다. 실측(1960년대 원고):
「이발소 회전 간판」은 `part_of` 부모가 **아예 없어서** 홀로 대상이 됐다 —
「이발소」가 행에 없기 때문이다.

★그렇다고 **부모 엔티티를 지어내면 안 된다.** 원문에 독립된 부모가 없는데
엔티티를 만들면 없는 신원을 만들고 같은 장소가 구간마다 중복 생긴다.
**엔티티 관계**와 **검색 맥락**은 다른 것이다 — 그 둘을 갈라야 사용자 요구
(「1960년대 이발소 맥락 + 회전 간판 상세, 참조 여럿, 완전 자동」)를 지킨다.

## 세 상태

    bound_parent            원문에 부모가 **독립 대상**으로 있다
                            → `parent_local_id` **필수** · rows 에 **존재** ·
                              owner **= location** · `part_of` **정확히 1개**
    explicit_context_only   장소 맥락은 있는데 **부모 행이 없다**
                            → `parent_local_id` **금지** ·
                              `search_subject`·`evidence` **필수**
    unresolved              맥락도 못 정했다
                            → 의미 칸을 **몰래 쓰지 않는다**

★`grounding_shot_catalog` 의 세 상태 계약과 **같은 모양**이다 — 어기면
「그럴듯한 기본값」으로 접지 않고 **`unresolved` + 사유**로 내린다.

## 갈리면 고르지 않는다

같은 LP 가 여러 구간에서 **서로 다른** `host_context` 를 내면 한쪽을 고르지
않는다 — `conflict` 사유와 함께 **`unresolved`** 로 남긴다. 그래야 나중에
사람이 무엇이 갈렸는지 본다.
"""
from __future__ import annotations

from typing import Any, Dict, List, Optional, Sequence, Tuple

#: 상태 이름. ★코드가 문자열을 다시 적지 않게 여기서만 정한다.
BOUND_PARENT = "bound_parent"
CONTEXT_ONLY = "explicit_context_only"
HC_UNRESOLVED = "unresolved"
HC_STATES = (BOUND_PARENT, CONTEXT_ONLY, HC_UNRESOLVED)

#: 이 갈래만 `host_context` 를 갖는다. ★owner enum 에서 온다.
HOST_CONTEXT_OWNER = "location_part"
#: `bound_parent` 일 때 부모가 되어야 하는 갈래.
PARENT_OWNER = "location"

#: 의미 칸 — `unresolved` 면 **비어 있어야** 한다.
_SEMANTIC_FIELDS = ("parent_local_id", "search_subject")


def normalize(hc: Optional[Dict[str, Any]], *, row_local_id: str,
              rows_by_id: Dict[str, Dict[str, Any]],
              part_of: Sequence[Dict[str, Any]],
              evidence_ok: bool = True) -> Tuple[str, Dict[str, Any], str]:
    """한 행의 `host_context` 를 계약에 맞춰 내린다.

    Args:
        hc: 모델이 낸 것. 없으면 `unresolved`.
        row_local_id: 이 행의 ID.
        rows_by_id: 같은 판독의 모든 행 (부모 존재·갈래 확인용).
        part_of: `{"part", "whole"}` 관계 목록.
        evidence_ok: 근거 인용이 **원문 그 자리에 있었나** — 부르는 쪽이
            기존 span 대조(`grounding_chunk._find_span`)를 태워 넘긴다.
            ★여기서 원문을 다시 읽지 않는다(두 벌이 된다).

    Returns:
        `(state, kept, why)`. 어기면 `state=unresolved` 이고 `why` 에 사유.
        ★「그럴듯한 기본값」으로 접지 않는다.
    """
    d = dict(hc or {})
    st = str(d.get("state") or "").strip()
    if st not in HC_STATES:
        return HC_UNRESOLVED, {}, f"모르는 맥락 상태 {st!r} — {HC_STATES}"

    pid = str(d.get("parent_local_id") or "").strip()
    subj = str(d.get("search_subject") or "").strip()
    ev = list(d.get("evidence") or ())

    if st == HC_UNRESOLVED:
        # ★의미 칸을 **몰래 쓰지 않는다** — 「모른다」면서 값을 남기면
        #  하류가 그것을 믿는다.
        smuggled = [k for k in _SEMANTIC_FIELDS if str(d.get(k) or "").strip()]
        if smuggled:
            return HC_UNRESOLVED, {}, (
                f"`{HC_UNRESOLVED}` 인데 의미 칸 {smuggled} 에 값이 있다 — "
                "「모른다」와 어긋난다")
        return st, {}, ""

    if st == CONTEXT_ONLY:
        if pid:
            return HC_UNRESOLVED, {}, (
                f"`{CONTEXT_ONLY}` 인데 `parent_local_id` 가 {pid!r} 다 — "
                "부모 행이 있으면 `bound_parent` 여야 한다")
        if not subj:
            return HC_UNRESOLVED, {}, (
                f"`{CONTEXT_ONLY}` 인데 `search_subject` 가 없다 — "
                "무엇을 맥락으로 찾을지가 없다")
        if not ev:
            return HC_UNRESOLVED, {}, (
                f"`{CONTEXT_ONLY}` 인데 근거가 없다 — 원문 어디서 왔는지 "
                "없으면 지어낸 것과 못 가른다")
        if not evidence_ok:
            return HC_UNRESOLVED, {}, (
                "근거 인용이 원문 그 자리에 없다 — 지어냈거나 다듬어 적었다")
        return st, {"search_subject": subj, "evidence": ev}, ""

    # bound_parent
    if not pid:
        return HC_UNRESOLVED, {}, (
            f"`{BOUND_PARENT}` 인데 `parent_local_id` 가 없다")
    parent = rows_by_id.get(pid)
    if parent is None:
        return HC_UNRESOLVED, {}, (
            f"`{BOUND_PARENT}` 의 부모 {pid!r} 가 판독 행에 **없다**")
    powner = str(parent.get("owner_type") or "")
    if powner != PARENT_OWNER:
        return HC_UNRESOLVED, {}, (
            f"부모 {pid!r} 의 갈래가 {powner!r} 다 — `{PARENT_OWNER}` 여야 한다")
    links = [x for x in part_of if str(x.get("part")) == str(row_local_id)]
    if len(links) != 1:
        return HC_UNRESOLVED, {}, (
            f"`part_of` 가 {len(links)}개다 — `{BOUND_PARENT}` 는 정확히 1개")
    if str(links[0].get("whole")) != pid:
        return HC_UNRESOLVED, {}, (
            f"`part_of` 부모 {links[0].get('whole')!r} 와 "
            f"`parent_local_id` {pid!r} 가 다르다")
    return st, {"parent_local_id": pid}, ""


def reconcile(seen: Sequence[Any]) -> Tuple[str, Dict[str, Any], str]:
    """구간마다 낸 것을 합친다. ★**한쪽을 고르지 않고, 버리지도 않는다**.

    ★★★앞 판은 뜻이 같으면 `got[0]` 을 그대로 돌려줬다 — 그러면 **뒤 구간의
    근거가 통째로 사라진다**(Codex 2026-08-31). 그리고 `normalize` 가 낸
    실패 사유(`why`)가 서명에 아예 없어서 **왜 떨어졌는지도 잃었다**.
    다구간에서는 이것이 **실제 기록 손실**이다.

    이제 —

        뜻이 같으면      근거를 **결정적으로 합친다**(정렬·중복 제거).
                        입력 차례가 바뀌어도 **같은 결과**여야 한다
        뜻이 다르면      `conflict` → `unresolved` (앞과 같다)
        실패한 구간      사유를 **구간별로 보존**한다

    Args:
        seen: `(state, kept)` 또는 `(state, kept, why)`. ★뒤엣것을 권한다 —
            `why` 를 안 주면 실패 사유가 어디에도 안 남는다.

    Returns:
        `(state, kept, why)`. `kept["evidence"]` 는 **합쳐진** 근거이고,
        `kept["per_chunk"]` 에 구간별 사유가 남는다.
    """
    rows: List[Tuple[str, Dict[str, Any], str, str]] = []
    for item in seen:
        t = tuple(item)
        if len(t) == 2:
            rows.append((str(t[0]), dict(t[1] or {}), "", ""))
        elif len(t) == 3:
            rows.append((str(t[0]), dict(t[1] or {}), str(t[2] or ""), ""))
        elif len(t) == 4:
            rows.append((str(t[0]), dict(t[1] or {}), str(t[2] or ""),
                         str(t[3] or "")))
        else:
            raise ValueError(
                f"모르는 모양 {t!r} — (state, kept[, why[, chunk_id]])")

    # ★실패 사유는 **어느 갈래로 가든** 보존하고 **어느 구간**인지도 적는다
    audit = [(c, w) for _s, _k, w, c in rows if w]
    if not rows:
        return HC_UNRESOLVED, {}, "맥락을 낸 구간이 없다"

    states = {st for st, _k, _w, _c in rows}
    if len(states) > 1:
        return HC_UNRESOLVED, _audit(audit), (
            f"구간마다 맥락 상태가 다르다 {sorted(states)} — conflict")
    st = rows[0][0]
    if st == HC_UNRESOLVED:
        return st, _audit(audit), ""

    key = "parent_local_id" if st == BOUND_PARENT else "search_subject"
    vals = {str(k.get(key) or "") for _s, k, _w, _c in rows}
    if len(vals) > 1:
        return HC_UNRESOLVED, _audit(audit), (
            f"구간마다 {key} 가 다르다 {sorted(vals)} — conflict")

    out: Dict[str, Any] = {key: sorted(vals)[0]}
    # ★★근거는 **합친다** — 뒤 구간 것을 버리지 않는다.
    #  정렬+중복 제거라 **입력 차례가 바뀌어도 같은 bytes** 가 나온다.
    ev = {_ev_key(x): x for _s, k, _w, _c in rows
          for x in (k.get("evidence") or ())}
    if ev:
        out["evidence"] = [ev[t] for t in sorted(ev)]
    out.update(_audit(audit))
    return st, out, ""


def _ev_key(x: Any) -> Tuple[str, str, str]:
    """근거 하나의 **정본 열쇠**. ★같은 자리를 가리키면 같은 것으로 본다.

    구조화된 span 이면 `(segment_id, start, end)`, 글자만 오면 그 글자.
    ★글자 비교로 **뜻**을 판단하는 것이 아니다 — **같은 자리인가**만 본다.
    """
    if isinstance(x, dict):
        return (str(x.get("segment_id") or ""), str(x.get("start") or ""),
                str(x.get("end") or ""))
    return ("", "", str(x))


def _audit(reasons: Sequence[Tuple[str, str]]) -> Dict[str, Any]:
    """구간별 실패 사유. ★**결정적**이어야 한다 — 차례에 안 흔들린다.

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

    이제 `(chunk_id, 사유)` 로 **정렬·중복 제거**한다.
    ★비면 칸을 안 만든다 — 빈 칸이 늘면 신원이 흔들린다.
    """
    got = sorted({(str(c or ""), str(r)) for c, r in reasons if r})
    return {"per_chunk": [{"chunk_id": c, "why": r} for c, r in got]} \
        if got else {}


#: 묶음 멤버의 쓰임. ★`background` kind 안에서 이것으로 갈린다.
PURPOSE_CONTEXT = "context"
PURPOSE_DETAIL = "detail"
PURPOSES = (PURPOSE_CONTEXT, PURPOSE_DETAIL)


def member_identity(*, subject_final_id: str, purpose: str,
                    acquisition_identity: str) -> str:
    """묶음 멤버 하나의 신원. ★**결정적**이어야 재사용과 재구매가 안 갈린다.

    접는 것 — LP 정본 ID · 쓰임 · **실제 나가는 획득 신원**.
    ★쓰임을 빼면 맥락과 상세가 같은 것이 되어, 부모를 재사용했더니 상세까지
    안 사는 일이 난다.
    """
    import hashlib

    if purpose not in PURPOSES:
        raise ValueError(f"모르는 쓰임 {purpose!r} — {PURPOSES}")
    if not str(subject_final_id or "").strip():
        raise ValueError("`subject_final_id` 가 비었다")
    if not str(acquisition_identity or "").strip():
        raise ValueError("획득 신원이 비었다 — 무엇으로 사는지가 없다")
    h = hashlib.sha256()
    for part in (str(subject_final_id), str(purpose), str(acquisition_identity)):
        h.update(part.encode("utf-8"))
        h.update(b"\x00")
    return h.hexdigest()[:20]
