"""참조 생성 보호에 **선택된 샷 VE 의 캐릭터**가 들어간다 (2026-09-19 사용자 지시).

실측(컨트리로드 2판): 선택된 샷에 나오는 캐릭터 6명이 참조 이미지를 못
받았다. 보호 판정이 `scene_detail.required_refs` 하나만 봤고, 여섯 다
`t2i_count=0 · required=False` 로 「등장 적은 인물」이 되어 건너뛰었다.
샷 이미지는 VE 를 보고 참조를 붙이므로, 붙일 참조가 없어 앞 샷 사진만으로
생성됐다(찰리 홀로그램 S90sh9 등).

잠그는 것:
- 선택 샷 VE 의 캐릭터는 보호된다 — 사유 「selected shot VE」
- 선택 **안 된** 샷에만 나오는 캐릭터는 보호되지 않는다(쓸데없는 생성 방지)
- 종전 출처(scene_detail.required_refs)는 그대로, 사유 문자열도 그대로
- 보호되면 저빈도 스킵이 안 된다
"""
from __future__ import annotations

import json
from unittest.mock import MagicMock

PID, EID = "SAMPLE_P", "SAMPLE_E"


def _fake_db(chars, stills, linked=None):
    """모델마다 정해 둔 행을 돌려주는 DB 대역 — 행은 진짜 ORM 모델이다.
    ★SQL 조건은 흉내 내지 않는다 — 그래서 EntityCanon 행은 **캐릭터만**
     넣는다(캐릭터 거르기는 SQL 쪽 몫이다).
    ★`linked` = 이 화에 연결된(보류 아닌) canon id — `active_episode_canon_ids`
     가 읽는 `EntityEpisodeLink.canon_id` 열 조회에 돌려준다. 기본은 전부."""
    from app.models.project import EntityCanon, EntityEpisodeLink, SceneStill

    _linked = [c.id for c in chars] if linked is None else list(linked)
    rows = {EntityCanon: chars, SceneStill: stills,
            EntityEpisodeLink.canon_id: [(i,) for i in _linked]}

    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, []))
        return q

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


def _world(tmp_path, monkeypatch, *, sd_required=(), extra_chars=(),
           linked=None):
    from app.models.project import EntityCanon, SceneStill

    monkeypatch.setattr("app.core.config.settings.projects_dir",
                        str(tmp_path / "projects"))
    d = (tmp_path / "projects" / PID / "checkpoints" / "episodes" / EID
         / "scene_detail")
    d.mkdir(parents=True)
    (d / "manifest.json").write_text(json.dumps({"data": {"scenes": [{
        "render_prompt_card": {"asset_requirements": {
            "required_refs": [{"id": s} for s in sd_required]}}}]}}),
        encoding="utf-8")
    chars = [
        EntityCanon(id="E1", project_id=PID, short_id="C01",
                    entity_type="character", name="SAMPLE ROBOT"),
        EntityCanon(id="E2", project_id=PID, short_id="C02",
                    entity_type="character", name="SAMPLE EXTRA"),
        EntityCanon(id="E3", project_id=PID, short_id="C03",
                    entity_type="character", name="SAMPLE LEAD"),
    ]
    stills = [
        SceneStill(id="st_1", project_id=PID, episode_id=EID,
                   scene_index=1, shot_index=1, is_selected=True,
                   visible_entities_json=json.dumps(
                       [{"id": "E1", "short_id": "C01"}])),
        # 선택 안 된 샷에만 나오는 캐릭터
        SceneStill(id="st_2", project_id=PID, episode_id=EID,
                   scene_index=1, shot_index=2, is_selected=False,
                   visible_entities_json=json.dumps(
                       [{"id": "E2", "short_id": "C02"}])),
    ]
    return _fake_db(chars + list(extra_chars), stills, linked=linked)


def test_selected_shot_character_is_protected(tmp_path, monkeypatch):
    from app.core.entity_protection import (
        collect_reference_required_canon_ids,
    )

    db = _world(tmp_path, monkeypatch)
    got = collect_reference_required_canon_ids(db, PID, EID)
    assert got == {"E1": "selected shot VE"}


def test_unselected_shot_character_is_not_protected(tmp_path, monkeypatch):
    from app.core.entity_protection import (
        collect_reference_required_canon_ids,
    )

    db = _world(tmp_path, monkeypatch)
    assert "E2" not in collect_reference_required_canon_ids(db, PID, EID)


def test_scene_detail_source_keeps_its_reason(tmp_path, monkeypatch):
    """종전 출처는 그대로 — 스킵 보고의 사유 문자열도 예전과 같다."""
    from app.core.entity_protection import (
        collect_reference_required_canon_ids,
    )

    db = _world(tmp_path, monkeypatch, sd_required=("C03O01",))
    got = collect_reference_required_canon_ids(db, PID, EID)
    assert got["E3"] == "scene_detail.required_refs"
    assert got["E1"] == "selected shot VE"


def test_protected_character_is_not_low_freq_skipped():
    from app.core.entity_protection import should_skip_low_freq

    e = {"id": "E1", "entity_type": "character", "name": "SAMPLE ROBOT"}
    # 실측 여섯 명의 모양 그대로 — t2i 0 · 변형 아님
    assert should_skip_low_freq(e, 0, False, False, False) is True
    assert should_skip_low_freq(e, 0, False, False, True) is False


def test_selected_shot_staging_character_is_protected(tmp_path, monkeypatch):
    """촬영 계획이 **선택 샷** 화면 안에 세운 인물도 보호된다(2026-09-19).

    실측: 셰퍼드·재판관이 촬영 계획엔 있고 VE 엔 없어 참조가 아예 없었다 —
    스틸 조립은 이 인물에게 참조를 붙이려는데 붙일 것이 없었다. 선택 안 된
    샷의 촬영 계획에만 있는 인물은 보호하지 않는다. POV 인물은 화면 밖이다.
    """
    from app.core.entity_protection import (
        collect_reference_required_canon_ids,
    )

    db = _world(tmp_path, monkeypatch)
    d = (tmp_path / "projects" / PID / "checkpoints" / "episodes" / EID
         / "shot_staging")
    d.mkdir(parents=True)
    (d / "manifest.json").write_text(json.dumps({"data": {"shots": [
        {"scene_index": 1, "shot_index": 1, "pov_character": "SAMPLE ROBOT",
         "character_angles": [{"character": "SAMPLE LEAD"},
                              {"character": "SAMPLE ROBOT"}]},
        {"scene_index": 1, "shot_index": 2, "pov_character": "",
         "character_angles": [{"character": "SAMPLE EXTRA"}]},
    ]}}), encoding="utf-8")
    got = collect_reference_required_canon_ids(db, PID, EID)
    assert got["E3"] == "selected shot staging"      # 선택 샷 촬영 계획
    assert got["E1"] == "selected shot VE"           # VE 가 먼저(사유 우선순위)
    assert "E2" not in got                           # 선택 안 된 샷에만


def test_staging_name_of_a_character_not_in_this_episode_is_ignored(
        tmp_path, monkeypatch):
    """이 화에 연결이 없는 인물은 촬영 계획에 이름이 있어도 보호하지 않는다 —
    스틸 조립이 이름을 잇는 범위(이 화에서 살아 있는 인물)와 같다.
    실측: 재판관(C50)은 이 화 연결 행이 없어 참조 단계가 아예 안 본다."""
    from app.core.entity_protection import (
        collect_reference_required_canon_ids,
    )
    from app.models.project import EntityCanon

    ghost = EntityCanon(id="E4", project_id=PID, short_id="C04",
                        entity_type="character", name="SAMPLE GHOST")
    db = _world(tmp_path, monkeypatch, extra_chars=(ghost,),
                linked=("E1", "E2", "E3"))
    d = (tmp_path / "projects" / PID / "checkpoints" / "episodes" / EID
         / "shot_staging")
    d.mkdir(parents=True)
    (d / "manifest.json").write_text(json.dumps({"data": {"shots": [
        {"scene_index": 1, "shot_index": 1, "pov_character": "",
         "character_angles": [{"character": "SAMPLE GHOST"},
                              {"character": "SAMPLE LEAD"}]}]}}),
        encoding="utf-8")
    got = collect_reference_required_canon_ids(db, PID, EID)
    assert "E4" not in got
    assert got["E3"] == "selected shot staging"
