"""shot_visibility helpers — gaze pattern + offscreen drift detector.

S13/S19/S27 (PID 34dc0431 / EID 3453b2ab) canary 의 실제 description /
camera_direction 을 fixture 로 사용. shot_director.visible_entity_ids 와
shot_staging.camera_direction 사이 dual SOT drift 를 producer-side
description-only 와 consumer-side (Path 1 structured + Path 2 proximity
diagnostic) layer 에서 잡는 helper 의 단위 회귀 차단.

Area #3 v1 W3: Path 1/2 split. 이전 merged drift helper 단일 함수 폐기
후 detect_offscreen_drift_structured (Path 1, blocking-eligible) +
detect_offscreen_drift_proximity_diagnostic (Path 2, diagnostic only) 로
분리. 본 파일은 W3 split 후 각 함수의 회귀를 별도 표면에서 잡는다.
"""
from __future__ import annotations

from app.modules.pipeline.shot_visibility import (
    detect_gaze_pattern_exclusions,
    detect_offscreen_drift_proximity_diagnostic,
    detect_offscreen_drift_structured,
)


# ---------------------------------------------------------------------------
# detect_gaze_pattern_exclusions — description-level
# ---------------------------------------------------------------------------


def test_gaze_exclusion_s13_shot5_close_up_with_gaze_target():
    # canary S13_Shot5: "혜수를 올려다보며 ... 수리영의 얼굴 클로즈업"
    # 혜수 = gaze target (offscreen), 수리영 = gaze subject (in-frame).
    description = "혜수를 올려다보며 눈망울이 붉게 젖은 수리영의 얼굴 클로즈업"
    name_to_id = {"수리영": "C01", "혜수": "C02"}

    excluded = detect_gaze_pattern_exclusions(description, name_to_id)

    assert excluded == {"C02": "혜수"}, excluded


def test_gaze_exclusion_s19_shot7_multiword_target():
    # canary S19_Shot7: "굳은 표정으로 남자 직원을 응시하는 수리영의 얼굴 클로즈업"
    # 남자 직원 = gaze target (multi-word name).
    description = "굳은 표정으로 남자 직원을 응시하는 수리영의 얼굴 클로즈업"
    name_to_id = {"수리영": "C01", "남자 직원": "C07"}

    excluded = detect_gaze_pattern_exclusions(description, name_to_id)

    assert excluded == {"C07": "남자 직원"}, excluded


def test_gaze_exclusion_s12_shot4_directional_phrase_close_up():
    # canary S12_Shot4 (PID 02829fe8 fresh): description 에 명시적 gaze 동사
    # 없음. 대신 "TARGET 쪽으로 + 고개/시선 + SUBJECT의 얼굴 + 클로즈업" 4-token
    # 패턴으로 close-up gaze 확정.
    description = (
        "눈물을 머금은 채 혜수 쪽으로 고개를 든 수리영의 얼굴 클로즈업."
    )
    name_to_id = {"수리영": "C01", "혜수": "C02"}

    excluded = detect_gaze_pattern_exclusions(description, name_to_id)

    assert excluded == {"C02": "혜수"}, excluded


def test_gaze_exclusion_s12_shot13_body_part_possession_not_target():
    # canary S12_Shot13 (PID 02829fe8 fresh) 의 실제 production description.
    # "수리영의 어깨" 는 body-part possession 으로 SUBJECT 의 부분 묘사.
    # last-[를을] 매치가 "수리영의 어깨" 를 잡아 수리영 (C01) 을 TARGET 으로
    # 잘못 인식. body-part possession 패턴 차단 의무.
    description = (
        "수리영의 어깨를 양손으로 감싸 쥔 채 부드러운 눈빛으로 바라보는 "
        "혜수의 정면 구도."
    )
    name_to_id = {"수리영": "C01", "혜수": "C02"}

    excluded = detect_gaze_pattern_exclusions(description, name_to_id)

    # body-part possession 차단 — 수리영 (C01) 은 TARGET 으로 인식되면 안 됨.
    # 결과: 명시적 gaze TARGET 부재 → excluded == {} (둘 다 in-frame).
    assert "C01" not in excluded, excluded


def test_gaze_exclusion_s27_shot11_no_gaze_verb_no_exclusion():
    # canary S27_Shot11 control: 어깨를/손을 (non-gaze verbs) → no exclusion.
    description = (
        "조타실 문 안쪽으로 한쪽 어깨를 밀어 넣은 채 두 손을 앞으로 뻗은 "
        "수리영의 상체"
    )
    name_to_id = {"수리영": "C01", "인우": "C03"}

    excluded = detect_gaze_pattern_exclusions(description, name_to_id)

    assert excluded == {}


def test_gaze_exclusion_no_framing_noun_no_match():
    # gaze 동사는 있지만 framing 명사 없음 → 고신뢰 미달, noop.
    description = "혜수를 응시하던 수리영이 천천히 일어선다"
    name_to_id = {"수리영": "C01", "혜수": "C02"}

    excluded = detect_gaze_pattern_exclusions(description, name_to_id)

    assert excluded == {}


def test_gaze_exclusion_no_gaze_verb_no_match():
    # framing 만 있고 gaze 동사 없음 → noop.
    description = "수리영의 얼굴 클로즈업"
    name_to_id = {"수리영": "C01", "혜수": "C02"}

    excluded = detect_gaze_pattern_exclusions(description, name_to_id)

    assert excluded == {}


def test_gaze_exclusion_subject_protected_when_also_in_target_span():
    # 만약 subject 와 target 이 같은 이름이라면 (잘못 매치) → subject 가
    # 우선 (offscreen 처리 안 함).
    description = "수리영을 응시하던 수리영의 얼굴 클로즈업"
    name_to_id = {"수리영": "C01"}

    excluded = detect_gaze_pattern_exclusions(description, name_to_id)

    assert excluded == {}


def test_gaze_exclusion_short_name_skipped():
    # 1자 이름은 false-positive 위험 → skip.
    description = "갑을 바라보는 을의 얼굴 클로즈업"
    name_to_id = {"갑": "C01", "을": "C02"}

    excluded = detect_gaze_pattern_exclusions(description, name_to_id)

    # 1자 이름은 제외 — 안전 우선.
    assert excluded == {}


def test_gaze_exclusion_empty_input():
    assert detect_gaze_pattern_exclusions("", {"수리영": "C01"}) == {}
    assert detect_gaze_pattern_exclusions("어떤 묘사", {}) == {}


# ---------------------------------------------------------------------------
# detect_offscreen_drift_structured (Path 1) / _proximity_diagnostic (Path 2)
# Area #3 W3: 이전 merged drift helper 단일 함수 폐기 후 Path 별 분리 검증.
# ---------------------------------------------------------------------------


def test_offscreen_drift_s13_shot5_remains_off_camera():
    # canary S13_Shot5 staging cam: "혜수 remains off-camera as a pressure ..."
    # director.visible 에 C02 (혜수) 남아 있으면 drift.
    # character_angles 없음 → Path 2 (proximity diagnostic) 로 catch.
    cam = (
        "Low and close in front of 수리영, the camera continues a slight dolly-in "
        "to a CU, low angle, shallow-DOF emotional portrait as she looks upward "
        "toward 혜수 just above the lens line. 수리영's tear-reddened eyes fill "
        "the upper center of the frame, her shoulders small at the bottom edge, "
        "while 혜수 remains off-camera as a pressure in her gaze rather than a "
        "visible figure."
    )
    id_to_name = {"C01": "수리영", "C02": "혜수"}
    visible = ["C01", "C02", "L11"]

    drift = detect_offscreen_drift_proximity_diagnostic(visible, cam, id_to_name)

    assert drift == {"C02": "혜수"}, drift


def test_offscreen_drift_s19_shot7_real_canary_via_gaze_target():
    # canary S19_Shot7 의 *실제* staging cam_direction: 영어 descriptor
    # ("the male employee") 만 사용 — Korean canonical_name "남자 직원" 이
    # camera_direction 에 inline 으로 등장하지 않는다. proximity NL path
    # 로는 catch 못 함 → character_angles[].gaze_target 구조화 신호로 Path 1
    # (structured) blocking-eligible catch.
    cam = (
        "Inside the mart aisle, this is a CU at eye-level with a tight frontal "
        "three-quarter framing derived from the dolly-in; Suriyeong's face "
        "occupies the right-center of frame while the male employee remains "
        "entirely off-screen, present only through her fixed eyeline. The "
        "shelves behind her fall into shallow DOF, and the shot holds like "
        "an interrogation portrait without letting her look directly into "
        "the lens."
    )
    character_angles = [
        {
            "angle": "three_quarter_left",
            "body_pose": "leaning forward tense",
            "character": "수리영",
            "gaze_direction_kind": "looks_at_character",
            "gaze_target_id": "C07",
            "subject_state": "alive",
        }
    ]
    id_to_name = {"C01": "수리영", "C07": "남자 직원"}
    visible = ["C01", "C07", "L09", "L10"]

    drift = detect_offscreen_drift_structured(
        visible,
        camera_direction=cam,
        character_angles=character_angles,
        id_to_name=id_to_name,
    )

    assert drift == {"C07": "남자 직원"}, drift


def test_offscreen_drift_s19_real_canary_without_character_angles_misses():
    # 보조 신호 없으면 NL path 로는 catch 못 함을 명시 — Path 2 proximity
    # diagnostic 의 한계 회귀 차단 (gaze_target 보조가 정말 필요했는지 증명).
    cam = (
        "Inside the mart aisle, this is a CU at eye-level with a tight frontal "
        "three-quarter framing derived from the dolly-in; Suriyeong's face "
        "occupies the right-center of frame while the male employee remains "
        "entirely off-screen, present only through her fixed eyeline."
    )
    id_to_name = {"C01": "수리영", "C07": "남자 직원"}
    visible = ["C01", "C07", "L09", "L10"]

    drift = detect_offscreen_drift_proximity_diagnostic(visible, cam, id_to_name)

    # 한국어 이름이 cam 에 없음 + character_angles 없음 → miss.
    assert drift == {}, drift


def test_offscreen_drift_s27_shot11_offscreen_target_not_in_visible_no_drift():
    # canary S27_Shot11 control: cam 에 "block 인우 offscreen" 있지만
    # director.visible 에 인우 (C03) 가 들어있지 않음 → drift 없음.
    # character_angles 의 gaze_target=인우 도 visible_set 에 없으므로 통과.
    # Path 1 + Path 2 양쪽 모두 noop 의무.
    cam = (
        "Just inside the wheelhouse doorway, a medium close-up (MCU) in a "
        "slight Dutch angle uses the doorframe as a hard rectangular trap "
        "around 수리영's shoulder and outstretched hands. The handheld frame "
        "faces outward toward the deck, tightening on her upper body as she "
        "wedges herself through the threshold to block 인우 offscreen."
    )
    character_angles = [
        {
            "angle": "three_quarter_left",
            "body_pose": "shoulder wedged forward",
            "character": "수리영",
            "gaze_direction_kind": "looks_at_character",
            "gaze_target_id": "C03",
            "subject_state": "alive",
        }
    ]
    id_to_name = {"C01": "수리영", "C03": "인우"}
    visible = ["C01", "L19", "L20", "L21", "P17", "P18"]

    drift_structured = detect_offscreen_drift_structured(
        visible,
        camera_direction=cam,
        character_angles=character_angles,
        id_to_name=id_to_name,
    )
    drift_proximity = detect_offscreen_drift_proximity_diagnostic(
        visible, cam, id_to_name, character_angles=character_angles,
    )

    assert drift_structured == {}
    assert drift_proximity == {}


def test_offscreen_drift_gaze_target_only_when_offscreen_phrase_present():
    # gaze_target 이 visible 에 있더라도 cam 에 offscreen phrase 없으면 noop
    # (정상 in-frame gaze 케이스 false-positive 방지) — Path 1 OFFSCREEN_RE
    # gate 의무. W3 Case A 와 동등.
    cam = "Two-shot of 수리영 facing 혜수 across the table, soft frontal."
    character_angles = [
        {
            "character": "수리영",
            "gaze_direction_kind": "looks_at_character",
            "gaze_target_id": "C02",
            "subject_state": "alive",
        },
    ]
    id_to_name = {"C01": "수리영", "C02": "혜수"}
    visible = ["C01", "C02"]

    drift = detect_offscreen_drift_structured(
        visible,
        camera_direction=cam,
        character_angles=character_angles,
        id_to_name=id_to_name,
    )

    assert drift == {}


def test_offscreen_drift_no_offscreen_phrase_no_drift():
    cam = "MCU profile of 수리영 looking ahead, deep focus."
    id_to_name = {"C01": "수리영"}
    visible = ["C01"]

    drift = detect_offscreen_drift_proximity_diagnostic(visible, cam, id_to_name)

    assert drift == {}


def test_offscreen_drift_korean_phrase_화면밖():
    cam = "수리영의 정면 클로즈업, 혜수는 화면 밖에서 부른다."
    id_to_name = {"C01": "수리영", "C02": "혜수"}
    visible = ["C01", "C02"]

    drift = detect_offscreen_drift_proximity_diagnostic(visible, cam, id_to_name)

    assert drift == {"C02": "혜수"}, drift


def test_offscreen_drift_proximity_window_far_name_no_drift():
    # 이름이 offscreen phrase 에서 멀리 있으면 noise — drift 아님.
    cam = (
        "혜수 walks into the room. " + ("a" * 200) + " The frame is off-camera "
        "for a different actor entirely."
    )
    id_to_name = {"C02": "혜수"}
    visible = ["C02"]

    drift = detect_offscreen_drift_proximity_diagnostic(visible, cam, id_to_name)

    assert drift == {}


def test_offscreen_drift_empty_input():
    assert detect_offscreen_drift_proximity_diagnostic(
        [], "anything off-camera", {"C01": "x"},
    ) == {}
    assert detect_offscreen_drift_proximity_diagnostic(
        ["C01"], "", {"C01": "x"},
    ) == {}


def test_offscreen_drift_in_frame_speaker_looking_at_off_camera_target_not_in_visible():
    """In-frame speaker (character_angles[].character) must NOT be flagged as
    drift when NL says "<speaker> ... off-camera <target>" and target is
    correctly excluded from visible.

    Pattern (generic, synthetic names — no scenario dependency):
      cam      = "<speaker> ... toward off-camera <target>"
      visible  = [<speaker_sid>, ...]   (target NOT in visible — correctly off)
      angles   = [{character: <speaker>, gaze_target: <target>}]

    Without fix: Path 2 proximity fallback picks <speaker> from `visible_ids`
    as the closest name preceding "off-camera" → false positive drift on the
    in-frame speaker.
    With fix: character_angles[].character names map to visible sids that are
    exempt from drift candidacy in both Path 1 (structured) and Path 2
    (proximity diagnostic).
    """
    cam = (
        "Tight CU on Subject Alpha as she lifts her face toward off-camera "
        "Subject Beta. Subject Alpha occupies center-left of frame; Subject "
        "Beta is kept fully off-frame and exists only through Subject Alpha's "
        "upward gaze."
    )
    character_angles = [
        {
            "character": "Subject Alpha",
            "gaze_direction_kind": "looks_at_character",
            "gaze_target_id": "C92",
            "subject_state": "alive",
        },
    ]
    # Subject Beta (C92) intentionally NOT in visible — correctly off-camera.
    visible = ["C91", "P91", "L91"]
    id_to_name = {
        "C91": "Subject Alpha",
        "C92": "Subject Beta",
        "P91": "Phone",
        "L91": "Rooftop",
    }

    drift_structured = detect_offscreen_drift_structured(
        visible,
        camera_direction=cam,
        character_angles=character_angles,
        id_to_name=id_to_name,
    )
    drift_proximity = detect_offscreen_drift_proximity_diagnostic(
        visible, cam, id_to_name, character_angles=character_angles,
    )

    assert drift_structured == {}, drift_structured
    assert drift_proximity == {}, drift_proximity


def test_offscreen_drift_in_frame_speaker_exempt_does_not_mask_true_drift():
    """character_angles in-frame exemption must not mask a true drift on a
    DIFFERENT visible entity whose name sits near an off-camera phrase.

    Pattern: two visible characters (Alpha = in-frame per angles, Gamma =
    visible but staging NL says off-camera). Path 2 proximity diagnostic
    must still flag Gamma (Path 1 structured does not, because
    gaze_target_id=C92 is not in visible_set).
    """
    cam = (
        "Tight CU on Subject Alpha; Subject Gamma walks off-camera behind her."
    )
    character_angles = [
        {
            "character": "Subject Alpha",
            "gaze_direction_kind": "looks_at_character",
            "gaze_target_id": "C92",
            "subject_state": "alive",
        },
    ]
    visible = ["C91", "C93"]  # Alpha (in-frame) + Gamma (drift candidate)
    id_to_name = {
        "C91": "Subject Alpha",
        "C92": "Subject Beta",
        "C93": "Subject Gamma",
    }

    drift_structured = detect_offscreen_drift_structured(
        visible,
        camera_direction=cam,
        character_angles=character_angles,
        id_to_name=id_to_name,
    )
    drift_proximity = detect_offscreen_drift_proximity_diagnostic(
        visible, cam, id_to_name, character_angles=character_angles,
    )

    # Path 1 structured: gaze_target_id=C92 not in visible_set → noop.
    assert drift_structured == {}, drift_structured
    # Path 2 proximity diagnostic: Alpha is in-frame per character_angles —
    # exempt. Gamma is in visible AND nearest to off-camera phrase → true drift.
    assert drift_proximity == {"C93": "Subject Gamma"}, drift_proximity


def test_offscreen_drift_no_character_angles_path2_behavior_unchanged():
    """When character_angles is absent, Path 2 proximity diagnostic retains
    behavior — closest visible name to off-camera phrase wins.

    This locks the in-frame-exemption fix to character_angles signal only,
    ensuring callers that do not pass character_angles see no behavior change.
    """
    cam = "Subject Alpha walks off-camera, leaving the frame empty."
    visible = ["C91"]
    id_to_name = {"C91": "Subject Alpha"}

    drift = detect_offscreen_drift_proximity_diagnostic(visible, cam, id_to_name)

    # No character_angles → in_frame_sids empty → Path 2 flags Alpha as drift.
    assert drift == {"C91": "Subject Alpha"}, drift
