"""Phase C v2 classifier regression — Codex NEEDS_REVISION_BEFORE_PHASE_D 반영.

S5_S2 / S5_S6 / S14_S4 / S14_S5 / S14_S9 / S27_S1 / S27_S4 / S18_S9 / S12_S6 케이스
가 다시 잘못 분류되면 fail. Phase D image API 호출 전 반드시 green.
"""
from __future__ import annotations

import sys
from pathlib import Path

_REPO_ROOT = Path(__file__).resolve().parents[3]
_SCRIPTS = _REPO_ROOT / "backend" / "scripts"
if str(_SCRIPTS) not in sys.path:
    sys.path.insert(0, str(_SCRIPTS))

import experiment_rooftop_spatial_bg as exp  # noqa: E402


def _shot(
    *,
    scene: int,
    shot: int,
    summary: str,
    description: str,
    visible_ids: list[str] | None = None,
    dependent: str | None = None,
) -> exp.ShotMeta:
    return exp.ShotMeta(
        still_id=f"st-{scene}-{shot}",
        scene_index=scene,
        shot_index=shot,
        scene_summary=summary,
        shot_description=description,
        visible_short_ids=visible_ids or ["L05"],
        dependent_scene_id=dependent,
        raw_visible_json="[]",
    )


# ---------------------------------------------------------------------------
# sub_space — Codex Blocking 1 regression
# ---------------------------------------------------------------------------
def test_s27_s1_living_room_text_overrides_summary_room_mention() -> None:
    """summary 가 '옥탑방 안에서' 일반 표현이어도 shot_description 의 명시적 '거실' 우선."""
    s = _shot(
        scene=27, shot=1,
        summary=(
            "해질 무렵, 여전히 어지럽혀진 옥탑방 안에서 혜수가 수리영의 음성 메시지를 들으며 "
            "초조한 표정으로 김 형사와 서 있다. 황량한 기운이 감도는 방 안에는 바람에 현관문이 덜커덩거리며"
        ),
        description="어스름한 옥탑방 거실, 혜수가 스마트폰을 귀에 댄 채 심각한 표정으로 서 있는 전신",
    )
    label, _ev, _notes = exp.classify_sub_space(s)
    assert label == "main_room", f"S27_S1 expected main_room, got {label}"


def test_s27_s4_living_room_text_overrides_summary() -> None:
    s = _shot(
        scene=27, shot=4,
        summary="해질 무렵, 여전히 어지럽혀진 옥탑방 안에서 혜수가 수리영의 음성 메시지를 들으며",
        description="거실에 선 혜수와 김형사 뒤로 바람에 흔들린 현관문이 미세하게 벌어진 구도",
    )
    label, _ev, _notes = exp.classify_sub_space(s)
    assert label == "main_room", f"S27_S4 expected main_room, got {label}"


def test_s5_s2_main_room_normal_morning() -> None:
    s = _shot(
        scene=5, shot=2,
        summary=(
            "햇살이 비치는 옥탑방 거실. 앞치마를 두른 엄마 민숙이 불안하게 뉴스를 보다 딸 수리영이 "
            "나오자 급히 TV를 끈다. 외출 채비를 하던 수리영은 식탁에서 한약재 물을 마시고, 나가는 "
            "길에 자신의 손목 안쪽에 새겨진 작은 붉은 원형 표식을 본다."
        ),
        description="앞치마를 두른 민숙이 어둡고 심각한 표정으로 거실의 켜진 TV 화면을 응시하는 상체",
    )
    sub, _, _ = exp.classify_sub_space(s)
    state, _, _ = exp.classify_state(s)
    assert sub == "main_room", f"S5_S2 sub_space expected main_room, got {sub}"
    # 핵심: '손목 ... 붉은 원형 표식' 이 vandalized 로 분류되면 안 됨 (캐릭터 mark).
    assert state != "vandalized", f"S5_S2 state must NOT be vandalized (character mark, not room vandal), got {state}"


def test_s12_s6_bedroom_corpse_marks_night() -> None:
    s = _shot(
        scene=12, shot=6,
        summary=(
            "어두운 옥탑방 안, 수리영은 방 안에서 어깨가 처참하게 뜯겨 죽어 있는 엄마 민숙의 시신을 "
            "발견한다. 공포에 떨며 시신의 손에서 사진을 빼내는 순간, 민숙의 손목 문신이 붉게 변하"
        ),
        description=(
            "어깨와 쇄골의 살점이 거칠게 뜯겨나가 피를 흘리는 민숙이 방바닥에 고개를 떨군 채 "
            "주저앉아 있는 참혹한 모습"
        ),
    )
    sub, _, _ = exp.classify_sub_space(s)
    state, _, _ = exp.classify_state(s)
    time, _, _ = exp.classify_time(s)
    assert sub == "bedroom", f"S12_S6 sub_space expected bedroom, got {sub}"
    assert state == "corpse_marks", f"S12_S6 state expected corpse_marks, got {state}"
    assert time == "night", f"S12_S6 time expected night, got {time}"


def test_s14_s4_cleaned_overrides_corpse_marks() -> None:
    """'피와 시신 없이 깨끗하게 정돈된' summary — cleaned 가 corpse_marks 를 이겨야."""
    s = _shot(
        scene=14, shot=4,
        summary=(
            "밤의 빌라 옥탑방 안, 피와 시신, 붉은 표식 없이 깨끗하게 정돈된 방을 보고 수리영은 "
            "충격에 빠진다. 40대 여성 경위인 이모 혜수와 30대 김 형사는 흔적을 찾지 못해 허탈해하"
        ),
        description="핏자국이나 시신 없이 지나치게 깔끔하게 정돈된 수리영의 방안 침대와 창문 커튼 전경",
    )
    state, _, _ = exp.classify_state(s)
    sub, _, _ = exp.classify_sub_space(s)
    assert state == "cleaned", f"S14_S4 state expected cleaned (negation), got {state}"
    assert sub == "bedroom", f"S14_S4 sub_space expected bedroom, got {sub}"


def test_s14_s9_ambiguous_should_be_manual_review() -> None:
    """shot_description 에 sub_space 키워드 없고 summary 도 일반적이면 manual_review."""
    s = _shot(
        scene=14, shot=9,
        summary=(
            "밤의 빌라 옥탑방 안, 피와 시신, 붉은 표식 없이 깨끗하게 정돈된 방을 보고 수리영은 "
            "충격에 빠진다."
        ),
        description="혜수가 두 손으로 수리영의 어깨를 감싸 안고 다정한 눈빛으로 바라보는 상체",
    )
    sub, _, _ = exp.classify_sub_space(s)
    # 명시적 sub_space evidence 부족 → manual_review_needed 가 정확함.
    assert sub == "manual_review_needed", (
        f"S14_S9 (no explicit sub_space in shot_description) expected manual_review_needed, got {sub}"
    )


def test_s18_s9_bathroom_morning() -> None:
    s = _shot(
        scene=18, shot=9,
        summary=(
            "아침, 빈 집에서 깨어난 수리영은 방과 욕실을 헤맨다. 욕실 거울 속 벽면에 붉은 원 표식이 "
            "나타났다 사라지고, 거실 식탁 위 사진 속 엄마의 얼굴이 기괴하게 흘러내리는 환각을 목"
        ),
        description="거울에 비친 수리영의 어깨너머 욕실 벽면에 나타난 거친 붉은 원 형태의 클로즈업",
    )
    sub, _, _ = exp.classify_sub_space(s)
    time, _, _ = exp.classify_time(s)
    assert sub == "bathroom", f"S18_S9 sub_space expected bathroom, got {sub}"
    assert time == "morning", f"S18_S9 time expected morning, got {time}"


def test_s18_s5_corpse_negation_yields_non_corpse_state() -> None:
    """'시신이 없는 깨끗한 바닥' — corpse_marks 무력화. 결과는 cleaned 또는 normal."""
    s = _shot(
        scene=18, shot=5,
        summary=(
            "아침, 빈 집에서 깨어난 수리영은 방과 욕실을 헤맨다. 욕실 거울 속 벽면에 붉은 원 표식이 "
            "나타났다 사라지고, 거실 식탁 위 사진 속 엄마의 얼굴이 기괴하게 흘러내리는 환각을 목"
        ),
        description="시신이 없는 깨끗한 바닥을 내려다보는 수리영의 측면",
    )
    state, _, _ = exp.classify_state(s)
    assert state != "corpse_marks", (
        f"S18_S5 corpse negation must block corpse_marks, got {state}"
    )


def test_s12_s14_wall_red_circle_yields_vandalized() -> None:
    """'벽면에 굵은 붓으로 칠한 듯한 거친 붉은 원' — vandalized."""
    s = _shot(
        scene=12, shot=14,
        summary=(
            "어두운 옥탑방 안, 수리영은 방 안에서 어깨가 처참하게 뜯겨 죽어 있는 엄마 민숙의 시신을 "
            "발견한다."
        ),
        description=(
            "침대 위 어두운 벽면에 굵은 붓으로 칠한 듯한 거친 붉은 원 형태가 뚜렷하게 떠오른 모습"
        ),
    )
    state, _, _ = exp.classify_state(s)
    assert state == "vandalized", (
        f"S12_S14 wall red circle expected vandalized, got {state}"
    )


def test_s25_s7_bedroom_dusk_vandalized() -> None:
    s = _shot(
        scene=25, shot=7,
        summary=(
            "해질녘 가구가 어지럽혀진 옥탑방으로 돌아온 수리영은 방 벽면에 그려진 붉은 원형 표식과 "
            "그 중심에 꽂힌 피 묻은 옛날 사진을 발견한다."
        ),
        description="충격에 빠져 침대 가장자리에 주저앉은 채 넋이 나간 수리영의 전신",
    )
    sub, _, _ = exp.classify_sub_space(s)
    state, _, _ = exp.classify_state(s)
    time, _, _ = exp.classify_time(s)
    assert sub == "bedroom", f"S25_S7 sub_space expected bedroom (침대 strong), got {sub}"
    assert state == "vandalized", f"S25_S7 state expected vandalized (가구가 어지럽혀진), got {state}"
    assert time == "dusk", f"S25_S7 time expected dusk, got {time}"


# ---------------------------------------------------------------------------
# Camera slot — fallback_unknown
# ---------------------------------------------------------------------------
def test_assign_camera_slot_manual_review_yields_fallback() -> None:
    s = _shot(scene=99, shot=1, summary="", description="")
    slot, notes = exp.assign_camera_slot(s, "manual_review_needed")
    assert slot == "fallback_unknown"
    assert any("manual_review" in n for n in notes)


def test_assign_camera_slot_unknown_sub_space() -> None:
    s = _shot(scene=99, shot=1, summary="", description="")
    slot, _notes = exp.assign_camera_slot(s, "garage")  # 정의 부재
    assert slot == "fallback_unknown"


# ---------------------------------------------------------------------------
# Prompt — Codex Blocking 3: no numeric metric
# ---------------------------------------------------------------------------
def test_base_prompt_has_no_metric_units() -> None:
    p = exp.build_base_plate_prompt(
        "main_room", "eye_level_wide",
        "거실 중앙 standing eye-level, wide framing, 식탁/주방 정면, 거실 전체",
        "옥탑방 내부 canon",
    )
    forbidden = ["1.6m", "35mm", "50mm", "1024x768", "1.6 m", "35 mm", "50 mm"]
    for token in forbidden:
        assert token not in p, f"base prompt must not contain metric token {token!r}"


def test_camera_slot_pool_has_no_metric_units() -> None:
    forbidden = ["1.6m", "35mm", "50mm", "1.6 m", "35 mm", "50 mm"]
    for _sub, _label, desc in exp.CAMERA_SLOT_POOL:
        for token in forbidden:
            assert token not in desc, f"slot desc {_label!r} must not contain metric token {token!r}"


# ---------------------------------------------------------------------------
# Cost estimate sanity
# ---------------------------------------------------------------------------
def test_estimate_cost_basic_math() -> None:
    out = exp.estimate_cost(unique_base_plates=3, shot_plate_count=14, plan_lvm=True)
    # retry_factor 1.2 → image_calls = (3+14)*1.2 = 20.4, lvm = 17
    assert out["image_calls_est"] == round(3 * 1.2 + 14 * 1.2, 2)
    assert out["lvm_calls_est"] == 17.0
    assert out["usd_estimate"] > 0


# ---------------------------------------------------------------------------
# Phase D infra — _parse_stages / _find_free_port / VALID_STAGES
# ---------------------------------------------------------------------------
def test_parse_stages_default_string() -> None:
    out = exp._parse_stages("plan,base,lvm")
    assert out == ["plan", "base", "lvm"]


def test_parse_stages_rejects_unknown() -> None:
    import pytest
    with pytest.raises(SystemExit):
        exp._parse_stages("plan,unknown_stage")


def test_parse_stages_empty_falls_back_to_plan() -> None:
    out = exp._parse_stages("")
    assert out == ["plan"]


def test_valid_stages_set_includes_all_documented() -> None:
    for s in ("plan", "base", "lvm", "shots", "shots_lvm", "context"):
        assert s in exp.VALID_STAGES


def test_find_free_port_returns_int_or_none() -> None:
    # 50000~50005 사이에서 찾기 — 일반적으로 free.
    port = exp._find_free_port(50000, attempts=6, bind="127.0.0.1")
    assert port is None or (50000 <= port <= 50005)


# ---------------------------------------------------------------------------
# LVM schema sanity — strict json_schema 가 유효한지
# ---------------------------------------------------------------------------
def test_realized_spatial_card_schema_has_required_fields() -> None:
    schema = exp.REALIZED_SPATIAL_CARD_SCHEMA
    assert schema["type"] == "object"
    assert schema["additionalProperties"] is False
    required = set(schema["required"])
    for field in (
        "plate_id", "plate_kind", "space_key", "camera_slot",
        "fixed_objects", "depth_zones", "camera_impression",
        "placement_zones", "not_visible",
        "overall_confidence", "overall_confidence_band",
    ):
        assert field in required, f"required field {field!r} missing"


def test_lvm_instruction_template_no_metric_units() -> None:
    template = exp.LVM_INSTRUCTION_TEMPLATE
    # 의도적으로 'metric (mm, m) 표기 금지' 명령 자체는 포함되어야 함.
    assert "metric" in template
    # 그러나 instruction 안에 모델에게 metric 값을 던지진 않음.
    forbidden_value_tokens = ["35mm wide", "1.6m", "50mm"]
    for t in forbidden_value_tokens:
        assert t not in template, f"instruction must not embed metric value {t!r}"
