"""GROUNDING-V2 — 조사 대상의 **임시 owner**(provisional research subject).

설계: docs/design/2026-08-29-grounding-v2-plan.md §6 · 계약 §7.

왜 있나 — 조사 경계(entity_merge 뒤)에는 **canon_id 가 아직 없다.**
``EntitySyncService`` 는 ``entity_t2i``(order 15.0) 체크포인트를 읽어 canon 을
만들기 때문이다. 그래서 조사 record 의 owner 를 **먼저 발급**하고, 15.0 sync 뒤
``subject_id → canon_id`` 를 **append-only 로 bind** 한다. destructive re-key 가
아니라서 임시 owner 는 남아 provenance 가 된다.

★**cache scope 를 identity 에 넣지 않는다.** ``facet``·``era``·``region``·
``target image model``·**A0 pack hash** 가 바뀌면 **같은 subject 의 새 revision**
이어야지 새 identity 가 되면 안 된다. 그것들은 provenance / cache key 쪽이다.

★**정규화 name 은 identity 가 아니다.** ``entity_canon`` 의 유일 색인은
``(project_id, short_id)`` 하나뿐이고 name 제약이 없다 (``database.py:178``).
"""
from __future__ import annotations

import hashlib
import re
import unicodedata
from typing import Any, Dict, Optional

SUBJECT_ID_CONTRACT_VERSION = 1

#: id 앞에 붙는 표식 — 로그·DB 에서 한눈에 임시 owner 임을 알아본다.
SUBJECT_ID_PREFIX = "rs"

_WS = re.compile(r"\s+")


def normalize_surface(text: str) -> str:
    """표면형 정규화 — 발급을 **resume 에 안정적**으로 만드는 유일한 전처리.

    NFKC + 소문자 + 공백 축약만 한다. ★뜻으로 묶지 않는다 — 글자로 의미를
    판단하지 않는다는 규칙과 같은 줄에 있다. 동의어 병합은 identity resolution
    (§6 ㉰②) 이 구조화 판정으로 하고, 여기서는 **같은 말이면 같은 id** 만 보장한다.
    """
    if not text:
        return ""
    return _WS.sub(" ", unicodedata.normalize("NFKC", text).strip().lower())


def mint_subject_id(
    *,
    project_id: str,
    episode_id: str,
    source_anchor: str,
    surface_form: str,
    owner_type: str,
) -> str:
    """결정적 발급 — 같은 입력이면 **A0 를 다시 돌려도 같은 id**.

    Args:
        project_id / episode_id: 범위. ★episode 범위로 발급해도 된다 —
            에피소드 사이 재사용은 **bind 뒤 canon 층에서** 일어난다.
        source_anchor: 원문 근거 위치 (씬/문단 등 안정적인 좌표).
        surface_form: **원문에 실제로 쓰인 말**. ★순번이 아니라 표면형을 쓴다 —
            순번은 A0 산출 순서가 바뀌면 흔들리는데, 표면형이면 **팩을 올려도
            같은 말은 같은 id** 다.
        owner_type: provisional owner (prop / character / location_part / outlook ...).

    ★A0 pack hash · A0 model/version 은 **여기 안 들어간다.** provenance 로 따로 남긴다.
    """
    for field, value in (
        ("project_id", project_id), ("episode_id", episode_id),
        ("source_anchor", source_anchor), ("surface_form", surface_form),
        ("owner_type", owner_type),
    ):
        if not (value or "").strip():
            raise ValueError(f"mint_subject_id: {field} 가 비었다 — 발급이 불안정해진다")

    payload = "\x1f".join((
        str(SUBJECT_ID_CONTRACT_VERSION),
        project_id.strip(),
        episode_id.strip(),
        normalize_surface(source_anchor),
        normalize_surface(surface_form),
        owner_type.strip().lower(),
    ))
    digest = hashlib.sha256(payload.encode("utf-8")).hexdigest()[:24]
    return f"{SUBJECT_ID_PREFIX}_{digest}"


def build_subject(
    *,
    project_id: str,
    episode_id: str,
    source_anchor: str,
    surface_form: str,
    owner_type: str,
    provenance: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
    """subject record — id + **provenance 를 분리**해 담는다.

    provenance 에 들어가는 것: ``a0_pack_hash`` · ``a0_model`` · ``a0_model_version``
    등. **id 계산에는 안 쓰인다** (§6 ㉮).
    """
    return {
        "contract_version": SUBJECT_ID_CONTRACT_VERSION,
        "research_subject_id": mint_subject_id(
            project_id=project_id, episode_id=episode_id,
            source_anchor=source_anchor, surface_form=surface_form,
            owner_type=owner_type,
        ),
        "project_id": project_id,
        "episode_id": episode_id,
        "source_anchor": source_anchor,
        "surface_form": surface_form,
        "owner_type": owner_type,
        "provenance": dict(provenance or {}),
        # bind 는 15.0 sync 뒤에 append-only 로 붙는다. 여기서는 항상 미결.
        "canon_id": None,
        "bind_state": "unbound",
    }
