"""**조사하기 전에** 참조 의무를 정한다. ★조사도 판정도 여기서 안 한다.

## 왜 조사 앞인가

Codex BLOCK (2026-09-01) — 앞 판은 맥락 멤버를 **조사가 끝난 뒤에** 만들었다.
그러면 —

    부모 줄 없는 `LP01` 하나에서
      상세  신원 LP01 · sha_LP01 · LP01.jpg
      맥락  **같은 조사 결과** · 같은 sha · 같은 경로

즉 「장소 맥락 사진」과 「부분 상세 사진」을 **따로 조사하지 않았다.** 쓰임만
다시 적었을 뿐이다. 그리고 부모 `L01` 이 비대상이면 중앙이 맥락을 아예 안
조사했으므로 **뒤에서 복구할 수도 없다.**

그래서 의무를 **먼저** 세운다. 의무마다 **다른 나가는 신원**을 갖고, 중앙
조사기가 각각 `selected`/`reference_unavailable` 를 내고, 묶음 투영은 그
결과를 **옮기기만** 한다.

## 규칙

    location_part          상세 의무 — `LP##` + `detail`
      ├ 부모 좌표가 있다   맥락 의무 — **`L##`** + `context`
      │                    ★부모 장소가 비대상이어도 **LP 의무에서 결정적으로
      │                     파생**한다. screen 을 다시 묻지 않는다.
      │                    ★같은 `L##` 의 형제들은 의무 **하나**로 합치고
      │                     `covers` 만 union 한다.
      └ 부모 좌표가 없다   맥락 의무 — `LP##` + `context`
                           (`explicit_context_only`) ★`L##` 를 **안 지어낸다**
    location               맥락 의무 — `L##` + `context`
    그 밖(인물·소품·아웃룩)  의무 하나 — 쓰임 없음. 배경 자리가 아니다

★`explicit_context_only` 의 조사 재료는 모델이 낸 `host_context` 의
`search_subject`/`evidence` 다 — 그 부분의 지문을 그대로 쓰면 상세와 **같은
것을 두 번 조사한다**. 재료가 없으면 없다고 적고 중앙에서 `skipped` 로
남긴다. 지어내지 않는다.
"""
from __future__ import annotations

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

from app.modules.pipeline.grounding_host_context import (PURPOSE_CONTEXT,
                                                         PURPOSE_DETAIL)

#: ★2026-09-01 — 부모 행 없는 LP 의 맥락이 `skipped` 에서 **실제 대상**으로
#:  바뀌었다. 이 판이 D 의 producer step config·sidecar 신원을 움직인다.
OBLIGATION_CONTRACT_VERSION = "2.202609012900"

#: 맥락 의무의 **주인**.
OWNER_PARENT = "bound_parent"          # 부모 장소가 갖는다
OWNER_SELF = "explicit_context_only"   # 부모 좌표가 없어 그 부분이 갖는다

#: 조사 재료가 없어 못 세운 의무의 사유.
WHY_NO_CONTEXT_SOURCE = "no_context_search_material"


def _context_payload_from_host(row: Dict[str, Any]) -> Optional[Dict[str, Any]]:
    """LP 의 `host_context` → **맥락 조사 재료**. 없으면 `None`.

    ★★그 장소를 찾는 질의는 **장소의 이름**이어야 한다 — 그 부분의 지문을
    그대로 쓰면 상세와 같은 것을 두 번 조사한다 (Codex BLOCK · 09-01).
    그래서 `search_subject` 를 질의로, `evidence` 를 근거로 쓴다.

    ★검색어를 **글자로 쪼개지 않는다** — 원문이 부른 말을 통째로 준다.
    """
    from app.modules.pipeline.grounding_entity_contract import HOST_CONTEXT
    from app.modules.pipeline.grounding_host_context import CONTEXT_ONLY

    hc = (row or {}).get(HOST_CONTEXT) or {}
    if str(hc.get("state") or "") != CONTEXT_ONLY:
        return None
    subj = str(hc.get("search_subject") or "").strip()
    # ★★★**검증된 좌표**가 있어야 한다 (Codex BLOCK · 09-01). 글자만 있으면
    #  어느 자리 것인지 못 되짚고, 다른 씬의 인용이나 지어낸 이름이 그대로
    #  조사하러 나간다. 좌표는 `grounding_chunk_merge` 가 그 행의 씬에서
    #  찾아 붙인 것이다.
    spans = [x for x in (hc.get("evidence_spans") or ())
             if isinstance(x, dict) and x.get("segment_id")
             and str(x.get("source_quote") or "").strip()]
    if not subj or not spans:
        return None
    return {
        "payload": {
            "coarse_type_label": subj,
            "visual_brief": subj,
            "search_terms_native": [subj],
            # ★어느 언어로 찾을지는 **그 행이 이미 정한 것**을 따른다
            "language_lock_native": str(
                ((row or {}).get("grounding_producer_payload") or {})
                .get("language_lock_native") or ""),
        },
        # ★기존 증거 모양 그대로 — 뒤가 다른 모양을 배우지 않게
        "source_evidence": {
            "source_quote": spans[0]["source_quote"],
            "source_anchor": (f"{spans[0]['segment_id']}:"
                              f"{spans[0].get('start')}-{spans[0].get('end')}"),
            "occurrences": [{"source_span": {k: v for k, v in sp.items()
                                             if k != "source_quote"},
                             "source_quote": sp["source_quote"]}
                            for sp in spans],
        },
    }


class ObligationError(RuntimeError):
    """의무를 세울 수 없다. ★조용히 건너뛰지 않는다."""


PURPOSE_OUTFIT = "outfit"
#: 야외 구조물의 **형태** 참조 의무 — 배경 lane 의 `context`/`detail` 과 다른 쓰임이다
#: (Codex 2026-09-03: 같은 final_id 라고 context 사진을 구조 형태 참조로 올리지 않는다).
#: `purpose` 는 비우고 `obligation_kind` 로 적는다 — 아웃룩과 같은 꼴.
OBLIGATION_STRUCTURE_FORM = "structure_form"


def outfit_obligation(final_id: str, pieces: Sequence[Dict[str, Any]], *,
                      outfit: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
    """한 아웃룩의 조각 줄들 → **의무 하나**. 뼈대 = 아웃룩 엔티티 이름(production 산출), 검색어 =
    조각 검색어 union, 언어 잠금 = 첫 값. 조각은 `details` 에 그대로(감사·i2i). 이름 목록·regex 없음."""
    from app.modules.pipeline.grounding_entity_contract import PRODUCER_PAYLOAD

    pieces = list(pieces)
    first = pieces[0]
    payloads = [dict((p or {}).get(PRODUCER_PAYLOAD) or {}) for p in pieces]
    lock = next((str(p.get("language_lock_native")) for p in payloads if p.get("language_lock_native")), "")
    kind = str((outfit or {}).get("name") or payloads[0].get("coarse_type_label") or "")
    desc = str((outfit or {}).get("description") or payloads[0].get("visual_brief") or "")
    # ★★검색어는 **옷 전체 뼈대**에서만 (Codex BLOCK 2026-09-03): 조각의 검색어(깃·신·끈)를 합치면 행만
    #  하나일 뿐 검색 의도는 여전히 조각 셋이다. 저작기는 종류 이름 + 시대·지역으로 넓은 질의를 쓴다.
    #  조각 검색어·묘사는 `details` 에만 남는다(감사·i2i).
    terms: List[str] = [kind] if kind else []
    merged_payload = {**payloads[0],
                      "coarse_type_label": kind, "surface_form": kind,
                      "visual_brief": desc, "search_terms_native": terms,
                      "language_lock_native": lock,
                      "hard_to_generate": any(p.get("hard_to_generate") is True for p in payloads),
                      "viewers_would_notice": any(p.get("viewers_would_notice") is True for p in payloads)}
    # ★`purpose` 는 배경 lane 의 쓰임(context/detail) 계약이라 여기선 비운다 — 아웃룩은 그 계약
    #  밖이다. 대신 `obligation_kind` 로 「옷 전체 하나」임을 적고, 대상 ID 는 `<final_id>#outfit`.
    return {**copy.deepcopy(first),
            "research_subject_id": obligation_subject_id(final_id, PURPOSE_OUTFIT),
            "purpose": None, "obligation_kind": PURPOSE_OUTFIT, "covers": [final_id],
            "origin_subject_ids": sorted({str(p.get("research_subject_id") or "") for p in pieces}),
            PRODUCER_PAYLOAD: merged_payload,
            # ★조각 — 세부는 i2i 몫. 검색에는 안 나간다
            "details": [{"research_subject_id": str(p.get("research_subject_id") or ""),
                         # ★조각의 표기는 원문 증거(source_evidence)에 있다 — payload 에는 없을 수 있다
                         "surface_form": str(((p.get(PRODUCER_PAYLOAD) or {}).get("surface_form"))
                                             or ((p.get("source_evidence") or {}).get("surface_form")) or ""),
                         "coarse_type_label": str(((p.get(PRODUCER_PAYLOAD) or {}).get("coarse_type_label")) or ""),
                         "visual_brief": str(((p.get(PRODUCER_PAYLOAD) or {}).get("visual_brief")) or ""),
                         "search_terms_native": list(((p.get(PRODUCER_PAYLOAD) or {}).get("search_terms_native")) or ())}
                        for p in pieces]}


def obligation_subject_id(final_id: str, purpose: Optional[str]) -> str:
    """의무 하나의 **대상 ID**. ★쓰임이 다르면 다른 대상이다.

    ★이것이 뒤에서 획득 신원을 가른다 — 같은 `LP01` 이라도 상세와 맥락은
    **다른 것을 조사한다**.
    """
    return f"{final_id}#{purpose}" if purpose else str(final_id)


def _carry(src: Dict[str, Any]) -> Dict[str, Any]:
    from app.modules.pipeline.grounding_entity_contract import PRODUCER_PAYLOAD

    out: Dict[str, Any] = {
        "source_evidence": copy.deepcopy(src.get("source_evidence") or {})}
    if src.get(PRODUCER_PAYLOAD):
        out[PRODUCER_PAYLOAD] = copy.deepcopy(src[PRODUCER_PAYLOAD])
    return out


def plan(ledger: Dict[str, Any]) -> Dict[str, Any]:
    """공통 장부 → **의무 장부**. ★모양은 같다 — 중앙 입구가 그대로 받는다.

    Returns:
        `{"contract_version", "rows": [...]}`. 각 줄은 공통 장부 줄에
        `purpose`·`covers`·`origin_subject_ids`·`context_owner` 가 붙은 것이다.

    ★조사 대상이 아닌 줄(비대상·못 이은 것)은 **그대로 지나간다** — 회계가
    온전해야 하고, 나중 UI 가 「무엇이 없었는지」를 찾을 수 있어야 한다.
    """
    from app.modules.pipeline import grounding_acquisition_ledger as gl
    from app.modules.pipeline.grounding_entity_contract import (
        BACKGROUND_LANE_OWNERS)

    rows = list((ledger or {}).get("rows") or ())
    by_final = {str(r.get("final_id") or ""): r for r in rows
                if r.get("final_id")}
    buy = {str(r.get("research_subject_id") or "")
           for r in gl.acquisition_targets(ledger)}

    out: List[Dict[str, Any]] = []
    #: `L##`(또는 자기 자신) → 그 맥락이 덮는 의무들
    context_covers: Dict[str, List[str]] = {}
    context_owner: Dict[str, str] = {}
    #: 부모 행이 없을 때 그 LP 가 낸 **맥락 조사 재료**
    self_context: Dict[str, Dict[str, Any]] = {}

    outfits = dict((ledger or {}).get("outfits") or {})
    outfit_rows: Dict[str, List[Dict[str, Any]]] = {}
    for r in rows:
        owner = str(r.get("owner_type") or "")
        rsid = str(r.get("research_subject_id") or "")
        fid = str(r.get("final_id") or "")
        if rsid not in buy:
            # ★조사 대상이 아니다 — 그대로 남긴다(회계·감사)
            out.append({**copy.deepcopy(r), "purpose": None,
                        "covers": [fid] if fid else [],
                        "origin_subject_ids": [rsid]})
            continue
        if owner == "outlook" and fid:
            # ★★아웃룩은 **final_id 당 의무 하나** (Codex BLOCK 2026-09-02 밤 · 사용자 확정 「옷 전체
            #  뼈대 하나 — 세부는 i2i」). 같은 옷의 조각 줄(윗도리·깃·신)이 각각 조사 대상이 되어 한
            #  옷에 사진 셋을 샀다. 조각은 `details` 로 남기고 한 의무로 접는다(아래).
            outfit_rows.setdefault(fid, []).append(r)
            continue
        if owner not in BACKGROUND_LANE_OWNERS:
            out.append({**copy.deepcopy(r), "purpose": None,
                        "covers": [fid], "origin_subject_ids": [rsid]})
            continue
        if owner == "location_part":
            out.append({**copy.deepcopy(r),
                        "research_subject_id": obligation_subject_id(
                            fid, PURPOSE_DETAIL),
                        "purpose": PURPOSE_DETAIL, "covers": [fid],
                        "origin_subject_ids": [rsid]})
            par = str(r.get("parent_final_id") or "")
            host = par or fid
            context_owner[host] = OWNER_PARENT if par else OWNER_SELF
            context_covers.setdefault(host, []).append(fid)
            if not par:
                # ★★부모 행이 없다 — `L##` 를 **안 지어내고**, 그 LP 가 낸
                #  맥락 재료로 **제 맥락 의무**를 세운다
                got = _context_payload_from_host(r)
                if got:
                    self_context[host] = got
        else:                                   # location
            context_owner.setdefault(fid, OWNER_PARENT)
            context_covers.setdefault(fid, []).append(fid)

    for fid in sorted(outfit_rows):
        out.append(outfit_obligation(fid, outfit_rows[fid], outfit=outfits.get(fid)))
    for host in sorted(context_covers):
        src = by_final.get(host)
        covers = sorted(set(context_covers[host]) | ({host} if src else set()))
        base: Dict[str, Any] = {
            "owner_type": ("location" if context_owner[host] == OWNER_PARENT
                           and (src or {}).get("owner_type") != "location_part"
                           else "location_part"),
            "research_subject_id": obligation_subject_id(host, PURPOSE_CONTEXT),
            "screen": _obligation_screen(),
            "status": _resolved(),
            "final_id": host,
            "parent_final_id": None,
            "purpose": PURPOSE_CONTEXT, "covers": covers,
            "context_owner": context_owner[host],
            "origin_subject_ids": sorted(
                {str(r.get("research_subject_id") or "") for r in rows
                 if str(r.get("final_id") or "") in set(context_covers[host])}),
        }
        # ★★★맥락의 조사 재료는 **맥락 것**이어야 한다.
        #  `explicit_context_only` 에서 그 부분의 지문을 그대로 쓰면 상세와
        #  **같은 것을 두 번 조사한다** — 신원만 다르고 질의가 같다.
        if context_owner[host] == OWNER_SELF:
            mine = self_context.get(host)
            if mine:
                from app.modules.pipeline.grounding_entity_contract import (
                    PRODUCER_PAYLOAD)

                base[PRODUCER_PAYLOAD] = copy.deepcopy(mine["payload"])
                base["source_evidence"] = copy.deepcopy(
                    mine["source_evidence"])
            else:
                # ★모델이 맥락을 안 냈거나 못 정했다 — 지어내지 않는다
                base["why"] = WHY_NO_CONTEXT_SOURCE
                base["source_evidence"] = {}
        elif src is None:
            base["why"] = WHY_NO_CONTEXT_SOURCE
            base["source_evidence"] = {}
        else:
            base.update(_carry(src))
        out.append(base)
    return {"contract_version": OBLIGATION_CONTRACT_VERSION, "rows": out}


def _obligation_screen() -> str:
    from app.modules.pipeline.grounding_screen import SCREEN_OBLIGATION

    return SCREEN_OBLIGATION


def _resolved() -> str:
    from app.modules.pipeline import grounding_acquisition_ledger as gl

    return gl.RESOLVED


def context_owner_of(obligations: Dict[str, Any]) -> Dict[str, str]:
    """맥락 의무마다 **누가 주인인가**. ★공개 끝점."""
    return {str(r.get("final_id") or ""): str(r.get("context_owner") or "")
            for r in (obligations or {}).get("rows") or ()
            if r.get("purpose") == PURPOSE_CONTEXT}


def purposes_of(obligations: Dict[str, Any]) -> Dict[str, List[str]]:
    """대상마다 **어떤 쓰임의 의무**가 섰나. ★공개 끝점."""
    out: Dict[str, List[str]] = {}
    for r in (obligations or {}).get("rows") or ():
        if r.get("purpose"):
            out.setdefault(str(r.get("final_id") or ""),
                           []).append(str(r["purpose"]))
    return {k: sorted(v) for k, v in out.items()}
