"""엔티티 이름 매칭 유틸 — 괄호 접미사 드리프트 대응.

**배경**
`character_angles[].character`(LLM 출력) 등 시나리오 단위 입력과 `EntityCanon.name`
(DB 고정) 사이에 표기 드리프트가 발생함:
  - "이도령" vs "이도령(혼)" (괄호 접미사 variant)
  - "수리영 " vs "수리영" (공백)
  - "민숙" vs " 민숙 " (전후 공백)

exact-match 전제로 작성된 기존 dict lookup(`name_to_uuid.get(name)`) 패턴은
이런 드리프트에서 정상 엔티티까지 "미등록"으로 분류하는 silent bug를 만든다.

**정규화 범위**
안전(공백만):
  - 앞뒤 공백 제거
  - 중간 연속 공백 단일화

별개 단계(괄호 제거):
  - variant 엔티티("서현 (5세)")와 base("서현")가 공존하는 실측 데이터가 있어
    괄호 제거 정규화 키를 variant 엔티티에 등록하면 base와 충돌 → silent 오매칭.
  - 따라서 `build_name_index`는 괄호 있는 엔티티를 저장할 때 **bare key(괄호 제거 키)를
    등록하지 않음**. base는 원본=bare이므로 자연 등록. variant는 원본 및 공백 정리만.
  - `lookup_name`은 query 쪽을 단계별로 완화(raw → 공백정리 → bare)해 최대한 맞춤.
    이 최후 수단이 base에 맞을 수도 있지만, 원본 표기가 일치하면 variant 우선이라 안전.

의도적으로 **제외**:
  - 조사 제거("민숙이"→"민숙"): "민숙이"가 본명일 수 있음.
  - 대소문자 통일: 한글은 불필요. 영문명은 원본 표기 의도 보존.

**사용**
```python
from app.core.name_matcher import build_name_index, lookup_name

idx = build_name_index(canons, key_fn=lambda c: c.name, value_fn=lambda c: c.id)
uuid = lookup_name(idx, llm_name)  # None이면 미등록
```
"""
from __future__ import annotations

import logging
import re
from typing import Callable, Dict, Iterable, Optional, TypeVar

logger = logging.getLogger(__name__)

T = TypeVar("T")
V = TypeVar("V")

# 괄호 이하 제거: 한글·영문·전각·대괄호 모두 첫 괄호부터 문자열 끝까지.
# 유니코드 괄호 커버리지: ( [ （ ［ 【 〈 《 「 『
_BRACKET_PATTERN = re.compile(r"[\(\[（［【〈《「『].*$")
_WS_PATTERN = re.compile(r"\s+")


def normalize_name(name: str) -> str:
    """이름을 "bare key"(괄호 제거 + 공백 정리)로 정규화.

    처리:
      1. 앞뒤 공백 제거
      2. 괄호 접미사 제거 (첫 괄호부터 끝까지)
      3. 중복 공백 단일화

    빈 문자열 / None / 비문자열 입력 시 "" 반환.

    주의: 반환값은 "variant/base 구분 정보가 사라진" bare key이므로
    `build_name_index`의 variant 엔티티 등록에는 쓰이지 않는다.
    lookup_name의 최후 fallback으로만 쓰임.
    """
    if not isinstance(name, str) or not name:
        return ""
    s = name.strip()
    s = _BRACKET_PATTERN.sub("", s).strip()
    s = _WS_PATTERN.sub(" ", s)
    return s


def _collapse_whitespace(name: str) -> str:
    """공백만 정리 (괄호는 보존). variant/base 둘 다 안전하게 등록 가능한 수준."""
    if not isinstance(name, str) or not name:
        return ""
    return _WS_PATTERN.sub(" ", name).strip()


def build_name_index(
    items: Iterable[T],
    key_fn: Callable[[T], str],
    value_fn: Optional[Callable[[T], V]] = None,
) -> Dict[str, V]:
    """items의 이름을 키로 하는 lookup 인덱스 구축.

    등록 정책 (variant/base 충돌 방지):
      - raw (원본 이름) 등록
      - collapsed (공백만 정리) 등록 — raw와 다를 때만
      - **bare** (괄호 제거 정규화) — raw에 괄호가 없을 때만 (variant면 bare 등록 안 함).
        이유: "서현"(base)과 "서현 (5세)"(variant)가 공존하는 실측 데이터에서
        variant의 bare "서현"을 등록하면 base와 충돌 → silent 오매칭.
      - 충돌 시 첫 등록 우선 (`setdefault`). raw/collapsed 충돌은 warning 로그.

    Args:
        items: 엔티티 컬렉션 (예: EntityCanon 리스트, 체크포인트 dict 리스트)
        key_fn: 각 item에서 **이름 문자열**을 추출하는 함수
        value_fn: 인덱스 값 생성 함수. 생략 시 item 자체(항등) 사용.

    Returns:
        {key: value_fn(item)}. key는 raw/collapsed/bare 중 하나.

    Raises:
        없음. 빈 이름 item은 스킵.
    """
    if value_fn is None:
        value_fn = lambda x: x  # type: ignore[assignment,return-value]

    index: Dict[str, V] = {}

    def _register(key: str, val: V, raw_for_log: str) -> None:
        """key 등록. 기존 값과 다른 엔티티 val이 충돌하면 warning."""
        if not key:
            return
        if key in index:
            if index[key] is not val:
                logger.warning(
                    "name_index collision: key=%r kept existing entry, skipped new from raw=%r",
                    key, raw_for_log,
                )
            return
        index[key] = val

    for item in items:
        raw = key_fn(item) or ""
        if not raw:
            continue
        val = value_fn(item)
        _register(raw, val, raw)
        collapsed = _collapse_whitespace(raw)
        if collapsed != raw:
            _register(collapsed, val, raw)
        # bare(괄호 제거) 등록은 raw에 괄호가 없을 때만. variant는 bare를 base에 양보.
        if not _BRACKET_PATTERN.search(raw):
            bare = normalize_name(raw)
            if bare and bare != raw and bare != collapsed:
                _register(bare, val, raw)
    return index


def lookup_name(index: Dict[str, V], query: str) -> Optional[V]:
    """query로 index 조회. raw → 공백정리 → bare 단계적 완화.

    bare 단계에서 variant 엔티티가 등록 제외된 상태라 base에 맞을 수 있지만,
    index에 variant의 원본 키가 있으면 raw 단계에서 먼저 잡히므로 정상 우선순위.
    """
    if not query:
        return None
    if query in index:
        return index[query]
    collapsed = _collapse_whitespace(query)
    if collapsed and collapsed != query and collapsed in index:
        return index[collapsed]
    bare = normalize_name(query)
    if bare and bare not in (query, collapsed) and bare in index:
        return index[bare]
    return None
