"""실내 일반 샷 shared-model pose 가이드 — 결정론 plan (Wave5).

candidate_groups / complexity_signals / build_pose_brief. 의미판정·글자패턴 0:
그룹핑=구조키(scene_index/bg_id), 신호=enum/카운트, brief=구조화 FSC/staging 필드.

★시나리오 의존성 절대 0: 작품 고유명사·방/소품/캐릭터명·문구·예시 토큰 0.
brief 는 spatial descriptor(foreground/background figure)·generic gesture 만 emit —
FSC label/gesture_target_label/엔티티 ID 를 절대 그대로 주입하지 않는다.
"""
from typing import Any, Dict, List, Optional, Set, Tuple

ShotKey = Tuple[int, int]
_FRAMING_ENUM = ("wide", "medium", "close", "insert")


def _normalize_framing(shot: Dict[str, Any]) -> str:
    """framing_scale(shot_staging SOT) → shot_type → camera_direction 순으로 framing
    enum 정규화 (정확 enum 비교, substring X)."""
    for field in ("framing_scale", "shot_type"):
        v = str(shot.get(field) or "").strip().lower()
        if v in _FRAMING_ENUM:
            return v
    cd = str(shot.get("camera_direction") or "").strip().lower()
    return cd if cd in _FRAMING_ENUM else "unknown"


def complexity_signals(shot: Dict[str, Any]) -> Dict[str, Any]:
    """per-shot 구조 신호 (순수 enum/카운트, 이름/토큰 누출 0).

    fsc_constraint_count / depth_plane_bucket(고유 depth_plane 수) /
    entity_count(character target 고유 수) + bucket(none/single/multiple) /
    gesture_present(gesture_action != none 존재) / framing.
    """
    fsc = shot.get("frame_spatial_contract")
    constraints = (fsc or {}).get("constraints") or [] if isinstance(fsc, dict) else []
    char_ids: Set[str] = set()
    depth_vals: Set[str] = set()
    gesture_present = False
    for c in constraints:
        if not isinstance(c, dict):
            continue
        if c.get("target_kind") == "character" and str(c.get("target_id") or "").strip():
            char_ids.add(str(c["target_id"]))
        dp = str(c.get("depth_plane") or "").strip()
        if dp:
            depth_vals.add(dp)
        if str(c.get("gesture_action") or "none") != "none":
            gesture_present = True
    n = len(char_ids)
    bucket = "none" if n == 0 else ("single" if n == 1 else "multiple")
    return {
        "fsc_constraint_count": len(constraints),
        "depth_plane_bucket": len(depth_vals),
        "entity_count": n,
        "entity_count_bucket": bucket,
        "gesture_present": gesture_present,
        "framing": _normalize_framing(shot),
    }


# ── candidate_groups (그룹핑 + group signals) ───────────────────────────

_FRAMING_COARSE = {"wide", "medium"}
_FRAMING_FINE = {"close", "insert"}


def _group_signals(
    member_keys: List[ShotKey], signals_by_shot: Dict[str, Dict[str, Any]],
    shot_by_key: Dict[ShotKey, Dict[str, Any]],
) -> Dict[str, bool]:
    """그룹 단위 신호 (순수). camera_framing_variation / repeated_targets / multi_depth."""
    framings = {signals_by_shot[f"{si}_{shi}"]["framing"] for si, shi in member_keys}
    framing_var = bool(
        (len(framings) >= 2 and (framings & _FRAMING_COARSE) and (framings & _FRAMING_FINE))
        or len(framings - {"unknown"}) >= 2
    )
    # repeated_targets: 같은 character/prop target_id 가 둘 이상 멤버에서
    # 다른 (screen_zone, depth_plane) 으로 등장하면 True.
    # multi_depth: 그룹 멤버 통틀어 고유 depth_plane 이 2종 이상(cross-shot depth 다양성)
    #              또는 단일 샷이 자체로 2 depth band 점유.
    seen: Dict[str, Set[Tuple[str, str]]] = {}
    group_depths: Set[str] = set()
    for si, shi in member_keys:
        fsc = (shot_by_key.get((si, shi)) or {}).get("frame_spatial_contract") or {}
        for c in (fsc.get("constraints") or []):
            if not isinstance(c, dict):
                continue
            dp = str(c.get("depth_plane") or "").strip()
            if dp:
                group_depths.add(dp)
            if c.get("target_kind") in ("character", "prop"):
                tid = str(c.get("target_id") or "").strip()
                if tid:
                    seen.setdefault(tid, set()).add((str(c.get("screen_zone") or ""), dp))
    multi_depth = (len(group_depths) >= 2
                   or any(signals_by_shot[f"{si}_{shi}"]["depth_plane_bucket"] >= 2
                          for si, shi in member_keys))
    repeated = any(len(v) >= 2 for v in seen.values())
    return {"camera_framing_variation": framing_var,
            "repeated_targets": bool(repeated), "multi_depth": bool(multi_depth)}


def candidate_groups(
    *,
    selected_keys: List[ShotKey],
    bg_id_by_shot: Dict[ShotKey, str],
    shot_by_key: Dict[ShotKey, Dict[str, Any]],
    zoom_member_keys: Set[ShotKey],
    single_shot_lane_enabled: bool = False,
) -> List[Dict[str, Any]]:
    """same scene_index + same actual bg_id + selected 2+ 멀티샷 연속성 그룹 (결정론).

    그룹 키 = (scene_index, bg_id). bg_id 는 실제 consumer 부착 background id
    (label/primary_location 금지 — false group 방지). bg_id 빈 문자열은 제외.
    zoom_member_keys 는 진단 필드(zoom_members)로만 표시 — 후보에서 제외하지 않는다.

    **single_shot_lane_enabled=True (4b fix 2026-07-01, Codex 합의)**: cross-shot 2+
    그룹에 커버되지 않은 selected indoor 샷 중 **figure_count>=1** 인 단일샷도 1-멤버
    후보로 올린다(사용자 "한샷이라도 구도/복잡이면 실내외 무조건 마네킹"). group_id 는
    context 에서 lane 별로 분리(cross=indoor-*, single=isp-single-*). 판정은
    downstream shared-model judge/QC 가 fail-closed 로 결정 — 코드는 figure>=1 구조
    prefilter 만. figure 0(establishing/insert)은 후보 제외(마네킹 대상 아님).
    default(False) = 기존 2+ 그룹만(byte-identical).
    """
    by_group: Dict[Tuple[int, str], List[ShotKey]] = {}
    for si, shi in selected_keys:
        bg_id = str(bg_id_by_shot.get((si, shi)) or "").strip()
        if not bg_id:
            continue
        by_group.setdefault((si, bg_id), []).append((si, shi))
    out: List[Dict[str, Any]] = []
    covered: Set[ShotKey] = set()   # cross-shot 2+ 그룹이 이미 커버한 샷
    for (si, bg_id), members in sorted(by_group.items(), key=lambda kv: (kv[0][0], kv[0][1])):
        members = sorted(members, key=lambda k: k[1])
        if len(members) < 2:
            continue
        signals_by_shot = {
            f"{m_si}_{m_shi}": complexity_signals(shot_by_key[(m_si, m_shi)])
            for m_si, m_shi in members
        }
        out.append({
            "scene_index": si, "bg_id": bg_id, "lane": "cross_shot_continuity",
            "member_keys": members, "anchor_key": members[0],
            "signals_by_shot": signals_by_shot,
            "group_signals": _group_signals(members, signals_by_shot, shot_by_key),
            "zoom_members": [k for k in members if k in zoom_member_keys],
        })
        covered.update(members)
    if single_shot_lane_enabled:
        # broad single-shot lane — cross-shot 미커버 + bg_id 有 + figure>=1 단일샷.
        # cross-shot 이 같은 샷을 이미 커버하면 제외(Codex: single 후보는 exclude).
        for si, shi in sorted(selected_keys):
            key = (si, shi)
            if key in covered:
                continue
            bg_id = str(bg_id_by_shot.get(key) or "").strip()
            if not bg_id:
                continue
            sig = complexity_signals(shot_by_key[key])
            if int(sig.get("entity_count") or 0) < 1:
                continue   # figure 0 = 마네킹 대상 아님(establishing/insert)
            signals_by_shot = {f"{si}_{shi}": sig}
            out.append({
                "scene_index": si, "bg_id": bg_id, "lane": "single_shot_complexity",
                "member_keys": [key], "anchor_key": key,
                "signals_by_shot": signals_by_shot,
                "group_signals": _group_signals([key], signals_by_shot, shot_by_key),
                "zoom_members": [key] if key in zoom_member_keys else [],
            })
    return out


# ── indoor/outdoor 게이트 (구조키 우선, 시나리오 토큰 0) ─────────────────


def classify_loc_sets(
    building_groups: List[Dict[str, Any]],
) -> Tuple[Set[str], Set[str]]:
    """background_classify building_groups → (indoor_locs, outdoor_locs).

    structured field 조인만 — label/summary 텍스트는 보지 않는다(outdoor_loc_ids
    대칭). is_indoor==True → indoor, ==False → outdoor, None/누락 → 어느 집합도
    아님(default-deny 대상). 두 집합은 disjoint(loc 단위 is_indoor 가 bool 가정).
    """
    indoor: Set[str] = set()
    outdoor: Set[str] = set()
    for g in building_groups or []:
        for m in (g.get("members") or []):
            loc_id = m.get("loc_id")
            if not (isinstance(loc_id, str) and loc_id):
                continue
            if m.get("is_indoor") is True:
                indoor.add(loc_id)
            elif m.get("is_indoor") is False:
                outdoor.add(loc_id)
    return indoor, outdoor


def filter_indoor_groups(
    groups: List[Dict[str, Any]],
    *,
    bg_loc_by_id: Dict[str, str],
    indoor_locs: Set[str],
    outdoor_locs: Set[str],
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
    """candidate group 을 순수 indoor loc 만 남기고 필터 (구조키 우선, Codex 정렬).

    loc 해석 = `bg_loc_by_id[group.bg_id]`(background_render groups[bg_id].location_id
    구조 SOT). 시나리오 토큰/문자열 파싱 0. 판정:
      - loc ∈ indoor_locs (and ∉ outdoor_locs) → keep + group["location_id"] annotate
      - loc ∈ outdoor_locs → exclude reason=outdoor_owned (outdoor_site_layout 소유)
      - loc 미해석(bg_id 미존재) → exclude reason=loc_unresolved (default-deny)
      - loc 해석되나 indoor/outdoor 어느쪽도 아님 → exclude reason=loc_not_indoor
    반환: (kept_groups, excluded_diagnostics). 모든 제외는 진단 기록(silent drop 0).
    """
    kept: List[Dict[str, Any]] = []
    excluded: List[Dict[str, Any]] = []
    for g in groups:
        bg_id = str(g.get("bg_id") or "").strip()
        loc = bg_loc_by_id.get(bg_id)
        diag = {"scene_index": g.get("scene_index"), "bg_id": bg_id, "location_id": loc}
        if not loc:
            excluded.append({**diag, "reason": "loc_unresolved"})
            continue
        if loc in outdoor_locs:
            excluded.append({**diag, "reason": "outdoor_owned"})
            continue
        if loc not in indoor_locs:
            excluded.append({**diag, "reason": "loc_not_indoor"})
            continue
        kept.append({**g, "location_id": loc})
    return kept, excluded


# ── build_pose_brief (FSC → staging 계층, 이름 누출 0) ───────────────────

_SUPPORT_CLAUSE = ("each figure must be supported by a visible surface beneath it; "
                   "nothing floats or hovers unsupported in mid-air")


def _slot_descriptor(zone: Any, depth: Any) -> str:
    """spatial descriptor (이름/ID 0) — depth 우선, 없으면 screen zone."""
    d = str(depth or "").strip().lower()
    if d in ("foreground", "background"):
        return f"{d} figure"
    z = str(zone or "").strip().lower()
    return f"{z} figure" if z in ("left", "right", "center") else "figure"


def _target_descriptor(zone: Any, depth: Any) -> Optional[str]:
    """gesture 대상의 공간구 (라벨/이름 0) — FSC screen_zone/depth_plane enum 만.

    W-C(2026-07-03): gesture_target_label 을 받고도 'a nearby object' 로 뭉개
    대상의 공간 위치가 소실되던 결함 복원. ZONE_PHRASES/DEPTH_PHRASES(frame_spatial_
    contract enum SOT) 재사용 — 정확 enum 키 비교, 글자패턴/라벨 원문 주입 0.
    둘 다 미해석이면 None(호출부가 기존 generic 문구 유지)."""
    from app.core.frame_spatial_contract import DEPTH_PHRASES, ZONE_PHRASES
    z = str(zone or "").strip().lower()
    d = str(depth or "").strip().lower()
    z_phrase = ZONE_PHRASES[z][0] if z in ZONE_PHRASES else None
    d_phrase = DEPTH_PHRASES[d][0] if d in DEPTH_PHRASES else None
    if z_phrase and d_phrase:
        return f"the element at the {z_phrase} of frame, in the {d_phrase}"
    if z_phrase:
        return f"the element at the {z_phrase} of frame"
    if d_phrase:
        return f"the element in the {d_phrase}"
    return None


# W-C(2026-07-03) facing 실방향 문구 — gaze_direction_kind closed-world enum
# (app.core.gaze_direction.GAZE_DIRECTION_KINDS) → 마네킹이 그릴 수 있는 방향 서술.
# 기존 "oriented per gaze direction"(방향 정보 0 무의미 문구) 대체. gaze_target_id
# 는 계속 미주입(이름/ID 0). 미등재 kind → None(문구 생략).
_FACING_BY_GAZE_KIND: Dict[str, str] = {
    "camera": "facing the camera",
    "down": "head angled downward",
    "up": "head angled upward",
    "distant": "gazing into the distance",
    "closed_eyes": "eyes closed",
    "off_screen": "facing off-frame",
    "looks_at_character": "facing the other figure",
    "looks_at_object": "turned toward the object they engage with",
}


def _angle_index(shot: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
    """character_angles 를 character 키로 인덱싱 (데이터 키 매칭, 의미판정 X)."""
    out: Dict[str, Dict[str, Any]] = {}
    for a in shot.get("character_angles") or []:
        if isinstance(a, dict):
            key = str(a.get("character") or "").strip()
            if key:
                out[key] = a
    return out


def build_pose_brief(
    shot: Dict[str, Any], *, max_figures: int = 2,
    name_by_id: Optional[Dict[str, str]] = None,
) -> Dict[str, Any]:
    """FSC(layout/relationship) → character_angles(if available) → framing 계층 brief.

    ★support/contact 는 끝까지 generic clause 로만 — FSC 만으로 물리 접촉 확정 금지.
    ★figure slot=spatial descriptor, gesture=generic phrase — FSC label/이름/ID 미주입.
    character target > max_figures 면 상위 max_figures 만 + contact_locked=False.

    ★name_by_id (target_id→entity name) 주어지면 character_angles 를 **target_id→name
    매칭**으로 연결(FSC label 영문 ↔ angle character 한글 불일치 회피). body_pose/gaze
    가 이 경로로 robust 하게 흐른다. 미제공/미매칭이면 기존 label 매칭으로 fallback.
    ★주의: 매칭에만 name 사용 — body_pose 같은 **자세 묘사**만 figure 에 싣고 이름/ID 는
    절대 미주입(slot/gesture/pose/facing 전부 spatial·동작 descriptor).
    """
    fsc = shot.get("frame_spatial_contract")
    constraints = (fsc or {}).get("constraints") or [] if isinstance(fsc, dict) else []
    char_cons = [c for c in constraints
                 if isinstance(c, dict) and c.get("target_kind") == "character"
                 and str(c.get("target_id") or "").strip()]
    contact_locked = True
    if len(char_cons) > max_figures:
        contact_locked = False
        char_cons = char_cons[:max_figures]

    angles = _angle_index(shot)
    field_diag: Dict[str, int] = {}
    figures: List[Dict[str, Any]] = []
    for c in char_cons:
        ga = str(c.get("gesture_action") or "none")
        gtl = str(c.get("gesture_target_label") or "").strip()
        gesture = None
        if ga != "none":
            # 다인물 상호접촉(target 이 character)면 contact 미확정.
            tgt_is_char = any(
                isinstance(o, dict) and o.get("target_kind") == "character"
                and str(o.get("label") or "").strip() == gtl
                for o in constraints)
            if tgt_is_char:
                contact_locked = False
            # W-C(2026-07-03): 대상의 공간 위치 복원 — label==gtl 인 non-character
            # constraint(FSC 자체 필드 간 exact join, 글자패턴 아님)의 screen_zone/
            # depth_plane 을 공간구로. 라벨 원문/이름/ID 는 계속 미주입. 미발견
            # (char 대상 포함)이면 기존 generic 문구 유지.
            target_desc = None
            if gtl and not tgt_is_char:
                for o in constraints:
                    if (isinstance(o, dict)
                            and o.get("target_kind") != "character"
                            and str(o.get("label") or "").strip() == gtl):
                        target_desc = _target_descriptor(
                            o.get("screen_zone"), o.get("depth_plane"))
                        break
            gesture = (f"{ga} toward {target_desc}" if target_desc
                       else f"{ga} toward a nearby object")   # generic, label/ID 미주입

        # character_angles robust 보강 (if available — 필드 부재는 diagnostic, deny 아님).
        # ★target_id→name 매칭 우선(name_by_id), 미매칭이면 label 매칭 fallback.
        pose = None
        facing = None
        tid = str(c.get("target_id") or "").strip()
        resolved_name = (name_by_id or {}).get(tid, "")
        ang = (angles.get(resolved_name) if resolved_name else None) \
            or angles.get(str(c.get("label") or "").strip())
        if ang is None:
            field_diag["character_angle_missing"] = field_diag.get("character_angle_missing", 0) + 1
        else:
            bp = str(ang.get("body_pose") or "").strip()
            if bp:
                pose = bp
            else:
                field_diag["body_pose_missing"] = field_diag.get("body_pose_missing", 0) + 1
            gdk = str(ang.get("gaze_direction_kind") or "").strip()
            # W-C(2026-07-03): enum→실방향 문구 (기존 "oriented per gaze direction"
            # 은 방향 정보 0 무의미 문구 — S14 응시 방향 소실의 근본원인).
            # gaze_target_id(C##)는 계속 미주입 — generic facing 만.
            facing = _FACING_BY_GAZE_KIND.get(gdk) if gdk else None

        figures.append({
            "slot": _slot_descriptor(c.get("screen_zone"), c.get("depth_plane")),
            "screen_zone": str(c.get("screen_zone") or ""),
            "depth_plane": str(c.get("depth_plane") or ""),
            "gesture": gesture,
            "pose": pose,
            "facing": facing,
        })

    return {
        "figures": figures,
        "support_clause": _SUPPORT_CLAUSE,
        "framing": _normalize_framing(shot),
        "contact_locked": contact_locked,
        "skipped_reason": None if figures else "no_character_target",
        "field_diagnostics": field_diag,
    }
