"""GROUNDING-V2 §2-3.5 — A0 후보를 추출 입력에 **합류**시킨다.

설계: 계획 §1.8.

★A0 를 sidecar 로만 두면 **결속할 엔티티가 아예 없다.** ``entity_all`` 은
**shot description 만** 읽고(``entity_lister.py:170``) shot schema 는
``characters`` 가 필수가 아니라, 한 번만 크게 나오는 대상은 볼 기회조차 없다.
그래서 A0 산출이 **다음 단계 입력에 합류**해야 한다.

★**legacy 는 한 글자도 안 바뀐다** — 후보가 없으면 빈 목록을 돌려주고
호출부는 아무것도 안 붙인다.

★후보를 **넣기만 하고 끝나지 않는다.** 뒤 단계가 그것을 또 거를 수 있으므로
``missing_candidates`` 로 **완전성**을 확인한다 — 하나라도 사라지면
「미확정」이지 「통과」가 아니다.
"""
from __future__ import annotations

import unicodedata
from typing import Any, Dict, Iterable, List, Sequence, Optional

OVERLAY_CONTRACT_VERSION = 2

#: A0 의 owner_type → entity_all 의 entity_type
_OWNER_TO_ENTITY = {
    "prop": "prop",
    "character": "character",
    "location": "location",
    # ★facet 은 **base 갈래로 승격하지 않는다.** producer 가 §2-6.5 이고,
    #  여기서 base 로 올리면 「base location 을 prop 으로 우회 등록한 행 0건」
    #  (§2-6.5 통과 조건)을 지금 어기게 된다. `deferred` 로 센다.
    "location_part": None,
    "outlook": None,
}


def _norm(text: str) -> str:
    """비교용 정규화 — NFKC + 소문자 + 공백 축약. ★뜻으로 묶지 않는다."""
    return " ".join(unicodedata.normalize("NFKC", (text or "")).lower().split())


def candidates_for(
    candidates: Iterable[Dict[str, Any]], entity_type: str,
) -> List[Dict[str, Any]]:
    """그 갈래가 받아야 할 후보만. ★owner 를 억지로 바꾸지 않는다."""
    return [c for c in candidates
            if _OWNER_TO_ENTITY.get(c.get("owner_type")) == entity_type]


def build_overlay_lines(
    candidates: Iterable[Dict[str, Any]], entity_type: str,
) -> List[str]:
    """추출 입력에 덧붙일 줄. ★후보가 없으면 **빈 목록** — legacy 불변.

    ★「이미 제외 규칙에 걸릴 만한 것들이니 빼지 마라」를 함께 적는다.
    후보만 나열하면 모델이 기존 제외 규칙을 그대로 적용해 다시 지운다.

    ★**제외 기준 뒤에 놓아야 한다.** 앞에 놓으면 프롬프트가
    「빼지 마라 … 빼라」 순서가 되어 자기모순이고, 뒤에 오는 목록이 이긴다.
    조립 순서는 호출부 시험으로 잠근다.
    """
    mine = candidates_for(candidates, entity_type)
    if not mine:
        return []
    lines = [
        "[원문에서 먼저 건진 고증 후보 — ★위 제외 기준보다 이 목록이 우선합니다]",
        "아래는 원문을 읽고 **거르기 전에** 건진 것입니다. "
        "위에 적힌 제외 기준 중 무엇에 걸리더라도 **이 목록의 대상은 빼지 마세요.**",
    ]
    for c in mine:
        lines.append(
            f"- {c.get('surface_form','')} "
            f"(×{c.get('planned_occurrences', 1)}, {c.get('source_anchor','')}) "
            f"— {c.get('why_candidate','')}"
        )
    return lines


def missing_candidates(
    candidates: Iterable[Dict[str, Any]],
    surviving: Sequence[Dict[str, Any]],
    entity_type: str,
) -> List[Dict[str, Any]]:
    """후보 중 **산출에 안 남은 것**. ★없어진 것을 「없다」로 읽지 않는다.

    이름이 그대로 살아남는다는 보장은 없으므로 **정규화한 표면형이
    산출 이름 안에 들어 있는지**로 본다 — 「고무줄로 묶인 회수권 뭉치」가
    「회수권 뭉치」로 줄어도 잡는다.

    ★이것은 **글자로 뜻을 판단하는 것이 아니다.** 「같은 대상인가」를 묻는 게 아니라
    「내가 넣은 말이 산출에 남았는가」를 묻는 것이다 — 완전성 확인이다.
    """
    names = [_norm(e.get("name", "")) for e in surviving]
    out: List[Dict[str, Any]] = []
    for c in candidates_for(candidates, entity_type):
        want = _norm(c.get("surface_form", ""))
        if not want:
            continue
        if not any(want in n or n in want for n in names):
            out.append(c)
    return out


#: 보호해야 하는 route — 계약 §13 의 ``grounding_controlled`` 와 같다.
#: ★``skip`` 은 안 넣는다. 넣으면 저빈도 필터가 아무것도 못 지운다.
PROTECTED_ROUTES = frozenset({"research", "design", "unresolved"})


def protected_short_ids(
    decided: Iterable[Dict[str, Any]],
    entities_by_type: Dict[str, Sequence[Dict[str, Any]]],
) -> set:
    """조사·저작·미확정 대상의 ``short_id``. ★**기존 보호 통로에 union 한다.**

    계획 §1.8: 「새 필터 기구는 만들지 않는다 — 기존 ``protected_short_ids`` 통로에
    research 확정 후보를 union 한다」.

    ★``skip`` 은 보호하지 않는다. 넣으면 저빈도 필터가 아무것도 못 지우고,
    그러면 이 필터를 없앤 것과 같다.

    ★``unresolved`` 는 **보호한다** — 「모른다」를 「빼도 된다」로 읽으면
    계약 §3 을 어긴다.
    """
    want = {
        _norm(d.get("_surface_form") or d.get("surface_form") or "")
        for d in decided
        if d.get("route") in PROTECTED_ROUTES
    }
    want.discard("")
    out: set = set()
    for rows in entities_by_type.values():
        for e in rows:
            sid = (e.get("short_id") or "").strip()
            if not sid:
                continue
            name = _norm(e.get("name", ""))
            if name and any(w in name or name in w for w in want):
                out.add(sid)
    return out


#: 갈래별 `short_id` 접두 — `entity_steps._assign_short_ids` 와 같은 규칙.
#: ★★키를 **단수·복수 둘 다** 받는다. 호출부(`entity_filter`)의 dict 는
#:  `characters`/`locations`/`props` 인데 이 모듈의 다른 함수들은 단수를 쓴다.
#:  한쪽만 넣어 두면 `get()` 이 `None` 을 내고 **아무것도 안 만들면서 조용히
#:  통과**한다 — 이름이 달라 절반이 죽는 그 부류다.
def _build_entity_prefix() -> Dict[str, str]:
    """★접두는 **계약 모듈**에서 오고, **복수 alias 만** 코드로 만든다.

    여기 다섯 갈래를 다시 적으면 그것이 두 벌이다 (Codex).
    """
    from app.modules.pipeline.grounding_entity_contract import OWNER_PREFIX

    out: Dict[str, str] = {}
    for owner, pre in OWNER_PREFIX.items():
        out[owner] = pre
        out[f"{owner}s"] = pre          # ★복수 alias — 호출부 dict 가 복수다
    return out


_ENTITY_PREFIX = _build_entity_prefix()

#: 복수 키 → 단수 갈래. `candidates_for` 가 단수로 맞춰 보기 때문이다.
_KEY_TO_ENTITY_TYPE = {"characters": "character", "locations": "location",
                       "props": "prop"}


def materialize_missing_entities(
    candidates: Iterable[Dict[str, Any]],
    decided: Iterable[Dict[str, Any]],
    surviving_by_type: Dict[str, Sequence[Dict[str, Any]]],
) -> Dict[str, List[Dict[str, Any]]]:
    """★★★**어려운 단발 대상을 실제 엔티티 행으로 만든다.**

    왜 필요한가 — `protected_short_ids` 는 **이미 있는 행**의 이름을 찾아
    보호할 뿐이고, `promoted` subject 는 `short_id` 가 **없다**
    (`grounding_carry`: 「엔티티가 없어서 온 것이다」). 그래서 지금 통로는
    「이미 등록된 저빈도 행을 안 지움」까지만 하고, 사용자가 말한
    **「어려운 단발 대상을 엔티티에 등록」은 못 한다.**

    그러면 옛 화폐가 **조사 대상에는 있는데 엔티티·카드에는 없는** 반쪽이 된다.

    ★대상은 **`generation_difficulty` 가 여는 것만**이다. `route` 로 고르면
    안 된다 — 실측에서 아홉 축이 **전부** `research` 였다.

    ★facet(`location_part`·`outlook`)은 **안 올린다** — 계약
    (`GENERIC_PROMOTION_OWNERS`)이 정한다. LP 는 부모 없이 만들면 **고아**가
    되고, outlook 은 `OutlookSyncService` 가 SOT 다. 이미 등록된 `LP##` 행을
    **스캔·결속**하는 것은 다른 축이고 그쪽은 열려 있다.

    Returns:
        갈래 → **새로 만든 행들**. 기존 행은 안 건드린다.
    """
    from app.modules.pipeline.grounding_planner import needs_reference_acquisition

    want_ids = {str(d.get("research_subject_id") or "")
                for d in decided if needs_reference_acquisition(d)}
    want_ids.discard("")
    if not want_ids:
        return {}

    from app.modules.pipeline.grounding_entity_contract import (
        GENERIC_PROMOTION_OWNERS)

    out: Dict[str, List[Dict[str, Any]]] = {}
    for key, rows in surviving_by_type.items():
        prefix = _ENTITY_PREFIX.get(key)
        if not prefix:
            continue
        etype = _KEY_TO_ENTITY_TYPE.get(key, key)
        # ★★★**부모 없이 서도 되는 갈래만** 여기서 만든다 (Codex 2026-09-01).
        #  `location_part` 를 여기서 만들면 부모(`part_of`) 없는 **고아 LP** 가
        #  된다 — 그것은 `LP##` + 정확한 부모를 함께 내는 parent-aware 경로
        #  몫이다. `outlook` 은 `OutlookSyncService` 가 SOT 다.
        #  ★키가 표에 없으면 `etype` 이 복수형 그대로 흘러 **없는 갈래**로
        #   등록될 뻔했다 — 이 문이 그것도 막는다.
        if etype not in GENERIC_PROMOTION_OWNERS:
            continue
        gone = [c for c in missing_candidates(candidates, rows, etype)
                if str(c.get("research_subject_id") or "") in want_ids]
        if not gone:
            continue
        # ★번호는 **그 갈래에 이미 있는 것 다음부터**. 겹치면 하류가 엉뚱한
        #  행을 가리킨다.
        used = {str(e.get("short_id") or "") for e in rows}
        n = 0
        for c in gone:
            while True:
                n += 1
                sid = f"{prefix}{n:02d}"
                if sid not in used:
                    break
            used.add(sid)
            out.setdefault(key, []).append({
                "short_id": sid,
                "name": _norm(c.get("surface_form", "")),
                # ★**어디서 왔는지 남긴다.** 안 남기면 다음 사람이 이 행을
                #  「추출이 건진 것」으로 읽는다.
                "grounding_materialized": True,
                "research_subject_id": str(c.get("research_subject_id") or ""),
                "source_anchor": c.get("source_anchor") or "",
                "why_candidate": c.get("why_candidate") or "",
                "scene_indices": list(c.get("scene_indices") or []),
            })
    return out


def completeness_report(
    candidates: Iterable[Dict[str, Any]],
    surviving_by_type: Dict[str, Sequence[Dict[str, Any]]],
    *,
    promoted_ids: Optional[Iterable[str]] = None,
) -> Dict[str, Any]:
    """갈래별 완전성. ★하나라도 사라지면 통과가 아니다.

    ``deferred`` 는 **잃은 것이 아니다** — 이 단계에 받을 갈래가 없을 뿐이다
    (outlook 은 §2-6.5). 「없다」와 「여기서 못 받는다」를 갈라 센다.

    ★★``promoted_ids`` 도 **잃은 것이 아니다.** 엔티티에 없어서 A0 신원 그대로
    승격된 것들이라, 「엔티티 목록에 있나」로 물으면 당연히 없다 — 그런데 그건
    **사라진 것이 아니라 다른 자리로 간 것**이다. 이 인자가 없던 판에서는
    승격한 7개가 그대로 `missing` 으로 세어져 스텝이 `partial` 이 되고 하류가
    막혔다. 승격이 반쪽이었다.

    ★**명시로 받는다.** 후보 목록에서 「엔티티에 없으면 승격됐겠지」로 유추하면
    승격 안 된 것(얽혀서 `unresolved` 인 것)까지 통과시킨다.
    """
    cands = list(candidates)
    kept = {str(x) for x in (promoted_ids or ()) if str(x or "").strip()}
    missing: Dict[str, List[Dict[str, Any]]] = {}
    # ★★**두 수를 갈라 둔다.**
    #  `missing`          — 엔티티에도 없고 승격도 안 됐다. **진짜 잃은 것**
    #  `entity_missing`   — 엔티티 행이 없다. 승격됐어도 여기엔 든다
    #  앞의 것은 하류를 막고, 뒤의 것은 「추출이 지웠다」를 보여 주는 수다.
    #  합쳐 두면 승격이 게이트를 통째로 무디게 만든다.
    entity_missing: Dict[str, List[Dict[str, Any]]] = {}
    for etype, rows in surviving_by_type.items():
        no_row = missing_candidates(cands, rows, etype)
        if no_row:
            entity_missing[etype] = no_row
        gone = [c for c in no_row
                if str(c.get("research_subject_id") or "") not in kept]
        if gone:
            missing[etype] = gone
    deferred = [c for c in cands if _OWNER_TO_ENTITY.get(c.get("owner_type")) is None]
    checked = [c for c in cands if _OWNER_TO_ENTITY.get(c.get("owner_type")) is not None]
    return {
        "contract_version": OVERLAY_CONTRACT_VERSION,
        "candidate_count": len(cands),
        "checked_count": len(checked),
        "deferred": deferred,
        # ★엔티티에는 없지만 **승격되어 살아 있는** 것. 「사라졌다」와 갈라 센다.
        "promoted_count": len(kept),
        # ★엔티티 행이 없는 것 — 승격됐어도 든다. 「추출이 지웠다」를 보는 수다.
        "entity_missing": entity_missing,
        "entity_missing_count": sum(len(v) for v in entity_missing.values()),
        "missing": missing,
        "missing_count": sum(len(v) for v in missing.values()),
        "complete": not missing,
    }
