"""SceneStillNormalizer 단위 테스트 — W4 P3-1b.

순수 로직 테스트 — DB 없음. 3가지 경로(legacy/v4/unselected) + edge case 커버.
"""
from __future__ import annotations

import json

import pytest


@pytest.fixture
def maps():
    from app.services.checkpoint_sync._scene_still_contracts import EntityMaps

    return EntityMaps(
        name_to_id={"민숙": "id-c01", "사무실": "id-l01"},
        name_to_short={"민숙": "C01", "사무실": "L01"},
        short_to_id={"C01": "id-c01", "L01": "id-l01"},
        short_to_name={"C01": "민숙", "L01": "사무실"},
    )


@pytest.fixture
def normalizer(maps):
    from app.services.checkpoint_sync.scene_still_normalizer import SceneStillNormalizer

    return SceneStillNormalizer(maps)


def _make_bundle(**overrides):
    from app.services.checkpoint_sync._scene_still_contracts import CheckpointBundle

    b = CheckpointBundle()
    for k, v in overrides.items():
        setattr(b, k, v)
    return b


# ── 빈/비완료 상태 ──


def test_sd_not_completed_returns_empty(normalizer):
    """scene_detail 미완료면 아무것도 생성하지 않음 (baseline)."""
    bundle = _make_bundle(sd_completed=False, scenes=[{"scene_index": 1}])
    assert normalizer.normalize(bundle) == []


def test_empty_scenes_empty_output(normalizer):
    bundle = _make_bundle(sd_completed=True, scenes=[])
    assert normalizer.normalize(bundle) == []


# ── Legacy 경로 (selected_shots 없음) ──


def test_legacy_one_still_per_scene(normalizer):
    """selected shots 정보 없으면 scene당 1 still, scene_extractor_v3."""
    bundle = _make_bundle(
        sd_completed=True,
        scenes=[{
            "scene_index": 1,
            "heading": "S#1",
            "representative_moment": "대표 순간",
            "t2i_prompt": "prompt",
            "t2i_variations": [{"t2i_prompt": "v1"}],
            "visible_entities": ["C01"],
            "scene_type": "normal",
        }],
        scene_director_audio={1: ["C02"]},
        scene_director_hall={1: ["C03"]},
    )
    planned = normalizer.normalize(bundle)
    assert len(planned) == 1
    p = planned[0]
    assert p.key == (1, None)
    assert p.still_index == 1
    assert p.scene_index == 1
    assert p.t2i_composer_version == "scene_extractor_v3"
    assert p.columns["still_frame_prompt"] == "대표 순간"
    assert p.columns["audio_entity_ids"] == json.dumps(["C02"])
    assert p.columns["hallucination_entity_ids"] == json.dumps(["C03"])
    # visible_entities backfill — string → dict with id/name
    ve = json.loads(p.columns["visible_entities_json"])
    assert ve[0]["short_id"] == "C01"
    assert ve[0]["entity_name"] == "민숙"


def test_legacy_no_t2i_variations_writes_null(normalizer):
    bundle = _make_bundle(
        sd_completed=True,
        scenes=[{"scene_index": 1, "t2i_variations": []}],
    )
    planned = normalizer.normalize(bundle)
    assert planned[0].columns["t2i_variations_json"] is None


# ── V4 per-shot 경로 ──


def test_v4_per_shot_stills(normalizer):
    """2 selected shots → 2 stills, shot_director_ve_map 사용."""
    bundle = _make_bundle(
        sd_completed=True,
        scenes=[{
            "scene_index": 1,
            "t2i_variations": [
                {"t2i_prompt": "v1"}, {"t2i_prompt": "v2"},
                {"t2i_prompt": "v3"}, {"t2i_prompt": "v4"},
            ],
        }],
        shot_info_by_scene={1: [
            {"shot_index": 1, "description": "shot 1"},
            {"shot_index": 2, "description": "shot 2"},
        ]},
        selected_flag_by_scene={1: {1, 2}},
        shot_director_ve_map={(1, 1): ["C01"], (1, 2): ["L01"]},
    )
    planned = normalizer.normalize(bundle)
    assert len(planned) == 2
    assert planned[0].key == (1, 1)
    assert planned[0].t2i_composer_version == "scene_extractor_v4"
    assert planned[0].columns["is_selected"] is True
    assert planned[0].columns["shot_index"] == 1
    assert planned[0].columns["shot_description"] == "shot 1"
    # 4 vars / 2 shots = 2 vars each
    v0 = json.loads(planned[0].columns["t2i_variations_json"])
    v1 = json.loads(planned[1].columns["t2i_variations_json"])
    assert len(v0) == 2 and len(v1) == 2
    # shot_director_ve_map 기반 VE
    ve0 = json.loads(planned[0].columns["visible_entities_json"])
    assert ve0[0]["short_id"] == "C01"
    assert ve0[0]["entity_name"] == "민숙"


def test_v4_result_shot_idx_filters_to_single_shot(normalizer):
    """_shot_index가 설정되면 해당 shot만 생성 (partial sync)."""
    bundle = _make_bundle(
        sd_completed=True,
        scenes=[{
            "scene_index": 1,
            "_shot_index": 2,
            "t2i_variations": [{"t2i_prompt": "v1"}],
        }],
        shot_info_by_scene={1: [
            {"shot_index": 1, "description": "shot 1"},
            {"shot_index": 2, "description": "shot 2"},
        ]},
        selected_flag_by_scene={1: {1, 2}},
    )
    planned = normalizer.normalize(bundle)
    # partial: only shot 2 from scene loop + shot 1 is still "selected" so NOT in unselected
    assert len(planned) == 1
    assert planned[0].key == (1, 2)


def test_v4_result_shot_idx_not_in_selected_skipped(normalizer):
    """_shot_index가 selected에 없으면 해당 scene 스킵."""
    bundle = _make_bundle(
        sd_completed=True,
        scenes=[{"scene_index": 1, "_shot_index": 99, "t2i_variations": [{"t2i_prompt": "v"}]}],
        shot_info_by_scene={1: [{"shot_index": 1}]},
        selected_flag_by_scene={1: {1}},
    )
    planned = normalizer.normalize(bundle)
    assert planned == []


def test_v4_user_edited_uses_representative_moment(normalizer):
    """_user_edited=True면 shot.description 대신 representative_moment 사용."""
    bundle = _make_bundle(
        sd_completed=True,
        scenes=[{
            "scene_index": 1,
            "_user_edited": True,
            "representative_moment": "유저 편집본",
            "t2i_variations": [{"t2i_prompt": "v"}],
        }],
        shot_info_by_scene={1: [{"shot_index": 1, "description": "자동 description"}]},
        selected_flag_by_scene={1: {1}},
    )
    planned = normalizer.normalize(bundle)
    assert planned[0].columns["still_frame_prompt"] == "유저 편집본"


def test_v4_fallback_ve_from_t2i_text(normalizer):
    """shot_director_ve_map 없으면 t2i_prompt 텍스트에서 [CLP]ID 추출."""
    bundle = _make_bundle(
        sd_completed=True,
        scenes=[{
            "scene_index": 1,
            "t2i_variations": [{"t2i_prompt": "C01 and L01 in a room"}],
        }],
        shot_info_by_scene={1: [{"shot_index": 1}]},
        selected_flag_by_scene={1: {1}},
        scene_director_ve={1: ["C01", "L01", "P01"]},
        # shot_director_ve_map intentionally empty
    )
    planned = normalizer.normalize(bundle)
    ve = json.loads(planned[0].columns["visible_entities_json"])
    short_ids = {v["short_id"] for v in ve}
    assert short_ids == {"C01", "L01"}  # P01 not in t2i text


def test_v4_fallback_ve_from_outfit_assignments(normalizer):
    """outfit_assignments.character_id도 used_ids에 포함."""
    bundle = _make_bundle(
        sd_completed=True,
        scenes=[{
            "scene_index": 1,
            "t2i_variations": [{
                "t2i_prompt": "scene text",
                "outfit_assignments": [{"character_id": "C01"}],
            }],
        }],
        shot_info_by_scene={1: [{"shot_index": 1}]},
        selected_flag_by_scene={1: {1}},
        scene_director_ve={1: ["C01", "C02"]},
    )
    planned = normalizer.normalize(bundle)
    ve = json.loads(planned[0].columns["visible_entities_json"])
    assert {v["short_id"] for v in ve} == {"C01"}


# ── Unselected 경로 ──


def test_unselected_creates_row_for_unselected_shots(normalizer):
    """selected가 {1}일 때 shot 2/3는 shot_more_unselected로 생성."""
    bundle = _make_bundle(
        sd_completed=True,
        scenes=[{"scene_index": 1, "t2i_variations": [{"t2i_prompt": "v"}]}],
        shot_info_by_scene={1: [
            {"shot_index": 1, "description": "s1"},
            {"shot_index": 2, "description": "s2"},
            {"shot_index": 3, "description": "s3"},
        ]},
        selected_flag_by_scene={1: {1}},
    )
    planned = normalizer.normalize(bundle)
    assert len(planned) == 3
    selected = [p for p in planned if p.columns.get("is_selected") is True]
    unselected = [p for p in planned if p.columns.get("is_selected") is False]
    assert len(selected) == 1
    assert len(unselected) == 2
    assert all(p.t2i_composer_version == "shot_more_unselected" for p in unselected)
    assert {p.key for p in unselected} == {(1, 2), (1, 3)}


def test_unselected_skipped_when_sd_not_completed(normalizer):
    """sd_completed=False면 unselected도 처리 안 함 (baseline 동작)."""
    bundle = _make_bundle(
        sd_completed=False,
        shot_info_by_scene={1: [{"shot_index": 1}]},
        selected_flag_by_scene={1: set()},
    )
    assert normalizer.normalize(bundle) == []


# ── 누적 still_index ──


def test_legacy_ve_id_only_set_when_matched(maps):
    """Codex P3-1 Medium: name→id 매핑 실패 시 id를 빈 문자열로 덮지 않음 (baseline parity)."""
    from app.services.checkpoint_sync.scene_still_normalizer import SceneStillNormalizer
    import json as _json

    n = SceneStillNormalizer(maps)
    bundle = _make_bundle(
        sd_completed=True,
        scenes=[{
            "scene_index": 1,
            "visible_entities": [{"entity_name": "미지의인물"}],  # maps에 없음
            "t2i_variations": [{"t2i_prompt": "v"}],
        }],
    )
    planned = n.normalize(bundle)
    ve = _json.loads(planned[0].columns["visible_entities_json"])
    # baseline parity: 매핑 실패 시 id 키를 생성하지 않음 (빈 문자열도 주입 금지).
    assert "id" not in ve[0], f"id 키가 생성됨: {ve[0]}"
    assert ve[0].get("entity_name") == "미지의인물"


def test_still_index_monotonic(normalizer):
    """전체 planned에 대해 still_index는 1부터 순차 증가."""
    bundle = _make_bundle(
        sd_completed=True,
        scenes=[
            {"scene_index": 1, "t2i_variations": [{"t2i_prompt": "v"}]},
            {"scene_index": 2, "t2i_variations": [{"t2i_prompt": "v"}]},
        ],
        shot_info_by_scene={
            1: [{"shot_index": 1}, {"shot_index": 2}],
            2: [{"shot_index": 1}],
        },
        selected_flag_by_scene={1: {1}, 2: {1}},
    )
    planned = normalizer.normalize(bundle)
    indices = [p.still_index for p in planned]
    assert indices == list(range(1, len(indices) + 1))
