"""실내 일반 샷 shared-model pose plan 결정론 테스트 (Wave5).

complexity_signals / candidate_groups / build_pose_brief — 의미판정 0,
이름/엔티티 ID 누출 0. LLM/VLM/T2I 무관(순수 함수).
"""
from app.modules.pipeline.indoor_shared_pose_plan import (
    build_pose_brief,
    candidate_groups,
    classify_loc_sets,
    complexity_signals,
    filter_indoor_groups,
)


def _shot(scene, shot, constraints, framing="medium", camera_direction=""):
    return {"scene_index": scene, "shot_index": shot,
            "shot_type": framing, "camera_direction": camera_direction,
            "frame_spatial_contract": {"reason": "blocking", "constraints": constraints}}


# ── Task 1: complexity_signals ──────────────────────────────────────────

def test_complexity_signals_multi_character_multi_depth_gesture():
    s = _shot(1, 1, [
        {"target_kind": "character", "target_id": "C01", "label": "woman", "screen_zone": "left",
         "depth_plane": "foreground", "gesture_action": "reach", "gesture_target_label": "cup"},
        {"target_kind": "character", "target_id": "C02", "label": "man", "screen_zone": "right",
         "depth_plane": "background", "gesture_action": "none", "gesture_target_label": ""},
        {"target_kind": "prop", "target_id": "P01", "label": "cup", "screen_zone": "center",
         "depth_plane": "foreground", "gesture_action": "none", "gesture_target_label": ""},
    ], framing="medium")
    sig = complexity_signals(s)
    assert sig["fsc_constraint_count"] == 3
    assert sig["entity_count"] == 2          # C01, C02
    assert sig["entity_count_bucket"] == "multiple"
    assert sig["depth_plane_bucket"] == 2    # foreground, background
    assert sig["gesture_present"] is True
    assert sig["framing"] == "medium"


def test_complexity_signals_null_fsc_is_empty():
    s = {"scene_index": 1, "shot_index": 2, "shot_type": "insert", "camera_direction": "",
         "frame_spatial_contract": None}
    sig = complexity_signals(s)
    assert sig["fsc_constraint_count"] == 0
    assert sig["entity_count_bucket"] == "none"
    assert sig["depth_plane_bucket"] == 0
    assert sig["gesture_present"] is False
    assert sig["framing"] == "insert"


# ── Task 2: candidate_groups ────────────────────────────────────────────

def test_candidate_groups_same_scene_same_bg_two_shots():
    keys = [(1, 1), (1, 2), (1, 3)]
    bg = {(1, 1): "bgA", (1, 2): "bgA", (1, 3): "bgB"}   # sh3 다른 plate
    shots = {
        (1, 1): _shot(1, 1, [{"target_kind": "character", "target_id": "C01", "label": "w",
                              "screen_zone": "left", "depth_plane": "foreground",
                              "gesture_action": "none", "gesture_target_label": ""}], framing="wide"),
        (1, 2): _shot(1, 2, [{"target_kind": "character", "target_id": "C01", "label": "w",
                              "screen_zone": "center", "depth_plane": "background",
                              "gesture_action": "none", "gesture_target_label": ""}], framing="close"),
        (1, 3): _shot(1, 3, [], framing="insert"),
    }
    groups = candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots,
                              zoom_member_keys=set())
    assert len(groups) == 1                       # (1,bgA) 2샷만, bgB 1샷 제외
    g = groups[0]
    assert g["scene_index"] == 1 and g["bg_id"] == "bgA"
    assert g["member_keys"] == [(1, 1), (1, 2)]
    assert g["anchor_key"] == (1, 1)
    assert g["group_signals"]["camera_framing_variation"] is True   # wide vs close
    assert g["group_signals"]["repeated_targets"] is True           # C01 다른 zone/depth
    assert g["group_signals"]["multi_depth"] is True                # foreground+background


def test_candidate_groups_cross_scene_not_merged():
    keys = [(1, 1), (2, 1)]
    bg = {(1, 1): "bgA", (2, 1): "bgA"}                 # 같은 plate라도 다른 scene
    shots = {(1, 1): _shot(1, 1, []), (2, 1): _shot(2, 1, [])}
    groups = candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots,
                              zoom_member_keys=set())
    assert groups == []                            # scene 경계 안 넘김 → 각 1샷


def test_candidate_groups_empty_bg_id_excluded():
    keys = [(1, 1), (1, 2)]
    bg = {(1, 1): "", (1, 2): ""}
    shots = {(1, 1): _shot(1, 1, []), (1, 2): _shot(1, 2, [])}
    assert candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots,
                            zoom_member_keys=set()) == []


def test_candidate_groups_zoom_member_flagged_not_excluded():
    keys = [(1, 1), (1, 2)]
    bg = {(1, 1): "bgA", (1, 2): "bgA"}
    shots = {(1, 1): _shot(1, 1, []), (1, 2): _shot(1, 2, [])}
    groups = candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots,
                              zoom_member_keys={(1, 2)})
    assert len(groups) == 1
    assert groups[0]["zoom_members"] == [(1, 2)]    # 표시만, 제외 안 함


# ── Task 4b: single-shot broad lane (2026-07-01, Codex 합의) ──────────────

def _fig_shot(scene, shot, framing="medium"):
    """figure 1인 있는 단일 복잡샷(FSC character target 1)."""
    return _shot(scene, shot, [
        {"target_kind": "character", "target_id": "C01", "label": "w",
         "screen_zone": "center", "depth_plane": "foreground",
         "gesture_action": "reach", "gesture_target_label": "cup"}], framing=framing)


def test_single_shot_lane_off_no_single_member_candidate():
    """flag OFF(default) → 단일 복잡샷은 후보 아님(byte-identical)."""
    keys = [(1, 1)]
    bg = {(1, 1): "bgA"}
    shots = {(1, 1): _fig_shot(1, 1)}
    assert candidate_groups(selected_keys=keys, bg_id_by_shot=bg,
                            shot_by_key=shots, zoom_member_keys=set()) == []


def test_single_shot_lane_on_emits_single_member_candidate():
    """flag ON + selected + bg plate id + figure>=1 → 1-멤버 후보(lane 표기)."""
    keys = [(1, 1)]
    bg = {(1, 1): "bgA"}
    shots = {(1, 1): _fig_shot(1, 1)}
    groups = candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots,
                              zoom_member_keys=set(), single_shot_lane_enabled=True)
    assert len(groups) == 1
    g = groups[0]
    assert g["lane"] == "single_shot_complexity"
    assert g["member_keys"] == [(1, 1)] and g["anchor_key"] == (1, 1)
    assert g["bg_id"] == "bgA"
    assert g["signals_by_shot"]["1_1"]["entity_count"] == 1


def test_single_shot_lane_on_excludes_zero_figure():
    """flag ON 이라도 figure 0(establishing/insert) 은 후보 제외."""
    keys = [(1, 1)]
    bg = {(1, 1): "bgA"}
    shots = {(1, 1): _shot(1, 1, [])}   # FSC constraint 0 → entity_count 0
    assert candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots,
                            zoom_member_keys=set(), single_shot_lane_enabled=True) == []


def test_single_shot_lane_on_excludes_empty_bg():
    """flag ON 이라도 actual bg plate id 없으면 후보 제외."""
    keys = [(1, 1)]
    bg = {(1, 1): ""}
    shots = {(1, 1): _fig_shot(1, 1)}
    assert candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots,
                            zoom_member_keys=set(), single_shot_lane_enabled=True) == []


def test_single_shot_lane_on_excludes_cross_shot_covered():
    """flag ON + 2+ 그룹이 이미 커버한 샷은 single 후보로 중복 생성 안 함."""
    keys = [(1, 1), (1, 2)]
    bg = {(1, 1): "bgA", (1, 2): "bgA"}       # 같은 bg → cross-shot 그룹
    shots = {(1, 1): _fig_shot(1, 1, "wide"), (1, 2): _fig_shot(1, 2, "close")}
    groups = candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots,
                              zoom_member_keys=set(), single_shot_lane_enabled=True)
    assert len(groups) == 1                    # cross-shot 1개만, single 중복 없음
    assert groups[0]["lane"] == "cross_shot_continuity"
    assert groups[0]["member_keys"] == [(1, 1), (1, 2)]


def test_single_shot_lane_on_mixed_cross_and_single():
    """같은 씬: bgA 2샷=cross-shot, bgB 1샷 figure=single lane 둘 다 후보."""
    keys = [(1, 1), (1, 2), (1, 3)]
    bg = {(1, 1): "bgA", (1, 2): "bgA", (1, 3): "bgB"}
    shots = {(1, 1): _fig_shot(1, 1, "wide"), (1, 2): _fig_shot(1, 2, "close"),
             (1, 3): _fig_shot(1, 3, "medium")}
    groups = candidate_groups(selected_keys=keys, bg_id_by_shot=bg, shot_by_key=shots,
                              zoom_member_keys=set(), single_shot_lane_enabled=True)
    lanes = sorted(g["lane"] for g in groups)
    assert lanes == ["cross_shot_continuity", "single_shot_complexity"]
    single = next(g for g in groups if g["lane"] == "single_shot_complexity")
    assert single["member_keys"] == [(1, 3)] and single["bg_id"] == "bgB"


# ── Task 3: build_pose_brief ────────────────────────────────────────────

def test_build_pose_brief_no_name_leak_and_generic_clause():
    s = _shot(1, 1, [
        {"target_kind": "character", "target_id": "C01", "label": "민숙", "screen_zone": "left",
         "depth_plane": "foreground", "gesture_action": "reach", "gesture_target_label": "cup"},
        {"target_kind": "prop", "target_id": "P01", "label": "커피잔", "screen_zone": "center",
         "depth_plane": "foreground", "gesture_action": "none", "gesture_target_label": ""},
    ])
    b = build_pose_brief(s)
    flat = str(b)
    assert "C01" not in flat and "민숙" not in flat and "P01" not in flat and "커피잔" not in flat
    assert "supported by a visible surface" in b["support_clause"]
    assert b["figures"][0]["gesture"] is not None      # reach + prop target → 약하게
    assert b["contact_locked"] is True


def test_build_pose_brief_no_gesture_invention():
    s = _shot(1, 1, [{"target_kind": "character", "target_id": "C01", "label": "w",
                      "screen_zone": "center", "depth_plane": "midground",
                      "gesture_action": "none", "gesture_target_label": ""}])
    b = build_pose_brief(s)
    assert b["figures"][0]["gesture"] is None          # gesture none → 발명 안 함


def test_build_pose_brief_caps_two_figures_contact_unlocked():
    cons = [{"target_kind": "character", "target_id": f"C0{i}", "label": f"p{i}",
             "screen_zone": "center", "depth_plane": "midground",
             "gesture_action": "none", "gesture_target_label": ""} for i in range(1, 4)]
    b = build_pose_brief(_shot(1, 1, cons), max_figures=2)
    assert len(b["figures"]) == 2
    assert b["contact_locked"] is False                # 3+ character → contact not locked


def test_build_pose_brief_character_angles_if_available():
    # body_pose 있으면 pose 보강, gaze 있으면 facing 보강 (이름/ID 미주입).
    # W-C(2026-07-03): gaze_direction_kind 는 closed-world enum(gaze_direction.py
    # SOT) — "camera" 가 실값("looks_at_camera" 는 존재하지 않는 값이었음).
    s = _shot(1, 1, [{"target_kind": "character", "target_id": "C01", "label": "w",
                      "screen_zone": "left", "depth_plane": "foreground",
                      "gesture_action": "none", "gesture_target_label": ""}])
    s["character_angles"] = [{"character": "w", "body_pose": "seated leaning forward",
                              "gaze_direction_kind": "camera", "gaze_target_id": None}]
    b = build_pose_brief(s)
    assert b["figures"][0]["pose"] == "seated leaning forward"
    assert b["figures"][0]["facing"] == "facing the camera"
    assert "field_diagnostics" in b


def test_build_pose_brief_missing_body_pose_is_diagnostic_not_deny():
    # character_angles 에 body_pose 부재 → pose None + diagnostic count, deny 아님.
    s = _shot(1, 1, [{"target_kind": "character", "target_id": "C01", "label": "w",
                      "screen_zone": "left", "depth_plane": "foreground",
                      "gesture_action": "none", "gesture_target_label": ""}])
    s["character_angles"] = [{"character": "w", "gaze_direction_kind": "off_screen",
                              "gaze_target_id": None}]
    b = build_pose_brief(s)
    assert b["figures"][0]["pose"] is None
    assert b["field_diagnostics"].get("body_pose_missing", 0) >= 1
    assert b["skipped_reason"] is None                 # figure 존재 → skip 아님


def test_build_pose_brief_target_id_name_matching():
    # FSC label(영문) ↔ character_angle.character(한글) 불일치라도 name_by_id
    # (target_id→name) 로 body_pose 가 흐른다. 이름 자체는 figure 에 미주입.
    s = _shot(1, 1, [{"target_kind": "character", "target_id": "C10", "label": "Suriyeong face",
                      "screen_zone": "middle_right", "depth_plane": "foreground",
                      "gesture_action": "none", "gesture_target_label": ""}])
    s["character_angles"] = [{"character": "수리영", "body_pose": "hand clamped mouth",
                              "gaze_direction_kind": "down", "gaze_target_id": None}]
    # name_by_id 없으면 label 매칭 실패 → pose None.
    assert build_pose_brief(s)["figures"][0]["pose"] is None
    # name_by_id 주면 C10→수리영 매칭 → body_pose 전달, 이름 누출 0.
    b = build_pose_brief(s, name_by_id={"C10": "수리영"})
    assert b["figures"][0]["pose"] == "hand clamped mouth"
    assert "수리영" not in str(b) and "C10" not in str(b)


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

_BGC = [
    {"group_id": "g1", "members": [
        {"loc_id": "L01", "is_indoor": False},   # 야외
        {"loc_id": "L03", "is_indoor": True},    # 실내
    ]},
    {"group_id": "g2", "members": [
        {"loc_id": "L07", "is_indoor": True},    # 실내
        {"loc_id": "L09", "is_indoor": None},    # 미분류 → 어느 집합도 아님
    ]},
]


def test_classify_loc_sets_indoor_outdoor_disjoint():
    indoor, outdoor = classify_loc_sets(_BGC)
    assert indoor == {"L03", "L07"}
    assert outdoor == {"L01"}                       # is_indoor None 은 어느 쪽도 아님


def _grp(scene, bg_id, members):
    return {"scene_index": scene, "bg_id": bg_id, "member_keys": members,
            "anchor_key": members[0], "signals_by_shot": {}, "group_signals": {},
            "zoom_members": []}


def test_filter_indoor_keeps_pure_indoor_group():
    indoor, outdoor = classify_loc_sets(_BGC)
    groups = [_grp(4, "L03B02", [(4, 1), (4, 2)])]
    bg_loc = {"L03B02": "L03"}                       # bg_id → location_id 구조키
    kept, excluded = filter_indoor_groups(
        groups, bg_loc_by_id=bg_loc, indoor_locs=indoor, outdoor_locs=outdoor)
    assert len(kept) == 1
    assert kept[0]["location_id"] == "L03"           # annotate
    assert excluded == []


def test_filter_indoor_excludes_outdoor_owned():
    indoor, outdoor = classify_loc_sets(_BGC)
    groups = [_grp(1, "L01B01", [(1, 1), (1, 2)])]
    kept, excluded = filter_indoor_groups(
        groups, bg_loc_by_id={"L01B01": "L01"}, indoor_locs=indoor, outdoor_locs=outdoor)
    assert kept == []
    assert excluded[0]["reason"] == "outdoor_owned"
    assert excluded[0]["location_id"] == "L01"


def test_filter_indoor_excludes_unresolved_loc_default_deny():
    indoor, outdoor = classify_loc_sets(_BGC)
    groups = [_grp(9, "L99B01", [(9, 1), (9, 2)])]
    # bg_id 가 background_render groups 에 없음 → loc unresolved → default-deny
    kept, excluded = filter_indoor_groups(
        groups, bg_loc_by_id={}, indoor_locs=indoor, outdoor_locs=outdoor)
    assert kept == []
    assert excluded[0]["reason"] == "loc_unresolved"


def test_filter_indoor_excludes_unclassified_loc():
    indoor, outdoor = classify_loc_sets(_BGC)
    # L09 는 is_indoor None → indoor/outdoor 어느 집합도 아님 → default-deny.
    groups = [_grp(5, "L09B01", [(5, 1), (5, 2)])]
    kept, excluded = filter_indoor_groups(
        groups, bg_loc_by_id={"L09B01": "L09"}, indoor_locs=indoor, outdoor_locs=outdoor)
    assert kept == []
    assert excluded[0]["reason"] == "loc_not_indoor"


# ───────── W-C (2026-07-03) — gesture 공간구 + facing 실방향 복원 ─────────


def test_build_pose_brief_gesture_target_spatial_restored():
    """W-C: gesture_target_label 과 label 이 일치하는 non-character constraint 의
    screen_zone/depth_plane 이 gesture 공간구로 복원된다 (라벨 원문/이름 미주입).
    S14 st28 '빈 식탁 가리킴 → 반대편 서랍장' 방향 소실의 근본 수정."""
    s = _shot(1, 1, [
        {"target_kind": "character", "target_id": "C01", "label": "w",
         "screen_zone": "middle_left", "depth_plane": "foreground",
         "gesture_action": "points_to", "gesture_target_label": "empty table"},
        {"target_kind": "prop", "target_id": "P01", "label": "empty table",
         "screen_zone": "middle_right", "depth_plane": "background",
         "gesture_action": "none", "gesture_target_label": ""},
    ])
    b = build_pose_brief(s)
    g = b["figures"][0]["gesture"]
    assert g == ("points_to toward the element at the middle-right of frame, "
                 "in the background")
    # 라벨 원문/ID 미주입 계약 유지
    assert "empty table" not in str(b) and "P01" not in str(b)


def test_build_pose_brief_gesture_target_unresolved_keeps_generic():
    """W-C: 대상 constraint 미발견(또는 char 대상) → 기존 generic 문구 유지."""
    s = _shot(1, 1, [
        {"target_kind": "character", "target_id": "C01", "label": "w",
         "screen_zone": "middle_left", "depth_plane": "foreground",
         "gesture_action": "reaches_for", "gesture_target_label": "curtain"},
    ])
    b = build_pose_brief(s)
    assert b["figures"][0]["gesture"] == "reaches_for toward a nearby object"


def test_build_pose_brief_facing_enum_mapping():
    """W-C: gaze_direction_kind closed-world enum → 실방향 문구 (기존 'oriented per
    gaze direction' 무의미 문구 대체). 미등재 kind → None (문구 생략)."""
    cases = {
        "camera": "facing the camera",
        "down": "head angled downward",
        "distant": "gazing into the distance",
        "looks_at_character": "facing the other figure",
    }
    for kind, expected in cases.items():
        s = _shot(1, 1, [{"target_kind": "character", "target_id": "C01", "label": "w",
                          "screen_zone": "left", "depth_plane": "foreground",
                          "gesture_action": "none", "gesture_target_label": ""}])
        s["character_angles"] = [{"character": "w", "body_pose": "standing",
                                  "gaze_direction_kind": kind, "gaze_target_id": None}]
        assert build_pose_brief(s)["figures"][0]["facing"] == expected
    # 미등재 kind → None (무의미 fallback 문구 없음)
    s = _shot(1, 1, [{"target_kind": "character", "target_id": "C01", "label": "w",
                      "screen_zone": "left", "depth_plane": "foreground",
                      "gesture_action": "none", "gesture_target_label": ""}])
    s["character_angles"] = [{"character": "w", "body_pose": "standing",
                              "gaze_direction_kind": "sideways", "gaze_target_id": None}]
    assert build_pose_brief(s)["figures"][0]["facing"] is None
