"""image_service_helpers 단위 테스트 — Phase 3b.2 shared helper.

검증 대상:
- image_to_dict: ImageAsset 전 필드 포함
- get_latest_world_guide: 최신 WorldGuide JSON 반환, 없으면 빈 dict
- auto_set_primary: composite/non-composite 분리 + still_id/entity_id 경쟁
- build_lineage_fields: code_version + reference_image_ids 생성
"""
from __future__ import annotations

import json
from unittest.mock import MagicMock

import pytest

from app.services.image_service_helpers import (
    auto_set_primary,
    build_lineage_fields,
    fill_missing_t2i_prompts,
    get_latest_world_guide,
    image_to_dict,
    populate_t2i_prompts,
)


# ──────────────────────────────────────────────────────────────────────
# populate_t2i_prompts — W5 F22 Phase B.20
# ──────────────────────────────────────────────────────────────────────


def _make_still_orm(cinematic="", closeup="", variations_json=None):
    orm = MagicMock()
    orm.t2i_prompt_cinematic = cinematic
    orm.t2i_prompt_closeup = closeup
    orm.t2i_variations_json = variations_json
    return orm


def test_populate_t2i_prompts_all_from_orm():
    """모든 still이 ORM에 cinematic/closeup/variations 보유 → converter 호출 없음."""
    db = MagicMock()
    stills = [{"id": "s1"}, {"id": "s2"}]
    stills_orm = [
        _make_still_orm("CIN_1", "CLO_1", '[{"t2i_prompt":"V1"}]'),
        _make_still_orm("CIN_2", "CLO_2", '[{"t2i_prompt":"V2"}]'),
    ]
    populate_t2i_prompts(db, stills, stills_orm, entities=[], openai_client=MagicMock())

    assert stills[0]["t2i_prompt_cinematic"] == "CIN_1"
    assert stills[0]["t2i_prompt_closeup"] == "CLO_1"
    assert stills[0]["t2i_variations"] == [{"t2i_prompt": "V1"}]
    assert stills[1]["t2i_prompt_cinematic"] == "CIN_2"
    db.commit.assert_not_called()


def test_populate_t2i_prompts_invalid_json_becomes_empty_list():
    """t2i_variations_json이 깨져 있으면 빈 리스트로 대체, converter 호출 안 함."""
    db = MagicMock()
    stills = [{"id": "s1"}]
    stills_orm = [_make_still_orm("CIN", "CLO", "not json {")]
    populate_t2i_prompts(db, stills, stills_orm, entities=[], openai_client=MagicMock())
    assert stills[0]["t2i_variations"] == []
    db.commit.assert_not_called()


def test_populate_t2i_prompts_missing_but_has_variations_no_converter(monkeypatch):
    """cinematic 없지만 variations 있으면 converter 호출 안 함."""
    import app.modules.t2i_visual_converter as tvc_mod

    converter_called = {"n": 0}

    class _FakeConverter:
        def __init__(self, llm_client):
            converter_called["n"] += 1
        def convert_scenes(self, *a, **k):
            return {}

    monkeypatch.setattr(tvc_mod, "T2IVisualConverter", _FakeConverter)

    db = MagicMock()
    stills = [{"id": "s1"}]
    stills_orm = [_make_still_orm("", "", '[{"t2i_prompt":"VAR"}]')]
    populate_t2i_prompts(db, stills, stills_orm, entities=[], openai_client=MagicMock())

    assert converter_called["n"] == 0
    db.commit.assert_not_called()
    # variations는 로드되었지만 cinematic/closeup 업데이트 없음 (fill_missing이 caller에서)
    assert stills[0]["t2i_variations"] == [{"t2i_prompt": "VAR"}]
    assert stills[0]["t2i_prompt_cinematic"] == ""


def test_populate_t2i_prompts_runtime_fallback_dict_result(monkeypatch):
    """cinematic + variations 모두 없으면 converter 호출 → dict 결과 반영 + commit."""
    import app.modules.t2i_visual_converter as tvc_mod

    recorded = {}

    class _FakeConverter:
        def __init__(self, llm_client):
            recorded["client"] = llm_client
        def convert_scenes(self, scene_dicts, entity_t2i_map):
            recorded["scene_dicts"] = scene_dicts
            recorded["entity_map"] = entity_t2i_map
            return {"s1": {"a": "A_PROMPT", "b": "B_PROMPT"}}

    monkeypatch.setattr(tvc_mod, "T2IVisualConverter", _FakeConverter)

    db = MagicMock()
    stills = [
        {"id": "s1", "still_frame_prompt": "FRAME_1",
         "screenplay_scene_heading": "INT. ROOM",
         "visible_entities_json": '["e1"]'},
    ]
    stills_orm = [_make_still_orm("", "", None)]
    entities = [{"id": "e1", "t2i_prompt": "ENT_PROMPT"}]
    client = MagicMock()
    populate_t2i_prompts(db, stills, stills_orm, entities, openai_client=client)

    assert recorded["client"] is client
    assert recorded["entity_map"] == {"e1": "ENT_PROMPT"}
    assert recorded["scene_dicts"][0]["id"] == "s1"
    assert recorded["scene_dicts"][0]["still_frame_prompt"] == "FRAME_1"
    assert stills[0]["t2i_prompt_cinematic"] == "A_PROMPT"
    assert stills[0]["t2i_prompt_closeup"] == "B_PROMPT"
    db.commit.assert_called_once()


def test_populate_t2i_prompts_runtime_fallback_str_result(monkeypatch):
    """converter가 str 반환 시 cinematic만 채움."""
    import app.modules.t2i_visual_converter as tvc_mod

    class _FakeConverter:
        def __init__(self, llm_client):
            pass
        def convert_scenes(self, scene_dicts, entity_t2i_map):
            return {"s1": "PLAIN_STR_PROMPT"}

    monkeypatch.setattr(tvc_mod, "T2IVisualConverter", _FakeConverter)

    db = MagicMock()
    stills = [{"id": "s1"}]
    stills_orm = [_make_still_orm("", "", None)]
    populate_t2i_prompts(db, stills, stills_orm, entities=[], openai_client=MagicMock())

    assert stills[0]["t2i_prompt_cinematic"] == "PLAIN_STR_PROMPT"
    assert stills[0]["t2i_prompt_closeup"] == ""  # 그대로
    db.commit.assert_called_once()


def test_populate_t2i_prompts_runtime_skip_missing_scene(monkeypatch):
    """converter가 특정 still의 결과를 빠뜨리면 해당 still은 변화 없음."""
    import app.modules.t2i_visual_converter as tvc_mod

    class _FakeConverter:
        def __init__(self, llm_client):
            pass
        def convert_scenes(self, scene_dicts, entity_t2i_map):
            return {}  # 아무 결과도 없음

    monkeypatch.setattr(tvc_mod, "T2IVisualConverter", _FakeConverter)

    db = MagicMock()
    stills = [{"id": "s1"}]
    stills_orm = [_make_still_orm("", "", None)]
    populate_t2i_prompts(db, stills, stills_orm, entities=[], openai_client=MagicMock())

    assert stills[0]["t2i_prompt_cinematic"] == ""
    db.commit.assert_called_once()  # 결과 비어도 commit은 발생 (기존 동작 보존)


# ──────────────────────────────────────────────────────────────────────
# fill_missing_t2i_prompts — W5 F22 Phase B.18
# ──────────────────────────────────────────────────────────────────────


def test_fill_missing_t2i_prompts_populates_from_variations():
    """t2i_variations 리스트가 있으면 첫 2개로 cinematic + closeup 채움."""
    stills = [{
        "t2i_prompt_cinematic": "",
        "t2i_variations": [
            {"t2i_prompt": "VAR_A"},
            {"t2i_prompt": "VAR_B"},
        ],
        "still_frame_prompt": "FRAME",
    }]
    fill_missing_t2i_prompts(stills)
    assert stills[0]["t2i_prompt_cinematic"] == "VAR_A"
    assert stills[0]["t2i_prompt_closeup"] == "VAR_B"


def test_fill_missing_t2i_prompts_falls_back_to_still_frame_prompt():
    """variations 없고 cinematic 없으면 still_frame_prompt 사용."""
    stills = [{
        "t2i_prompt_cinematic": "",
        "t2i_variations": [],
        "still_frame_prompt": "FRAME",
    }]
    fill_missing_t2i_prompts(stills)
    assert stills[0]["t2i_prompt_cinematic"] == "FRAME"
    assert stills[0]["t2i_prompt_closeup"] == "FRAME"


def test_fill_missing_t2i_prompts_preserves_existing_cinematic():
    """이미 cinematic 있으면 유지, closeup만 fallback."""
    stills = [{
        "t2i_prompt_cinematic": "EXISTING",
        "t2i_variations": [{"t2i_prompt": "VAR"}],
        "still_frame_prompt": "FRAME",
    }]
    fill_missing_t2i_prompts(stills)
    assert stills[0]["t2i_prompt_cinematic"] == "EXISTING"
    # closeup은 still_frame_prompt fallback (variations는 1개뿐)
    assert stills[0]["t2i_prompt_closeup"] == "FRAME"


def test_fill_missing_t2i_prompts_single_variation_no_closeup_from_vars():
    """variations가 1개면 cinematic만 채우고 closeup은 still_frame_prompt."""
    stills = [{
        "t2i_prompt_cinematic": "",
        "t2i_variations": [{"t2i_prompt": "ONLY_VAR"}],
        "still_frame_prompt": "FRAME",
    }]
    fill_missing_t2i_prompts(stills)
    assert stills[0]["t2i_prompt_cinematic"] == "ONLY_VAR"
    assert stills[0]["t2i_prompt_closeup"] == "FRAME"


# ──────────────────────────────────────────────────────────────────────
# image_to_dict
# ──────────────────────────────────────────────────────────────────────


def _make_image_asset():
    img = MagicMock()
    img.id = "img_1"
    img.asset_type = "reference"
    img.entity_id = "ent_1"
    img.still_id = None
    img.episode_id = "ep_1"
    img.file_path = "/path/to/img.png"
    img.prompt_used = "prompt"
    img.generation_model = "gemini"
    img.width = 1024
    img.height = 1024
    img.status = "generated"
    img.review_notes = "notes"
    img.validation_score = 85
    img.validation_result = '{"score":85}'
    img.sanitization_strategy = None
    img.original_prompt = None
    img.sanitization_note = None
    img.variant_type = None
    img.angle_applied = None
    img.color_applied = None
    img.source_image_id = None
    img.is_primary = 1
    img.prompt_type = None
    img.code_version = "1.0.0"
    img.prompt_file_version = "2.20260101"
    img.reference_image_ids = '["ref1"]'
    img.theme_label = None
    img.created_at = "2026-04-18T00:00:00Z"
    return img


def test_image_to_dict_includes_all_fields():
    img = _make_image_asset()
    d = image_to_dict(img)
    assert d["id"] == "img_1"
    assert d["file_path"] == "/path/to/img.png"
    assert d["is_primary"] is True  # bool 변환
    assert d["validation_score"] == 85
    assert d["reference_image_ids"] == '["ref1"]'


def test_image_to_dict_null_review_notes_becomes_empty_string():
    img = _make_image_asset()
    img.review_notes = None
    d = image_to_dict(img)
    assert d["review_notes"] == ""


def test_image_to_dict_null_reference_image_ids_becomes_empty_list():
    img = _make_image_asset()
    img.reference_image_ids = None
    d = image_to_dict(img)
    assert d["reference_image_ids"] == "[]"


# ──────────────────────────────────────────────────────────────────────
# get_latest_world_guide
# ──────────────────────────────────────────────────────────────────────


def test_get_latest_world_guide_parses_json():
    wg = MagicMock()
    wg.guide_json = '{"era": "Joseon", "region": "Korea"}'

    db = MagicMock()
    db.query.return_value.filter.return_value.order_by.return_value.first.return_value = wg

    result = get_latest_world_guide(db, "proj1", "ep1")
    assert result == {"era": "Joseon", "region": "Korea"}


def test_get_latest_world_guide_returns_empty_when_none():
    db = MagicMock()
    db.query.return_value.filter.return_value.order_by.return_value.first.return_value = None

    assert get_latest_world_guide(db, "proj1", "ep1") == {}


def test_get_latest_world_guide_handles_invalid_json():
    wg = MagicMock()
    wg.guide_json = "not valid json {"

    db = MagicMock()
    db.query.return_value.filter.return_value.order_by.return_value.first.return_value = wg

    assert get_latest_world_guide(db, "proj1", "ep1") == {}


# ──────────────────────────────────────────────────────────────────────
# auto_set_primary
# ──────────────────────────────────────────────────────────────────────


def test_auto_set_primary_non_composite_entity():
    """비-composite 이미지: 같은 entity의 비-composite siblings의 is_primary만 0으로."""
    new_img = MagicMock()
    new_img.id = "new1"
    new_img.entity_id = "ent1"
    new_img.still_id = None
    new_img.prompt_used = "plain prompt"

    sibling_non = MagicMock(id="sib_non", is_primary=1)
    sibling_non.prompt_used = "other plain"
    sibling_comp = MagicMock(id="sib_comp", is_primary=1)
    sibling_comp.prompt_used = "[composite:c1:o1] desc"

    db = MagicMock()
    db.query.return_value.filter.return_value.all.return_value = [sibling_non, sibling_comp]

    auto_set_primary(db, "p1", new_img)

    # 비-composite 신규 → 비-composite 경쟁만 0으로
    assert sibling_non.is_primary == 0
    # composite sibling은 건드리지 않음
    assert sibling_comp.is_primary == 1
    # 새 이미지는 primary
    assert new_img.is_primary == 1


def test_auto_set_primary_composite_entity():
    """composite 이미지: 같은 entity의 composite siblings의 is_primary만 0으로."""
    new_img = MagicMock()
    new_img.id = "new1"
    new_img.entity_id = "char1"
    new_img.still_id = None
    new_img.prompt_used = "[composite:char1:o1] xxx"

    sibling_non = MagicMock(id="sib_non", is_primary=1)
    sibling_non.prompt_used = "plain desc"
    sibling_comp = MagicMock(id="sib_comp", is_primary=1)
    sibling_comp.prompt_used = "[composite:char1:o2] yyy"

    db = MagicMock()
    db.query.return_value.filter.return_value.all.return_value = [sibling_non, sibling_comp]

    auto_set_primary(db, "p1", new_img)

    # composite 신규 → composite 경쟁만 0으로
    assert sibling_non.is_primary == 1
    assert sibling_comp.is_primary == 0
    assert new_img.is_primary == 1


def test_auto_set_primary_still_id_always_competes():
    """still_id가 있으면 모든 형제의 is_primary 0 (composite 구분 없음)."""
    new_img = MagicMock()
    new_img.id = "new1"
    new_img.entity_id = None
    new_img.still_id = "still_1"
    new_img.prompt_used = "scene prompt"

    sibling = MagicMock(id="sib", is_primary=1)
    sibling.prompt_used = "old scene"

    db = MagicMock()
    db.query.return_value.filter.return_value.all.return_value = [sibling]

    auto_set_primary(db, "p1", new_img)

    assert sibling.is_primary == 0
    assert new_img.is_primary == 1


# ──────────────────────────────────────────────────────────────────────
# build_lineage_fields
# ──────────────────────────────────────────────────────────────────────


def test_build_lineage_fields_no_refs():
    db = MagicMock()
    result = build_lineage_fields(db, "p1", "ref_image_pipeline")
    assert "code_version" in result
    assert result["reference_image_ids"] == "[]"
    assert result["prompt_type"] is None


def test_build_lineage_fields_with_refs(monkeypatch):
    """ref_entity_ids 주어지면 각 entity의 primary ref 이미지 id 수집."""
    ref1 = MagicMock(id="img_a")
    ref2 = MagicMock(id="img_b")

    db = MagicMock()
    first_call_count = {"n": 0}

    def _filter_side_effect(*args, **kwargs):
        # 첫 호출 → ref1 (ent_x primary), 두 번째 호출 → ref2 (ent_y primary)
        q = MagicMock()
        first_call_count["n"] += 1
        if first_call_count["n"] == 1:
            q.first.return_value = ref1
        elif first_call_count["n"] == 2:
            q.first.return_value = ref2
        else:
            q.first.return_value = None
        return q

    db.query.return_value.filter.side_effect = _filter_side_effect

    result = build_lineage_fields(
        db, "p1", "ref_image_pipeline",
        ref_entity_ids=["ent_x", "ent_y"],
        prompt_type="scene",
    )
    ref_ids = json.loads(result["reference_image_ids"])
    assert ref_ids == ["img_a", "img_b"]
    assert result["prompt_type"] == "scene"


def test_build_lineage_fields_fallback_to_non_primary():
    """primary가 없으면 최신 reference로 fallback."""
    fallback_img = MagicMock(id="fallback_id")

    db = MagicMock()
    call_count = {"n": 0}

    def _filter_side_effect(*args, **kwargs):
        q = MagicMock()
        call_count["n"] += 1
        if call_count["n"] == 1:
            # primary 조회 → None
            q.first.return_value = None
            return q
        elif call_count["n"] == 2:
            # fallback 조회 → order_by(...).first()
            q.order_by.return_value.first.return_value = fallback_img
            return q
        return q

    db.query.return_value.filter.side_effect = _filter_side_effect

    result = build_lineage_fields(
        db, "p1", "ref_image_pipeline", ref_entity_ids=["ent_x"],
    )
    assert json.loads(result["reference_image_ids"]) == ["fallback_id"]
