"""shot_director ↔ shot_staging visible/off-camera reconciliation helpers.

2 종류의 deterministic detector:

1. **Producer-side (description-only)** — `detect_gaze_pattern_exclusions`:
   shot_director 가 shot_staging 결과를 보지 못한 채 LLM emit 한
   visible_entity_ids 와 비교하기 위한 lexicon-based diagnostic
   candidate 추출. Area #3 v1: production decision 권한 없음 (audit
   field 만 emit, `shot_director.py:182-201` mutation 폐기).

2. **Consumer-side hybrid (Path 1, structured + OFFSCREEN_RE gate)** —
   `detect_offscreen_drift_structured`: shot_staging v13 의 character_angles
   [].gaze_direction_kind=="looks_at_character" + gaze_target_id 와
   camera_direction 의 OFFSCREEN_RE coarse gate 를 조합. blocking-eligible
   (`VisibleStagingDriftError` raise).

3. **Consumer-side proximity diagnostic (Path 2, NL fallback)** —
   `detect_offscreen_drift_proximity_diagnostic`: camera_direction NL
   proximity 윈도우 안 character name → drift candidate. Diagnostic only
   (caller-side `logger.warning`). Path 1 (structured) miss 또는
   character_angles 부재 시 보조.

Area #3 v1: 이전 merged drift helper 단일 함수 폐기 + Path 1/2 분리.
Path 2 production blocking 권한 제거. mode= param 금지 (silent coupling
회피).
"""
from __future__ import annotations

import re
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Set, Tuple

from app.core.gaze_direction import (
    resolve_visible_character_target,
    validate_pairing,
)


# ---------------------------------------------------------------------------
# Lexicons
# ---------------------------------------------------------------------------

# Korean gaze verb stems — "보는 행위" 한정 (밀다/뻗다/잡다 같은 일반 동작 제외).
_KOREAN_GAZE_STEMS = (
    "올려다보", "내려다보", "응시하",
    "바라보", "쳐다보", "마주보",
    "노려보", "뒤돌아보", "돌아보",
)

# Korean gaze verb tail (동사 활용형) — 다/는/며/면서/고/다가/면.
_KOREAN_GAZE_TAILS = ("다", "는", "며", "면서", "고", "다가", "면")

# Korean framing nouns — close-up / portrait subject 인지 표지 (Y[의] X 명사).
_KOREAN_FRAMING_NOUNS = (
    "얼굴", "눈", "눈빛", "표정", "상체",
    "뒷모습", "시선", "옆얼굴", "옆모습", "정면",
)

# Body-part nouns — "X의 [body-part]" 패턴은 SUBJECT possession 묘사이지 gaze
# TARGET 이 아님. _last_object_target 가 이 형태를 만나면 skip 해 false-trigger
# 차단 (S12_Shot13: "수리영의 어깨를 ... 바라보는 혜수의 정면 구도" — "수리영의
# 어깨" 가 last [를을] 라 수리영 이 잘못 TARGET 되던 케이스).
_BODY_PART_NOUNS = (
    # 상지
    "어깨", "팔", "팔뚝", "팔꿈치", "손", "손목", "손바닥", "손등", "손가락",
    # 하지
    "다리", "허벅지", "무릎", "정강이", "발", "발목", "발바닥", "발가락",
    # 몸통
    "등", "허리", "엉덩이", "옆구리", "가슴", "배", "골반",
    # 머리/얼굴 (framing 명사와 의도적으로 일부 겹침 — possession 형태일 때만 차단)
    "머리", "머리카락", "이마", "턱", "뺨", "볼", "광대", "관자놀이",
    "목", "목덜미", "후두부", "뒤통수",
    "입", "입술", "혀", "잇몸", "이빨",
    "귀", "코", "콧등",
    "피부", "살갗",
)

# Directional phrases — gaze verb 가 명시 안 되어도 TARGET 방향성 표지.
# "혜수 쪽으로 고개를 든 수리영의 얼굴 클로즈업" 같은 케이스 cover.
_DIRECTIONAL_TAILS = ("쪽으로", "쪽을", "향해", "향한", "향하여", "쪽에", "방향으로")

# Gaze nouns — directional path 의 명사 표지 (gaze verb 없는 경우 보조).
_GAZE_NOUNS = ("고개", "시선", "눈길", "눈빛", "눈")

# Close-up / framing intent markers — directional path 의 close-up 게이트.
_CLOSE_UP_MARKERS = (
    "클로즈업", "Close-up", "close-up", "Close up", "close up", "CU",
    "정면", "구도", "익스트림 클로즈업", "ECU",
)

# Off-camera/off-screen phrases — 한/영 양쪽. 모두 lowercase 검사.
_OFFSCREEN_PHRASES = (
    r"off[\-\s]?camera",
    r"off[\-\s]?screen",
    r"offscreen",
    r"out[\-\s]?of[\-\s]?frame",
    r"out[\-\s]?of[\-\s]?shot",
    r"화면\s*밖",
    r"프레임\s*밖",
    r"카메라\s*밖",
)

# Window for proximity-based name extraction near offscreen phrases (chars).
# Directional — *not* symmetric (`pre 60 / post 30`, clause-boundary heuristic).
# 너무 넓으면 같은 문장 다른 절의 in-frame 이름까지 false-positive (S19 의
# "수리영" 가 같은 문장 앞부분에 있어 80-char symmetric 윈도우 시 오탐).
_PROXIMITY_PRE = 60
_PROXIMITY_POST = 30


# ---------------------------------------------------------------------------
# Compiled regexes
# ---------------------------------------------------------------------------

KOREAN_GAZE_VERB_RE = re.compile(
    r"(?:" + "|".join(_KOREAN_GAZE_STEMS) + r")"
    r"(?:" + "|".join(_KOREAN_GAZE_TAILS) + r")"
)

# 한글 + 라틴 문자 + 공백 으로 구성된 이름/구 — 시작은 한글/라틴 1자.
_NAME_FRAGMENT = r"[가-힣A-Za-z][가-힣A-Za-z\s]{0,20}"

KOREAN_FRAMING_RE = re.compile(
    r"(?P<who>" + _NAME_FRAGMENT + r")\s*[의가]\s*"
    r"(?:" + "|".join(_KOREAN_FRAMING_NOUNS) + r")"
)

OFFSCREEN_RE = re.compile(
    r"(?:" + "|".join(_OFFSCREEN_PHRASES) + r")",
    re.IGNORECASE,
)


# ---------------------------------------------------------------------------
# Producer-side: description gaze pattern exclusion
# ---------------------------------------------------------------------------


_BODY_PART_POSSESSION_RE = re.compile(
    r"의\s*(?:" + "|".join(_BODY_PART_NOUNS) + r")$"
)


def _last_object_target(text_before_verb: str) -> str:
    """gaze verb 직전 텍스트에서 마지막 [를을] 앞 명사구 추출.

    "굳은 표정으로 남자 직원을 " → "남자 직원" (마지막 [을] 앞).
    매칭 없으면 "".

    Body-part possession 차단: 매치된 명사구가 "X의 [BODY_PART]" 형태이면
    SUBJECT 의 부분 묘사이지 gaze TARGET 이 아니므로 skip 하고 그 이전
    매치로 fallback. 모두 body-part 면 "" 반환 (TARGET 미달).
    """
    matches = list(re.finditer(
        r"([가-힣A-Za-z][가-힣A-Za-z\s]{0,20})\s*[를을]",
        text_before_verb,
    ))
    if not matches:
        return ""
    for m in reversed(matches):
        span = m.group(1).strip()
        if _BODY_PART_POSSESSION_RE.search(span):
            continue  # X의 어깨/손/팔/... — possession, skip.
        return span
    return ""


def _first_framing_subject(text_after_verb: str) -> str:
    """gaze verb 다음 텍스트에서 첫 Y[의] FRAMING 의 Y 추출.

    "눈망울이 붉게 젖은 수리영의 얼굴 클로즈업" → "수리영".
    """
    m = KOREAN_FRAMING_RE.search(text_after_verb)
    if not m:
        return ""
    return m.group("who").strip()


def _detect_directional_pattern(
    description: str,
    name_to_id: Mapping[str, str],
) -> Tuple[Dict[str, str], set]:
    """gaze verb 가 명시 안 된 close-up 패턴 fallback.

    4-token 게이트 — 모두 매치되어야 발동:
      1. close-up marker (`클로즈업` / `정면` / `구도` / CU 등)
      2. gaze noun (`고개` / `시선` / `눈길` / `눈빛`)
      3. framing pattern (`SUBJECT의 [얼굴/눈/...]`)
      4. directional phrase (`TARGET 쪽으로/향해/...`) — TARGET 직전 30 chars
         이내 character name.

    예: "혜수 쪽으로 고개를 든 수리영의 얼굴 클로즈업"
        → close-up 마커 클로즈업, gaze noun 고개, SUBJECT 수리영, TARGET 혜수.

    Returns: (excluded_dict, subject_id_set). 게이트 미달 시 ({}, set()).
    """
    has_close_up = any(m in description for m in _CLOSE_UP_MARKERS)
    if not has_close_up:
        return {}, set()
    has_gaze_noun = any(g in description for g in _GAZE_NOUNS)
    if not has_gaze_noun:
        return {}, set()
    framing_match = KOREAN_FRAMING_RE.search(description)
    if not framing_match:
        return {}, set()
    # KOREAN_FRAMING_RE 의 _NAME_FRAGMENT 가 greedy 라 group(who) 가 framing
    # 명사 직전까지 길게 매치될 수 있음 (예: "혜수 쪽으로 고개를 든 수리영"
    # 전체). SUBJECT 는 framing 명사 직전 마지막 단어이므로 split 의 last
    # token 만 사용.
    who_full = framing_match.group("who").strip()
    subject_token = who_full.split()[-1] if who_full else ""

    subjects: set[str] = set()
    if subject_token:
        for name, sid in name_to_id.items():
            if name and len(name) >= 2 and name in subject_token:
                subjects.add(sid)

    excluded: Dict[str, str] = {}
    for d_phrase in _DIRECTIONAL_TAILS:
        # 모든 directional phrase 위치 순회 — 같은 description 안 여러 번 가능.
        start = 0
        while True:
            idx = description.find(d_phrase, start)
            if idx == -1:
                break
            # phrase 직전 30 chars 안에서 character name 매치.
            window_start = max(0, idx - 30)
            window = description[window_start:idx]
            best_idx, best_sid, best_name = -1, None, ""
            for name, sid in name_to_id.items():
                if not name or len(name) < 2 or sid in subjects:
                    continue
                pos = window.rfind(name)
                if pos > best_idx:
                    best_idx, best_sid, best_name = pos, sid, name
            if best_sid is not None and best_sid not in excluded:
                excluded[best_sid] = best_name
            start = idx + len(d_phrase)
    return excluded, subjects


def detect_gaze_pattern_exclusions(
    description: str,
    name_to_id: Mapping[str, str],
) -> Dict[str, str]:
    """description 에서 close-up gaze 패턴이 가리키는 gaze TARGET (offscreen)
    을 추출.

    Two paths (둘 다 고신뢰 게이트):

    1. **gaze verb path** — `KOREAN_GAZE_VERB_RE` 매치 + `_KOREAN_FRAMING_NOUNS`
       매치 둘 다 있어야 함. body-part possession (`X의 어깨/손/팔/...`) 은
       `_last_object_target` 단계에서 skip 되어 SUBJECT 부분 묘사 false-trigger
       차단 (S12_Shot13).
    2. **directional path** — gaze verb 가 명시 안 된 close-up 케이스. close-up
       marker + gaze noun + framing + directional phrase 4-token 모두 매치
       의무. TARGET 은 directional phrase 직전 30 chars 윈도우 안 character
       name (S12_Shot4: "혜수 쪽으로 고개를 든 수리영의 얼굴 클로즈업").

    Args:
        description: shot 묘사 한국어 텍스트.
        name_to_id: {entity_name → short_id} (scene_ve 기준).

    Returns:
        {short_id → matched_name} — visible 에서 제외할 entity. 빈 dict =
        패턴 미매치 / 안전.
    """
    if not description or not name_to_id:
        return {}

    excluded: Dict[str, str] = {}
    subjects: set[str] = set()

    # ── Path 1: gaze verb + framing ────────────────────────────────────────
    for gv_match in KOREAN_GAZE_VERB_RE.finditer(description):
        before = description[:gv_match.start()]
        after = description[gv_match.end():]

        target_span = _last_object_target(before)
        subject_span = _first_framing_subject(after)
        if not target_span or not subject_span:
            # 한쪽 누락 — 고신뢰 미달, skip.
            continue

        # subject 먼저 기록 — target candidate 가 subject 면 제외.
        for name, sid in name_to_id.items():
            if name and len(name) >= 2 and name in subject_span:
                subjects.add(sid)

        for name, sid in name_to_id.items():
            if not name or len(name) < 2:
                continue
            if name in target_span and sid not in subjects:
                excluded[sid] = name

    # ── Path 2: directional + close-up + gaze noun (gaze verb 부재) ────────
    if not excluded:
        d_excluded, d_subjects = _detect_directional_pattern(
            description, name_to_id,
        )
        # subject union — directional path 도 subject 보호 의무.
        subjects |= d_subjects
        for sid, nm in d_excluded.items():
            if sid not in subjects:
                excluded[sid] = nm

    # 마지막 방어 — subject 가 target 에도 들어있으면 제외 안 함.
    return {sid: nm for sid, nm in excluded.items() if sid not in subjects}


# ---------------------------------------------------------------------------
# Consumer-side: camera_direction NL drift detection
# ---------------------------------------------------------------------------


def detect_offscreen_drift_structured(
    visible_ids: Sequence[str],
    camera_direction: str,
    character_angles: Sequence[Dict[str, Any]],
    id_to_name: Mapping[str, str],
) -> Dict[str, str]:
    """Path 1 — hybrid gate (camera_direction OFFSCREEN_RE + structured gaze_target_id).

    Gate 1: camera_direction MUST contain explicit offscreen phrase
            (OFFSCREEN_RE match). Frame-internal mutual gaze 정상 (drift 아님).
    Gate 2: character_angles[].gaze_direction_kind == "looks_at_character"
            with valid gaze_target_id resolved against visible_set
            (Q7 dispatch helper, structural validation only — name matching X).

    In-frame guarantee: character_angles[].character == visible canonical_name 인
    sid 는 drift 후보에서 영구 제외.

    Both gates required. Returns blocking-eligible drift entities.
    """
    if not camera_direction:
        return {}
    off_matches = list(OFFSCREEN_RE.finditer(camera_direction))
    if not off_matches:
        return {}

    drift: Dict[str, str] = {}
    visible_set = set(visible_ids)

    # canonical_name → visible sid 역방향 — in-frame guarantee용.
    name_to_visible_sid: Dict[str, str] = {}
    for sid in visible_ids:
        nm = (id_to_name.get(sid) or "").strip()
        if not nm or len(nm) < 2:
            continue
        name_to_visible_sid[nm] = sid

    # In-frame guarantee
    in_frame_sids: Set[str] = set()
    if character_angles:
        for ca in character_angles:
            if not isinstance(ca, dict):
                continue
            ch = (ca.get("character") or "").strip()
            if not ch or len(ch) < 2:
                continue
            sid = name_to_visible_sid.get(ch)
            if sid:
                in_frame_sids.add(sid)

    # Path 1 structured: gaze_direction_kind + gaze_target_id resolution
    if character_angles:
        visible_character_ids = {
            _sid for _sid in visible_set
            if _sid.startswith("C") and _sid[1:].isdigit()
        }
        for ca in character_angles:
            if not isinstance(ca, dict):
                continue
            kind = ca["gaze_direction_kind"]
            if kind != "looks_at_character":
                continue
            target_id = ca.get("gaze_target_id")
            validate_pairing(kind, target_id, where="shot_visibility.path1")
            resolved = resolve_visible_character_target(
                target_id,
                visible_character_ids,
                id_to_name,
                where="shot_visibility.path1",
            )
            if resolved is None:
                continue
            sid, name = resolved
            if (
                sid in visible_set
                and sid not in drift
                and sid not in in_frame_sids
            ):
                drift[sid] = name

    return drift


def detect_offscreen_drift_proximity_diagnostic(
    visible_ids: Sequence[str],
    camera_direction: str,
    id_to_name: Mapping[str, str],
    character_angles: Optional[Sequence[Dict[str, Any]]] = None,
) -> Dict[str, str]:
    """Path 2 — camera_direction OFFSCREEN_RE + proximity NL fallback.

    Diagnostic only (caller MUST log/warning, MUST NOT raise).
    character_angles (optional) provides in_frame_guarantee against
    false-positive.
    """
    if not camera_direction:
        return {}
    off_matches = list(OFFSCREEN_RE.finditer(camera_direction))
    if not off_matches:
        return {}

    drift: Dict[str, str] = {}
    visible_set = set(visible_ids)

    # canonical_name → visible sid 역방향
    name_to_visible_sid: Dict[str, str] = {}
    for sid in visible_ids:
        nm = (id_to_name.get(sid) or "").strip()
        if not nm or len(nm) < 2:
            continue
        name_to_visible_sid[nm] = sid

    # In-frame guarantee
    in_frame_sids: Set[str] = set()
    if character_angles:
        for ca in character_angles:
            if not isinstance(ca, dict):
                continue
            ch = (ca.get("character") or "").strip()
            if not ch or len(ch) < 2:
                continue
            sid = name_to_visible_sid.get(ch)
            if sid:
                in_frame_sids.add(sid)

    # Path 2 proximity NL fallback
    for om in off_matches:
        ws = max(0, om.start() - _PROXIMITY_PRE)
        pre_text = camera_direction[ws:om.start()]
        best_idx = -1
        best_sid = None
        best_name = ""
        for sid in visible_ids:
            if sid in drift or sid in in_frame_sids:
                continue
            name = (id_to_name.get(sid) or "").strip()
            if not name or len(name) < 2:
                continue
            idx = pre_text.rfind(name)
            if idx > best_idx:
                best_idx = idx
                best_sid = sid
                best_name = name
        if best_sid is not None:
            drift[best_sid] = best_name
            continue

        we = min(len(camera_direction), om.end() + _PROXIMITY_POST)
        post_text = camera_direction[om.end():we]
        for sid in visible_ids:
            if sid in drift or sid in in_frame_sids:
                continue
            name = (id_to_name.get(sid) or "").strip()
            if not name or len(name) < 2:
                continue
            if name in post_text:
                drift[sid] = name
                break
    return drift


def detect_offscreen_referenced_subjects(
    visible_ids: Sequence[str],
    camera_direction: str,
    character_angles: Sequence[Dict[str, Any]],
    id_to_name: Mapping[str, str],
    pov_character: Optional[str],
) -> Dict[str, str]:
    """C10 Phase 1 — visible 후보 인물 중 후행 staging 이 화면 밖/부재로
    연출한 인물을 결정론적으로 식별.

    Hard gate (전부 필요):
      1. C## 가 visible character 후보.
      2. character_angles 가 non-empty (producer 가 필드를 채웠음).
      3. 그 인물의 canonical name 이 character_angles[].character 에 없음.
      4. 그 인물이 POV character 가 아님.
      5. camera_direction 에 OFFSCREEN_RE 매치 존재.

    `gaze_direction_kind == "off_screen"` 은 의도적으로 사용하지 않음 — schema
    상 시선 방향이지 인물 가시성 결정이 아니다 (in-frame 인물도 off_screen
    gaze 가능).

    Returns:
        {subject_id: reason}. reason 에 confidence 태그("named" = canonical
        name 이 camera_direction 에 등장 / "structural" = 구조 신호만) 를
        provenance 로 포함. 다운그레이드 동작에는 영향 없음.
    """
    result: Dict[str, str] = {}
    if not camera_direction or not character_angles:
        return result
    if not OFFSCREEN_RE.search(camera_direction):
        return result

    angle_names: Set[str] = set()
    for ca in character_angles:
        if isinstance(ca, dict):
            ch = (ca.get("character") or "").strip()
            if ch:
                angle_names.add(ch)

    pov = (pov_character or "").strip()

    for sid in visible_ids:
        if not (isinstance(sid, str) and sid.startswith("C")):
            continue
        name = (id_to_name.get(sid) or "").strip()
        if not name:
            # 이름이 없으면 character_angles membership 을 검증할 수 없음 → skip.
            continue
        if sid == pov or name == pov:
            continue
        if name in angle_names:
            continue
        confidence = "named" if name in camera_direction else "structural"
        result[sid] = (
            "screen_presence_reconciliation: visible candidate absent from "
            "character_angles, non-POV, camera_direction off-screen reference "
            f"(confidence={confidence})"
        )
    return result


def staged_in_frame_character_ids(
    character_angles: Sequence[Dict[str, Any]],
    id_by_name: Mapping[str, str],
    pov_character: Optional[str],
) -> List[str]:
    """촬영 계획(shot_staging)이 **화면 안에 세운** 등록 인물의 entity id —
    `detect_offscreen_referenced_subjects` 의 반대 방향.

    실측(컨트리로드 2판, 2026-09-19): shot_director VE 에는 없는데
    character_angles 에는 있는 등록 인물이 선택 샷 239개 중 35쌍이었다.
    조립 문안(카메라 지시)은 그 인물을 화면에 세우는데 참조가 안 실려,
    로봇 찰리가 **사람 뒷모습**으로 그려졌다(S60sh4·S64sh7).

    - 이름 → id 는 정확한 이름 조인이다(구조 필드 — 뜻 판단 아님). 호출자는
      같은 이름이 둘 이상인 인물을 `id_by_name` 에서 **빼고** 넘긴다.
    - POV 인물(카메라가 그 사람의 눈)은 화면 밖이라 뺀다.
    - 정본에 없는 이름(단역·군중)은 건너뛴다.

    반환 순서는 character_angles 순서, 중복 없음.
    """
    pov = (pov_character or "").strip()
    out: List[str] = []
    for ca in character_angles or []:
        if not isinstance(ca, dict):
            continue
        name = (ca.get("character") or "").strip()
        if not name or name == pov:
            continue
        eid = id_by_name.get(name)
        if eid and eid not in out:
            out.append(eid)
    return out


def unique_character_ids_by_name(
    characters: Iterable[Tuple[str, str]],
) -> Dict[str, str]:
    """(entity id, 이름) → {이름: id}. **같은 이름이 둘 이상이면 뺀다** —
    어느 쪽인지 모르는 이름을 한쪽에 붙이면 남의 참조가 실린다."""
    seen: Dict[str, Set[str]] = {}
    for eid, name in characters:
        nm = (name or "").strip()
        if eid and nm:
            seen.setdefault(nm, set()).add(eid)
    return {nm: next(iter(ids)) for nm, ids in seen.items() if len(ids) == 1}


__all__ = [
    "OFFSCREEN_RE",
    "detect_offscreen_referenced_subjects",
    "staged_in_frame_character_ids",
    "unique_character_ids_by_name",
    "KOREAN_GAZE_VERB_RE",
    "KOREAN_FRAMING_RE",
    "detect_gaze_pattern_exclusions",
    "detect_offscreen_drift_structured",
    "detect_offscreen_drift_proximity_diagnostic",
]
