"""`location_part` 를 받는 sync 계약 — **순수 helper**. ★inert. 유료 0.

## 왜 순수 helper 로 먼저인가 (Codex 2026-08-31)

> 「기존 query 목록을 지금 무조건 넓히면 **inert 가 아니다**.」

`EntitySyncService` 의 목록을 그냥 늘리면 **legacy CP 에서도 동작이 바뀐다**.
그래서 —

    ①**표식이 있을 때만** 새 길로 간다 (`is_chunk_marked`)
    ②legacy CP 는 **옛 길 그대로** — byte·DB 효과가 같다
    ③새 길의 셈은 여기 **순수 함수**로 두고 시험으로 잠근다

## 고치는 결함 둘 (실측)

    ★번호 매기기 — `short_id[0]` + `int(short_id[1:])` 라 `LP01` 이
     **조용히 건너뛰어진다**. `L` counter 가 작아져 **있는 번호를 다시 발급**
     할 수 있다. 오류도 안 난다.
    ★canon 재사용 열쇠 — 이름 단독이면 `location` 과 `location_part` 의
     **같은 이름**이 조용히 충돌한다. 그리고 정본 ID 가 있는데 이름이 바뀌면
     **중복이 난다**.

## 아직 안 쓰인다

`app/` 어디서도 부르지 않는다.
"""
from __future__ import annotations

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

from app.modules.pipeline.grounding_entity_contract import (OWNER_PREFIX,
                                                            split_final_id)

#: CP 에 박히는 **명시 표식**. ★이것이 있어야만 새 길로 간다.
CHUNK_SCHEMA_MARKER = "grounding_v2_chunk_schema"
#: 표식 값. 모양이 바뀌면 이 값을 올린다.
CHUNK_SCHEMA_VERSION = "1"

#: 새 길이 다루는 갈래. ★`outlook` 은 **안 넣는다** — `OutlookSyncService` 관할.
CHUNK_OWNER_KEYS: Dict[str, str] = {
    "character": "characters", "location": "locations", "prop": "props",
    "location_part": "location_parts",
}


class SyncKeyConflict(RuntimeError):
    """두 열쇠가 **서로 다른** canon 을 가리킨다. ★임의로 고르지 않는다."""


class UnknownChunkSchema(RuntimeError):
    """표식은 **있는데** 모르는 판이다. ★조용히 옛 길로 안 간다.

    ★★★앞 판은 모르는 판을 legacy 로 떨어뜨렸다(내가 그걸 **의도로 시험에
    적기까지 했다**). 그러면 새 CP 인데 `location_part` 가 **조용히 버려진다**
    — 오류도 안 나고 행만 사라진다 (Codex 재현 2026-08-31).

    ★가르는 자리는 **표식이 있느냐**다:
        없다      → legacy. 옛 CP 라 옛 길이 맞다
        있고 아는 판 → 새 길
        있는데 모르는 판 → **선다**. 「무엇인지 모르는 것」을 옛 것으로 읽지 않는다
    """


def is_chunk_marked(cp: Any) -> bool:
    """이 체크포인트가 **새 모양**인가.

        표식이 없다        → `False` (legacy 길, 옛 동작 무변)
        아는 판이다        → `True`
        ★모르는 판이다     → **`UnknownChunkSchema` 로 선다**

    ★모르는 판을 legacy 로 떨어뜨리면 새 CP 의 `location_part` 가 **조용히
    버려진다**. 오류도 안 나고 행만 사라진다.
    """
    if not isinstance(cp, dict):
        return False
    if CHUNK_SCHEMA_MARKER not in cp:
        return False                                # ★표식 없음 = 옛 CP
    got = str(cp.get(CHUNK_SCHEMA_MARKER) or "")
    if got != CHUNK_SCHEMA_VERSION:
        raise UnknownChunkSchema(
            f"`{CHUNK_SCHEMA_MARKER}` 가 {got!r} 인데 아는 판은 "
            f"{CHUNK_SCHEMA_VERSION!r} 뿐이다 — 모르는 것을 옛 것으로 읽지 "
            "않는다. 사람이 보고 정해야 한다")
    return True


def seed_counters(short_ids: Sequence[str]) -> Dict[str, int]:
    """갈래별 **가장 큰 번호**. ★`owner_of_final_id` 와 **한 계약**을 쓴다.

    ★★옛 셈(`short_id[0]` + `int(short_id[1:])`)은 `LP01` 을 조용히 건너뛰어
    `L` counter 를 작게 만들었다 — **있는 번호를 다시 발급**할 수 있다.

    Returns:
        `{접두: 최대번호}`. ★못 푸는 것은 **세지 않는다**(옛 것과 같다).
    """
    out: Dict[str, int] = {p: 0 for p in OWNER_PREFIX.values()}
    for sid in short_ids:
        got = split_final_id(str(sid or ""))
        if got is None:
            continue
        owner, n = got
        pre = OWNER_PREFIX[owner]
        out[pre] = max(out.get(pre, 0), n)
    return out


def next_short_id(counters: Dict[str, int], owner: str) -> str:
    """다음 `short_id`. ★접두는 **계약**에서 온다 — 손으로 안 적는다."""
    pre = OWNER_PREFIX.get(owner)
    if pre is None:
        raise KeyError(f"모르는 갈래 {owner!r} — {sorted(OWNER_PREFIX)}")
    n = int(counters.get(pre, 0)) + 1
    counters[pre] = n
    return f"{pre}{n:02d}"


def resolve_canon(*, short_id: str, entity_type: str, name: str,
                  by_short_id: Dict[str, Any],
                  by_type_name: Dict[Tuple[str, str], Any]) -> Optional[Any]:
    """CP 의 한 줄이 DB 의 어느 canon 인가 — **short_id 가 정본** (Codex 계약 2026-09-03 06:55 · 실측 f7cc45c576c0).

    - incoming short_id 가 기존 행의 short_id 와 같으면 그 행(갱신).
    - incoming short_id 가 있는데 맞는 행이 없으면 **None(새 행)** — 이름이 같아도 **다른 short_id 를 가진 행은 남의 것**이다.
      동명·동타입·서로 다른 short_id 는 합법(실측: 「흙바닥」 LP05→L03 · LP07→L02).
      단, 이름이 같은 행 중 short_id 가 NULL 인 것이 **정확히 하나**면 그것을 이어받는다(legacy migration 후보).
    - incoming short_id 가 없으면(legacy CP) (type, name) 후보가 정확히 하나일 때만 그 행. 둘 이상이면 fail-closed —
      dict 로 하나를 고르지 않는다.
    `by_type_name` 값은 행 하나 또는 행 목록(동명 후보 전부)이어도 된다.
    """
    sid = str(short_id or "")
    if sid:
        a = by_short_id.get(sid)
        if a is not None:
            return a
    cands = by_type_name.get((str(entity_type), str(name)))
    if cands is None:
        cands = []
    elif not isinstance(cands, (list, tuple)):
        cands = [cands]
    if sid:
        # ★「비어 있는」 행 = short_id 가 원래부터 없던 legacy 행. pre-pass 가 잠시 NULL 로 만든 행(원래 지도 `by_short_id` 의 값)은
        #  남의 정본이라 비어 있는 것이 아니다 — 실측: LP05 가 NULL 임시화된 LP07 행을 이름으로 차지했다.
        _taken = {id(v) for v in by_short_id.values()}
        free = [c for c in cands if not getattr(c, "short_id", None) and id(c) not in _taken]
        if len(free) == 1:
            return free[0]
        if len(free) > 1:
            raise ValueError(
                f"`({entity_type}, {name})` 에 short_id 없는 canon 이 {len(free)}개 — 어느 것을 {sid} 로 이을지 모른다(fail-closed)")
        return None                                   # 남의 short_id 를 가진 동명 행은 재사용하지 않는다 → 새 행
    if len(cands) == 1:
        return cands[0]
    if len(cands) > 1:
        raise ValueError(
            f"legacy 줄 `({entity_type}, {name})` 에 canon 후보가 {len(cands)}개 — 이름만으로는 못 가른다(fail-closed)")
    return None

def owner_keys(cp: Any) -> List[str]:
    """이 CP 에서 읽을 **갈래 키들**. ★표식이 없으면 **옛 셋 그대로**.

    ★이것이 「무조건 넓히지 않는다」의 자리다 — legacy CP 는 `location_parts`
    를 **아예 안 본다**.
    """
    legacy = ["characters", "locations", "props"]
    if not is_chunk_marked(cp):
        return legacy
    return legacy + ["location_parts"]
