"""캐릭터로 등록된 것이 보이는 샷은 **무조건 사람 샷**이다 (2026-09-19 사용자 지시).

> 「사람 형태 로봇 등 캐릭터에 등록된 것이 있는 샷은 무조건 사람 형태로
>  인식하게」

실측: 분류 LLM 이 「로봇 찰리만 보임」을 사람 없음(person_visible=false)으로
판정해 배경 전용 샷이 됐고, 찰리 신원 참조가 안 붙었다 — 찰리 56샷 중 27샷.

잠그는 것:
- 규칙 함수 — 덮는 조건 · 덮지 않는 조건 · 판정값을 기록에 남김 · 입력 불변
- 캐릭터 집합 — VE 의 엔티티 id 를 캐릭터 정본으로 잇는다
- 콘티 단계 — `run_shot_conti_light` 에 **실제로 넘어간** 분류가 덮인 값이다
"""
from __future__ import annotations

import json
from unittest.mock import MagicMock, patch

PID, EID = "SAMPLE_P", "SAMPLE_E"


def fake_db(chars, stills):
    """모델마다 정해 둔 행을 돌려주는 DB 대역 — 행은 진짜 ORM 모델이다."""
    from app.models.project import EntityCanon, SceneStill

    rows = {EntityCanon: chars, SceneStill: stills}

    def _query(model, *a, **k):
        q = MagicMock()
        for m in ("filter", "filter_by", "join", "order_by", "options"):
            getattr(q, m).return_value = q
        q.all.return_value = list(rows.get(model, []))
        q.first.return_value = None
        q.count.return_value = 0
        return q

    db = MagicMock()
    db.query.side_effect = _query
    return db


def sample_rows(ve_ids=("E1",)):
    from app.models.project import EntityCanon, SceneStill

    chars = [EntityCanon(id="E1", project_id=PID, short_id="C01",
                         entity_type="character", name="SAMPLE ROBOT")]
    stills = [SceneStill(
        id="st_1", project_id=PID, episode_id=EID,
        scene_index=1, shot_index=1,
        visible_entities_json=json.dumps(
            [{"id": i, "short_id": "C01"} for i in ve_ids]))]
    return chars, stills


# ── 규칙 함수 ────────────────────────────────────────────────────────


def test_character_in_shot_forces_person_visible():
    from app.modules.pipeline.shot_ref_classify import (
        force_person_visible_for_characters,
    )

    raw = {"S1sh1": {"person_visible": False, "prev": None}}
    out = force_person_visible_for_characters(raw, {"S1sh1": {"C01"}})
    assert out["S1sh1"]["person_visible"] is True
    # 판정값과 규칙값을 기록에서 가를 수 있게
    assert out["S1sh1"]["person_visible_llm"] is False
    assert out["S1sh1"]["person_visible_forced_by"] == ["C01"]
    # 받은 것은 안 바꾼다 — 체크포인트의 판정값은 그대로다
    assert raw["S1sh1"] == {"person_visible": False, "prev": None}


def test_no_character_keeps_the_judgement():
    from app.modules.pipeline.shot_ref_classify import (
        force_person_visible_for_characters,
    )

    raw = {"S1sh1": {"person_visible": False}}
    out = force_person_visible_for_characters(raw, {"S1sh1": set()})
    assert out == raw


def test_already_person_shot_gets_no_extra_fields():
    from app.modules.pipeline.shot_ref_classify import (
        force_person_visible_for_characters,
    )

    raw = {"S1sh1": {"person_visible": True}}
    out = force_person_visible_for_characters(raw, {"S1sh1": {"C01"}})
    assert out == raw


def test_character_sids_come_from_ve_ids_joined_to_the_canon():
    from app.modules.pipeline.shot_ref_classify import (
        load_character_sids_by_tag,
    )

    chars, stills = sample_rows()
    assert load_character_sids_by_tag(fake_db(chars, stills), PID, EID) == {
        "S1sh1": {"C01"}}
    # 캐릭터 정본에 없는 id 는 안 센다
    chars, stills = sample_rows(ve_ids=("E_PROP",))
    assert load_character_sids_by_tag(fake_db(chars, stills), PID, EID) == {
        "S1sh1": set()}


# ── 콘티 단계 — 실제로 넘어간 분류를 잰다 ───────────────────────────


def test_conti_step_hands_the_forced_classification_to_generation():
    from app.core.steps.shot_conti_light_step import ShotContiLightStep

    step = ShotContiLightStep.__new__(ShotContiLightStep)
    step.project_id, step.episode_id = PID, EID
    step.project_config = {}
    step.build_opik_metadata = MagicMock(return_value={})
    chars, stills = sample_rows()
    step.db = fake_db(chars, stills)
    cps = {
        "shot_ref_classify": {"data": {"shots": {
            "S1sh1": {"person_visible": False, "prev": None}}}},
        "shot_continuity": {"data": {}},
        "shot_validator": {"data": {"scenes": [
            {"scene_index": 1, "shots": [
                {"shot_index": 1, "description": "SAMPLE"}]}]}},
        "shot_selection": None,
        "scene_director": {"data": {"scenes": []}},
        "scene_save": {"data": {"segments": []}},
    }
    step._load_prev_checkpoint = lambda sid: cps.get(sid)
    step._register_intermediate_assets = MagicMock()
    seen = {}

    def spy(**kw):
        seen.update(kw)
        return {"applicable_count": 1, "completed_count": 1,
                "failed_count": 0, "contis": {}}

    with patch("app.modules.pipeline.shot_conti_light.run_shot_conti_light",
               side_effect=spy), \
            patch("app.modules.pipeline.shot_conti_light."
                  "resolve_shot_plate_map", return_value={}), \
            patch("app.modules.pipeline.shot_ref_classify."
                  "derive_location_by_scene", return_value={}), \
            patch("app.core.config.settings.outdoor_lane_pipe_enabled",
                  False, create=True), \
            patch("app.core.config.settings.outdoor_lane_plan_enabled",
                  False, create=True), \
            patch("app.core.config.settings.outdoor_map_conti_enabled",
                  False, create=True):
        step._execute()
    cls = seen["classify_shots"]["S1sh1"]
    assert cls["person_visible"] is True
    assert cls["person_visible_forced_by"] == ["C01"]
