"""엔티티 등록 계약 — **한 벌**. ★두 곳에 적으면 한쪽만 고쳐진다.

Codex (2026-08-31): 「`MIN_OCCURRENCES=2` 와 `entity_steps` 의 `max_scenes=2`
가 여전히 두 상수이고, 시험이 production 소스를 정규식으로 읽습니다. 검사를
시험으로 옮겼을 뿐 **SOT 가 하나가 된 것은 아닙니다.**」

그래서 값을 **여기 한 곳**에 두고, 저빈도 필터를 부르는 자리와 chunk merge 가
**같은 이름을 소비**한다. 시험이 소스를 파싱할 일도 없어진다.

★owner→접두도 마찬가지다. `A0` schema 가 갈래를 늘리면 접두가 없어 최종 ID
발급이 **터진다** — 그래서 parity 를 검사한다.
"""
from __future__ import annotations

from typing import Dict, Optional, Tuple

#: 「이 횟수 미만이면 저빈도」 — `entity_filter(max_scenes=…)` 가 쓰는 값이고
#: chunk merge 의 등록 문턱이기도 하다. **사용자 확정: 2회 이상 남긴다.**
ENTITY_MIN_OCCURRENCES = 2

#: ★`entity_canon.entity_type` 이 받는 갈래 — **다섯 전부** (§2-6.5, 2026-09-01).
#:  ★DB 는 막지 않는다: 그 칸은 `text` 이고 check 제약이 **없다**(원본 실측 —
#:   location 1333 · character 932 · outlook 872 · prop 688 · location_part 0).
#:   즉 막고 있던 것은 **이 목록**이었지 schema 가 아니다. alembic 이 필요 없다.
#:  ★`location` 으로 등록하면 그것이 base 갈래 **우회 등록**이라 금지다 —
#:   `location_part` 는 **제 갈래로** 선다(접두 `LP`).
#:  ★이 상수의 주인은 **도메인 계약**이다. adapter 는 소비자지 주인이 아니다
#:   (Codex 2026-08-31).
MATERIALIZABLE_OWNER_TYPES = ("character", "location", "location_part",
                              "outlook", "prop")

#: ★★**뜻이 다른 집합을 하나로 합치지 않는다** (Codex BLOCK 1, 2026-09-01).
#:  `EntitySyncService` 가 **소유**하는 갈래 — `outlook` 은 여기 **없다**.
#:  그것은 `OutlookSyncService` 몫이고, base EntitySync 가 삼키면 두 주인이
#:  같은 행을 쓴다. 그래서 「전체 materializable」에서 그것만 뺀 것이다.
ENTITY_SYNC_OWNER_TYPES: Tuple[str, ...] = tuple(
    o for o in MATERIALIZABLE_OWNER_TYPES if o != "outlook")

#: ★★★**일반 승격(generic promotion)으로 새로 만들 수 있는 갈래.**
#:  `materialize_missing_entities` 가 「A0 가 봤는데 엔티티가 없다」를 그냥
#:  만들어 주는 자리다 — 그러니 **부모 없이 서도 되는 갈래만** 온다.
#:
#:  ★`location_part` 는 **여기 없다** (Codex 2026-09-01): 부모(`part_of`)
#:   없이 만들면 **고아 LP** 가 된다. 새로 만들려면 `LP##` + 정확한 부모 +
#:   등록된 location 을 함께 내는 **parent-aware** 경로여야 한다.
#:  ★`outlook` 도 없다 — `OutlookSyncService` 가 SOT 다.
#:  ★★「스캔할 갈래」와 **다르다**: 스캔은 `ENTITY_SYNC_OWNER_TYPES` 이고,
#:   이미 있는 `LP##` 행은 스캔·결속·고증 대상이 된다.
GENERIC_PROMOTION_OWNERS: Tuple[str, ...] = ("character", "location", "prop")

#: ★★producer 의 **신원과 계약 판**. 결속 링크를 검증할 때 「아는 발급자인가」를
#:  본다 — 아무 비공백 문자열이나 믿으면 그것은 검증이 아니다 (Codex · 09-01).
#:  ★여기 두는 까닭: `grounding_carry` 는 C(c) 모듈을 **부르면 안 된다**(아직
#:   inert). 계약 모듈은 그 가드 밖이라 **양쪽이 같은 값을 본다**.
PRODUCER_ISSUER = "grounding_chunk_adapter"
#: ★모양이 바뀌면 **올린다** — 그래야 옛 판이 조용히 섞이지 않는다.
#:  `2.` — producer 의 **구조화 판정**(`grounding_producer_payload`)을 후보에
#:  같이 싣기 시작한 판 (2026-09-01).
#: ★2026-09-01 — 후보가 `HOST_CONTEXT` 를 함께 싣기 시작했다.
#:  ★`KNOWN_PRODUCERS` 는 **옛 판도 받는다** — 이미 적힌 링크가 갑자기
#:   「모르는 계약」이 되면 안 된다.
PRODUCER_CONTRACT_VERSION = "3.202609012800"
PRODUCER_CONTRACT_ACCEPTED: Tuple[str, ...] = (PRODUCER_CONTRACT_VERSION,
                                               "2.202609011200")

#: ★★★producer 가 **유료로 낸 구조화 판정**이 앉는 칸. 원문 증거
#:  (`source_evidence`)와 **섞지 않는다** — 하나는 원고가 말한 것이고 하나는
#:  모델이 판단한 것이라, 섞으면 어느 쪽을 믿는지 못 가른다 (Codex · 09-01).
PRODUCER_PAYLOAD = "grounding_producer_payload"

#: ★★★facet 결속의 **구조 좌표 한 덩어리**. 한 칸(`parent_final_id`)만 떼면
#:  출처와 계약을 되짚을 수 없다 (Codex · 09-01).
#:  `{local_id, final_id, owner_type, parent_local_id, parent_final_id,
#:    parent_owner_type}`
FACET_BINDING = "grounding_facet_binding"

#: ★`location_part` 가 **어디에 달렸나**. 모델이 내고 `grounding_chunk_merge`
#:  가 계약대로 내린 것이 이 이름으로 실려 간다 — 후보 → 정본 행 → 장부 →
#:  의무 계획. ★이름을 자리마다 다시 적으면 한쪽만 고쳐진다.
HOST_CONTEXT = "grounding_host_context"

#: 그 안에 담기는 칸. ★**있을 때만** 담는다 — 없는 값을 만들지 않는다.
#:  ★쓰임이 갈린다 — 다만 **한 방향으로만** (2026-09-02 정정):
#:    `visual_brief`·검색어·언어 잠금 → **검색 저작에만**
#:    `coarse_type_label`            → 판정에도, 검색 저작에도
#:  막아야 하는 것은 **심판이 `visual_brief` 를 보는 것** 하나다. 그것을
#:  보여 주면 VLM 이 「무엇인가」 대신 「어떻게 생겼나」로 답한다.
PRODUCER_PAYLOAD_FIELDS: Tuple[str, ...] = (
    "coarse_type_label", "visual_brief", "search_terms_native",
    "language_lock_native", "hard_to_generate", "viewers_would_notice",
    "shot_binding_status", "shot_appearance_ids",
)
#: VLM 종류 판정으로 나가도 되는 칸.
#: ★★★**아직 「선언」이다** (Codex 정정 · 09-01). 이 두 표를 읽는 **실제
#:  provider 발신부가 아직 없다** — 중앙 획득을 배선할 때 「coarse 만 judge 로,
#:  visual/검색어/언어 잠금만 writer 로」 나가는 **공개 끝점**을 반드시 둔다.
#:  ★그때까지 「실제 전송도 갈렸다」고 **보고하지 않는다.**
JUDGE_FIELDS: Tuple[str, ...] = ("coarse_type_label",)
#: 검색 저작으로 나가도 되는 칸.
#:  ★★★금지는 **한 방향**이다 (2026-09-02). 막아야 하는 것은
#:   **심판이 `visual_brief` 를 보는 것** — 그러면 시대·형태 세부를 판정하게
#:   되고 사람도 사진 한 장으로 못 하는 일을 시킨다(사용자 확정 08-31).
#:   반대로 **검색 저작기가 부류 이름을 쓰는 것은 필요하다** —
#:   `grounding_ref_brief.build_brief_user` 가 `coarse_type_label` 을
#:   **요구한다**. 앞 판은 검색 쪽에 네 칸만 줘서 실제 첫 대상이 저작 전에
#:   `BriefInputsMissing` 으로 죽었다 (Codex BLOCK 2026-09-02).
SEARCH_FIELDS: Tuple[str, ...] = ("visual_brief", "search_terms_native",
                                  "language_lock_native",
                                  "coarse_type_label", "surface_form")


#: 원문 구간 id 의 **꼴**. ★한 곳에서만 만들고 한 곳에서만 되읽는다.
#:  ★★★같은 규칙이 세 자리에 흩어져 있었다 (`grounding_chunk_step` 이 만들고
#:   `grounding_shot_catalog` 가 되읽고 `grounding_outlook_binding` 이 또
#:   만들었다). 한쪽만 고치면 좌표가 어긋난다.
SEGMENT_ID_PREFIX = "scene-"


def scene_key(scene_index: Any) -> str:
    """씬 번호 → 원문 구간 id."""
    return f"{SEGMENT_ID_PREFIX}{scene_index}"


def scene_index_of_key(segment_id: Any) -> Optional[int]:
    """원문 구간 id → 씬 번호. ★꼴이 아니면 `None` — 짐작하지 않는다.

    ★이것은 **꼴 되읽기**이지 뜻 판단이 아니다. 만드는 쪽과 같은 규칙을
    쓴다(위 `scene_key`).
    """
    got = str(segment_id or "")
    if not got.startswith(SEGMENT_ID_PREFIX):
        return None
    tail = got[len(SEGMENT_ID_PREFIX):]
    return int(tail) if tail.isdigit() else None


def scene_indices_of(entity: Dict[str, Any]) -> List[int]:
    """이 줄이 **원문 어느 씬**에 나오나. ★producer 가 적은 좌표만 본다.

    ★★★이름·부분문자열로 짐작하지 않는다 (사용자 절대 규칙). producer 가
    `occurrences[].source_span.segment_id` 로 **구조화해 적어 둔 것**만 읽는다.
    좌표가 없으면 **빈 목록**이다 — 부모가 보인다고 딸려 들어가지 않는다.

    ★두 자리에 적힌다: 걸러진 엔티티는 `grounding_provenance`, 장부 줄은
    `source_evidence`. 둘 다 본다.
    """
    out: List[int] = []
    for holder in ("grounding_provenance", "source_evidence"):
        block = (entity or {}).get(holder) or {}
        for o in (block.get("occurrences") or ()):
            seg = ((o or {}).get("source_span") or {}).get("segment_id")
            idx = scene_index_of_key(seg)
            if idx is not None and idx not in out:
                out.append(idx)
    return sorted(out)


def producer_payload(row: Dict[str, Any]) -> Dict[str, Any]:
    """reduced 행 → producer 판정 **한 덩어리**. ★중앙 helper 하나뿐이다.

    ★없는 칸은 **안 만든다.** 지어낸 값은 판정이 아니다.
    """
    import copy as _copy

    got: Dict[str, Any] = {}
    for k in PRODUCER_PAYLOAD_FIELDS:
        v = (row or {}).get(k)
        if v is None or v == "" or v == []:
            continue
        got[k] = _copy.deepcopy(v)
    return got
KNOWN_PRODUCERS: Dict[str, Tuple[str, ...]] = {
    PRODUCER_ISSUER: PRODUCER_CONTRACT_ACCEPTED,
}


#: `RelationFact.relation_type` → **허용되는 (part, whole) owner 조합**.
#:  ★관계 계약은 「전체 owner 목록」이 아니다 — 갈래 조합이다 (Codex).
#:  `visual_variant` 는 기존 계약대로 base 갈래끼리, `part_of` 는 **정확히**
#:  `location_part → location` 이다.
RELATION_OWNER_PAIRS: Dict[str, Tuple[Tuple[str, str], ...]] = {
    "part_of": (("location_part", "location"),),
    "visual_variant": tuple(
        (o, o) for o in ("character", "location", "prop")),
}


def relation_sync_owner_types(relation_type: str) -> Tuple[str, ...]:
    """그 relation 이 **닿는** 갈래들. ★목록을 손으로 다시 적지 않는다."""
    pairs = RELATION_OWNER_PAIRS.get(str(relation_type or ""), ())
    return tuple(sorted({o for pair in pairs for o in pair}))

#: owner(단수) → `short_id` 접두. ★`grounding_overlay._ENTITY_PREFIX` 와
#: 같은 규칙이되 다섯 갈래를 **여기서만** 적는다.
OWNER_PREFIX: Dict[str, str] = {
    "character": "C",
    "location": "L",
    "prop": "P",
    "location_part": "LP",
    "outlook": "O",
}


#: ★조사가 「고증이 걸렸다」고 확정했을 때 **참조를 만들 수 있는** 갈래.
#:  ★★같은 규칙이 두 곳(`episode_reference_policy_step` · `render_prompt_card`)
#:   에 `short.startswith("C") or startswith("P")` 로 적혀 있었다 — 한쪽만
#:   고쳐질 자리다. 여기 한 벌로 모은다. §2-6.5 producer 가 서면 **이 줄에**
#:   `location_part`·`outlook` 을 더한다.
#:  ★`startswith` 로 견주지 않는다 — 접두표가 prefix-free 가 아니라
#:   `LP01` 이 `"L"` 로도 시작하고, `Pfoo` 도 `"P"` 로 시작한다.
#:  ★2026-09-01 **다섯 갈래를 다 연다** — 중앙 장부가 그 다섯을 내고
#:   `grounding_activation_contract` 가 「낼 수 있는데 못 받는 갈래」가 있으면
#:   조립을 세운다. 표에서 파생한다 — 손으로 다시 적지 않는다.
REFERENCE_SUPPORTED_OWNERS: Tuple[str, ...] = MATERIALIZABLE_OWNER_TYPES

#: ★★★갈래 → **이미 있는 참조 kind**. 새 kind·새 슬롯을 **안 만든다**
#:  (사용자 확정 · Codex 2026-09-01: 「배경 자리에 같이 붙이고 새 슬롯은
#:  만들지 않는다」).
#:
#:      character       인물 참조
#:      outlook         인물의 아웃룩 참조
#:      prop            소품 참조
#:      location        **배경 자리** — 그 장소의 시대·지역 참고 사진
#:      location_part   **배경 자리** — 그 부분의 상세 참고 사진
#:
#:  ★★같은 `background` 라고 **같은 자료가 아니다.** 앞서 구운 배경 판과
#:   고증 참고 사진은 역할(`grounding_reference_bundle.ROLE_BY_PURPOSES`)·
#:   부착 값(`meta_value` 의 `grounding:` 앞머리)·계보로 갈린다. 같은 bytes
#:   일 때만 content hash 로 합치고 역할은 union 한다.
#:
#:  ★★★일곱 자리(`ctx.entities` · `detail_steps` · policy step · render card ·
#:   validator · reference service · 여기)가 **각자 목록을 늘리면** 한쪽만
#:   고쳐진다. 전부 이 표에서 파생시킨다.
REFERENCE_KIND_BY_OWNER: Dict[str, str] = {
    "character": "character",
    "outlook": "character_outlook",
    "prop": "prop",
    "location": "background",
    "location_part": "background",
}

#: 배경 자리로 가는 갈래. ★`grounding_reference_bundle` 이 다루는 것들이다.
BACKGROUND_LANE_OWNERS: Tuple[str, ...] = tuple(
    o for o in MATERIALIZABLE_OWNER_TYPES
    if REFERENCE_KIND_BY_OWNER.get(o) == "background")


#: ★★★조사가 고른 참조를 **어느 집행자가 붙이나**. 갈래마다 하나뿐이다.
#:
#:      policy                        `episode_reference_policy` 가
#:                                    `required_refs` 로 올린다. 제 신원
#:                                    참조를 받는 subject — 인물·소품.
#:      background_sidecar            `grounding_reference_bundle` 이 **배경
#:                                    자리**에 붙인다. 장소·장소부분.
#:      character_outlook_attachment  기존 **인물 아웃룩 자리**(`C##O##`)에
#:                                    additive 로 붙인다. 아웃룩.
#:
#: ★★한 갈래에 문을 둘 걸면 **두 벌이 된다** — 정책이 배경 kind 를 required 로
#:  올리고 묶음도 같은 사진을 붙이면 같은 것이 두 번 실린다 (Codex 09-01).
#: ★★★값을 「부착」으로 뭉치지 않는다 — 배경 묶음과 아웃룩 자리는 **다른
#:  기구**다. 뭉치면 한쪽만 지어 놓고 「둘 다 됐다」로 읽는다 (Codex 09-01).
#: ★`none` 을 두지 않는다 — 활성 `v2_chunk` 의 materializable 갈래는 전부
#:  집행자가 있어야 한다. 「누락을 안다」는 완료가 아니다.
ENFORCE_BY_POLICY = "policy"
ENFORCE_BY_BACKGROUND_SIDECAR = "background_sidecar"
ENFORCE_BY_OUTLOOK_ATTACHMENT = "character_outlook_attachment"
ENFORCEMENT_GATES = (ENFORCE_BY_POLICY, ENFORCE_BY_BACKGROUND_SIDECAR,
                     ENFORCE_BY_OUTLOOK_ATTACHMENT)

REFERENCE_ENFORCEMENT_BY_OWNER: Dict[str, str] = {
    "character": ENFORCE_BY_POLICY,
    "prop": ENFORCE_BY_POLICY,
    "location": ENFORCE_BY_BACKGROUND_SIDECAR,
    "location_part": ENFORCE_BY_BACKGROUND_SIDECAR,
    "outlook": ENFORCE_BY_OUTLOOK_ATTACHMENT,
}


def enforcement_gate_of(owner_type: str) -> Optional[str]:
    """이 갈래의 참조를 **누가 집행하나**. 모르는 갈래면 `None`."""
    return REFERENCE_ENFORCEMENT_BY_OWNER.get(str(owner_type or ""))


def owners_enforced_by(gate: str) -> Tuple[str, ...]:
    """그 집행자가 맡은 갈래들. ★부르는 쪽이 목록을 다시 적지 않게."""
    if gate not in ENFORCEMENT_GATES:
        raise ValueError(f"모르는 집행자 {gate!r} — {ENFORCEMENT_GATES}")
    return tuple(o for o in MATERIALIZABLE_OWNER_TYPES
                 if REFERENCE_ENFORCEMENT_BY_OWNER.get(o) == gate)


def canonical_ref_owner_types() -> Tuple[str, ...]:
    """AI canonical ref(참조 이미지)를 **만들고·요구하고·싣는** 갈래 — 정책 집행 갈래 그대로.

    ★한 이름을 네 소비자가 같이 쓴다: readiness(`asset_readiness`) · 게이트
    (`pipeline_gate.episode_reference_entities`) · 생산자(`reference_phase1_service`
    와 orchestrator 의 저빈도 스킵) · 최종 ref map(`scene_reference_service`).
    ★★실측 (Codex BLOCK 2026-09-02): 계약은 location_part → background_sidecar
    하나인데 세 소비자는 `entity_type not in ("location", "outlook")` 로 적어
    LP 가 base ref 대상에 들었다 — 사람이 확인한 실사 sidecar 와 모델이 지어낸
    LP canonical ref 가 같은 그림에 실리고, LP 5장(정상)/25장(hard)을 헛산다.
    집행자는 한 갈래에 **하나**다.
    """
    return owners_enforced_by(ENFORCE_BY_POLICY)


def sidecar_owner_types() -> Tuple[str, ...]:
    """배경 sidecar 가 집행하는 갈래 — canonical ref map 에서 **빠진다**."""
    return owners_enforced_by(ENFORCE_BY_BACKGROUND_SIDECAR)


def assert_enforcement_covers_owners() -> None:
    """★다섯 갈래가 **각각 집행자 하나**를 갖나. 늘리면 여기서 선다.

    ★★배경 자리로 가는 갈래는 반드시 `background_sidecar` 다 —
    `grounding_reference_obligations.plan` 이 그 갈래에만 `purpose` 를 주고
    묶음이 그것으로 거른다. 선언이 그 사실과 어긋나면 한쪽이 거짓말이다.
    ★★그 반대도 본다 — 배경 자리가 아닌 갈래를 `background_sidecar` 로
    선언하면 묶음은 그 줄을 `purpose` 가 없어 **조용히 건너뛴다**.
    """
    missing = [o for o in MATERIALIZABLE_OWNER_TYPES
               if REFERENCE_ENFORCEMENT_BY_OWNER.get(o)
               not in ENFORCEMENT_GATES]
    if missing:
        raise ValueError(
            f"갈래 {missing} 에 집행자가 없다 — 늘렸으면 여기도 적어라")
    got = set(owners_enforced_by(ENFORCE_BY_BACKGROUND_SIDECAR))
    want = set(BACKGROUND_LANE_OWNERS)
    if got != want:
        raise ValueError(
            f"배경 자리 선언이 어긋난다 — 선언 {sorted(got)} · "
            f"의무가 `purpose` 를 주는 갈래 {sorted(want)}")


assert_enforcement_covers_owners()


def assert_projection_covers_owners() -> None:
    """★투영이 **다섯 갈래를 다 덮나.** 갈래가 늘면 여기서 선다.

    ★★그리고 **선언과 수용을 가른다** — 투영에 있다고 참조를 만드는 것이
    아니다. 실제로 여는 것은 `REFERENCE_SUPPORTED_OWNERS` 이고, 그것을
    넓히는 것이 곧 cutover 다. 미리 넓히면 새 producer 없이 옛 유료 경로만
    켜진다(2026-08-31 에 실제로 그랬다).
    """
    missing = [o for o in MATERIALIZABLE_OWNER_TYPES
               if o not in REFERENCE_KIND_BY_OWNER]
    if missing:
        raise ValueError(
            f"갈래 {missing} 가 참조 kind 투영에 없다 — 늘렸으면 여기도 적어라")
    outside = [o for o in REFERENCE_SUPPORTED_OWNERS
               if o not in REFERENCE_KIND_BY_OWNER]
    if outside:
        raise ValueError(f"연 갈래 {outside} 가 투영 밖이다")


assert_projection_covers_owners()


def reference_kind_of_owner(owner_type: str) -> Optional[str]:
    """이 갈래가 **어느 참조 자리**로 가나. 모르는 갈래면 `None`.

    ★부르는 쪽이 `"background"` 같은 값을 손으로 적지 않게 한다.
    """
    return REFERENCE_KIND_BY_OWNER.get(str(owner_type or ""))


def reference_owner_of(short_id: str) -> Optional[str]:
    """이 신원으로 **참조를 만들 수 있나**. 못 만들면 `None`.

    Returns:
        만들 수 있으면 그 갈래(`"character"`/`"prop"`), 아니면 `None`.
        ★신원이 아닌 글자(`Pfoo`)도 `None` 이다 — 접두만 보면 지나간다.

    ★열려 있는 갈래(`REFERENCE_SUPPORTED_OWNERS`)만 답한다 — 투영에 있다는
    것과 지금 만들 수 있다는 것은 다르다.
    """
    got = owner_of_final_id(short_id)
    return got if got in REFERENCE_SUPPORTED_OWNERS else None


def owners() -> Tuple[str, ...]:
    """다섯 갈래 — ★`grounding_a0` schema **에서** 온다.

    여기 손으로 다시 적으면 A0 가 갈래를 늘려도 이쪽은 모른다.
    """
    from app.modules.pipeline import grounding_a0 as a0

    sch = a0.load_pack()["stems"][a0.SCHEMA_STEM]["content"]
    if isinstance(sch, str):
        import json

        sch = json.loads(sch)
    got = tuple((sch.get("schema", sch).get("properties", {})
                 .get("candidates", {}).get("items", {})
                 .get("properties", {}).get("owner_type", {}).get("enum")) or ())
    if not got:
        raise RuntimeError("A0 schema 에서 owner enum 을 못 읽었다 — "
                           "여기 손으로 적으면 두 벌이 된다")
    return got


def assert_prefix_parity() -> None:
    """★A0 갈래와 접두표가 **같은 집합**인가.

    안 맞으면 `assert_rows` 는 통과시키는데 최종 ID 발급이 **터진다** —
    받는 문과 내보내는 문이 갈린 것이다 (Codex).
    """
    a, b = set(owners()), set(OWNER_PREFIX)
    if a != b:
        raise AssertionError(
            f"A0 갈래와 접두표가 다르다 — A0 에만 {sorted(a - b)} · "
            f"접두에만 {sorted(b - a)}")

def split_final_id(final_id: str) -> Optional[Tuple[str, int]]:
    """`final_id` → `(owner, 번호)`. ★모르면 `None` — **짐작하지 않는다**.

    ★★★왜 이 함수가 필요한가 (2026-08-31 실측).

    `EntitySyncService` 가 번호를 이렇게 셌다 —

        prefix = short_id[0]                 # `LP01` → **"L"**
        int(short_id[1:])                    # int("P01") → ValueError
        except (ValueError, IndexError): 「형식이 이상하다」로 **건너뛴다**

    즉 `LP01` 이 **죽지 않고 조용히 빠진다.** 그러면 `L` counter 가 실제보다
    작아져 **이미 있는 `L##` 를 다시 발급**할 수 있다 — 오류도 안 난다.

    ★`owner_of_final_id` 와 **같은 규칙**(가장 긴 접두 + 뒤가 전부 숫자)을
     쓴다. 파서를 두 벌로 만들면 한쪽만 고쳐진다.
    """
    owner = owner_of_final_id(final_id)
    if owner is None:
        return None
    return owner, int(str(final_id)[len(OWNER_PREFIX[owner]):])


def owner_of_final_id(final_id: str) -> Optional[str]:
    """신원 문자열이 **어느 갈래의 것**인가. ★못 읽으면 `None`.

    ★★두 가지를 **같이** 봐야 한다 (Codex 2026-08-31).

    ①`startswith` 만으로는 안 된다 — 접두표가 **prefix-free 가 아니다**.

        location       "L"
        location_part  "LP"      ← `LP01` 이 `"L"` 로도 시작한다

    그래서 `location` 행에 `LP01` 이 와도 통과했다.

    ②접두를 떼고 남은 것이 **숫자여야** 한다. `Pfoo` 도 `"P"` 로 시작하므로
    접두만 보면 `prop` 으로 통과한다 — 신원이 아닌 것이 신원 자리에 앉는다.

    가장 긴 접두부터 보고, 남은 것이 **한 자 이상 전부 숫자**인 첫 갈래를
    돌려준다. 접두가 겹칠 때 긴 쪽이 숫자로 안 끝나면 짧은 쪽도 본다.

    ★**이 함수가 한 곳이다.** 부르는 쪽이 각자 접두를 비교하면 접두표가 바뀔
    때 한쪽만 고쳐지고, adapter 가 제 문법을 하나 더 만들면 두 벌이 된다.
    """
    fid = str(final_id or "")
    if not fid:
        return None
    for owner, pre in sorted(OWNER_PREFIX.items(),
                             key=lambda kv: -len(kv[1])):
        if not fid.startswith(pre):
            continue
        tail = fid[len(pre):]
        # ★`isdigit()` 만 보면 `P١٢`(아랍 숫자)도 지난다. 신원은
        #  `f"{prefix}{n:02d}"` 로 **ASCII** 로만 만들어진다.
        if tail and tail.isascii() and tail.isdigit():
            return owner
    return None
