"""ImageComposerService 테스트 (W5 F23 Phase 3).

image_service.py의 T2I Composer + composer prompt 3 메서드를 ImageComposerService로 이관.
"""
from __future__ import annotations

import json
from types import SimpleNamespace
from unittest.mock import MagicMock

import pytest

from app.core.errors import AppError
from app.services.image_composer_service import ImageComposerService


@pytest.fixture
def svc():
    """ImageComposerService MagicMock 주입 인스턴스."""
    instance = ImageComposerService.__new__(ImageComposerService)
    instance._db = MagicMock()
    instance._project_id = "p1"
    instance._actor_id = "u1"
    instance._logger = MagicMock()
    return instance


# ── _get_visible_entities ──────────────────────────────────────


def test_get_visible_entities_empty_returns_empty(svc):
    """None/빈 문자열은 빈 리스트."""
    assert svc._get_visible_entities(None) == []
    assert svc._get_visible_entities("") == []


def test_get_visible_entities_invalid_json_returns_empty(svc):
    """JSON 파싱 실패 시 빈 리스트."""
    assert svc._get_visible_entities("{not-json") == []


def test_get_visible_entities_dict_form(svc):
    """dict 형식 {id}/{entity_id} 모두 파싱."""
    entity1 = SimpleNamespace(
        id="e1", name="N1", entity_type="character",
        short_id="C01", description="d1", stable_traits='{"k":"v"}',
    )
    entity2 = SimpleNamespace(
        id="e2", name="N2", entity_type="prop",
        short_id="P02", description=None, stable_traits=None,
    )
    query_call = [0]

    def _query(_model):
        query_call[0] += 1
        q = MagicMock()
        if query_call[0] == 1:
            q.filter.return_value.first.return_value = entity1
        else:
            q.filter.return_value.first.return_value = entity2
        return q

    svc._db.query.side_effect = _query
    ve_json = json.dumps([{"id": "e1"}, {"entity_id": "e2"}])
    result = svc._get_visible_entities(ve_json)
    assert len(result) == 2
    assert result[0]["id"] == "e1"
    assert result[0]["name"] == "N1"
    assert result[0]["short_id"] == "C01"
    assert result[0]["stable_traits"] == '{"k":"v"}'
    assert result[1]["short_id"] == "P02"
    assert result[1]["description"] == ""
    assert result[1]["stable_traits"] == "{}"


def test_get_visible_entities_string_form(svc):
    """순수 문자열 id 리스트 파싱."""
    entity = SimpleNamespace(
        id="e1", name="N", entity_type="character",
        short_id="C01", description=None, stable_traits=None,
    )
    svc._db.query.return_value.filter.return_value.first.return_value = entity
    result = svc._get_visible_entities(json.dumps(["e1"]))
    assert len(result) == 1
    assert result[0]["id"] == "e1"


def test_get_visible_entities_skips_missing_and_non_string_dict(svc):
    """entity 없으면 스킵, dict/str 외 원소 스킵."""
    svc._db.query.return_value.filter.return_value.first.return_value = None
    result = svc._get_visible_entities(json.dumps([{"id": "e1"}, 42, {"id": "e2"}]))
    assert result == []


# ── compose_prompts ────────────────────────────────────────────


def test_compose_prompts_no_openai_key_raises(svc, monkeypatch):
    """openai_api_key 없으면 AppError(400)."""
    monkeypatch.setattr("app.services.image_composer_service.settings", SimpleNamespace(openai_api_key=None))
    # [2026-08-01] 키 유무의 권위가 브로커로 옮겼다 — 모듈 지역 settings 만
    # 비우면 보조 슬롯이 살아 있어 '있음'이 된다. 브로커의 시야를 비운다.
    monkeypatch.delenv("OPENAI_API_KEY", raising=False)
    monkeypatch.setattr(
        "app.core.config.settings.openai_api_key", "", raising=False)
    monkeypatch.setattr(
        "app.core.config.settings.openai_api_key_secondary", "",
        raising=False)
    with pytest.raises(AppError) as exc_info:
        svc.compose_prompts("st1")
    assert exc_info.value.code == "image.openai_key_missing"
    assert exc_info.value.status_code == 400


def test_compose_prompts_still_not_found_raises(svc, monkeypatch):
    """still 없으면 AppError(404)."""
    monkeypatch.setattr("app.services.image_composer_service.settings", SimpleNamespace(openai_api_key="sk-x"))
    svc._db.query.return_value.filter.return_value.first.return_value = None
    with pytest.raises(AppError) as exc_info:
        svc.compose_prompts("st1")
    assert exc_info.value.code == "still.not_found"
    assert exc_info.value.status_code == 404


def test_compose_prompts_happy_path(svc, monkeypatch):
    """compose_prompts 성공: T2IPromptComposer 호출 + 저장 + 반환."""
    mock_still = SimpleNamespace(
        id="st1",
        visible_entities_json="[]",
        camera_json="{}",
        lighting_json="{}",
        still_frame_prompt="a cinematic shot",
        episode_id="ep1",
        t2i_prompt_cinematic=None,
        t2i_prompt_closeup=None,
        t2i_composer_version=None,
    )
    mock_proj_settings = SimpleNamespace(
        composer_system_prompt="sys",
        composer_user_prompt="user",
    )
    query_call = [0]

    def _query(_model):
        query_call[0] += 1
        q = MagicMock()
        if query_call[0] == 1:
            q.filter.return_value.first.return_value = mock_still
        else:
            q.filter.return_value.first.return_value = mock_proj_settings
        return q

    svc._db.query.side_effect = _query

    monkeypatch.setattr("app.services.image_composer_service.settings", SimpleNamespace(openai_api_key="sk-x"))
    monkeypatch.setattr(
        "app.services.image_composer_service.load_project_llm_config",
        lambda db, pid: {"model": "x"},
    )
    monkeypatch.setattr(
        "app.services.image_composer_service.get_latest_world_guide",
        lambda db, pid, ep: {"era": "Joseon"},
    )
    mock_composer_cls = MagicMock()
    mock_composer_cls.return_value.compose.return_value = {
        "cinematic": "cinematic prompt",
        "closeup": "closeup prompt",
        "composer_version": "v1",
        "key_visual_elements": ["kv1"],
        "removed_narrative_elements": ["rm1"],
    }
    monkeypatch.setattr("app.services.image_composer_service.T2IPromptComposer", mock_composer_cls)

    result = svc.compose_prompts("st1", ip="1.2.3.4")

    assert result == {
        "still_id": "st1",
        "t2i_prompt_cinematic": "cinematic prompt",
        "t2i_prompt_closeup": "closeup prompt",
        "composer_version": "v1",
        "key_visual_elements": ["kv1"],
        "removed_narrative_elements": ["rm1"],
    }
    assert mock_still.t2i_prompt_cinematic == "cinematic prompt"
    assert mock_still.t2i_prompt_closeup == "closeup prompt"
    assert mock_still.t2i_composer_version == "v1"
    svc._db.commit.assert_called_once()
    mock_composer_cls.assert_called_once_with(
        project_llm_config={"model": "x"},
        prompt_version="v1",
        system_prompt_override="sys",
        user_prompt_override="user",
    )
    log_kwargs = svc._logger.log.call_args.kwargs
    assert log_kwargs["action"] == "still.compose_prompts"
    assert log_kwargs["ip_address"] == "1.2.3.4"


def test_compose_prompts_handles_bad_camera_lighting_json(svc, monkeypatch):
    """camera_json/lighting_json JSON 실패 시 빈 dict로 폴백."""
    mock_still = SimpleNamespace(
        id="st1",
        visible_entities_json=None,
        camera_json="{bad",
        lighting_json="also-bad",
        still_frame_prompt="",
        episode_id="ep1",
        t2i_prompt_cinematic=None,
        t2i_prompt_closeup=None,
        t2i_composer_version=None,
    )
    query_call = [0]

    def _query(_model):
        query_call[0] += 1
        q = MagicMock()
        if query_call[0] == 1:
            q.filter.return_value.first.return_value = mock_still
        else:
            q.filter.return_value.first.return_value = None
        return q

    svc._db.query.side_effect = _query
    monkeypatch.setattr("app.services.image_composer_service.settings", SimpleNamespace(openai_api_key="sk-x"))
    monkeypatch.setattr("app.services.image_composer_service.load_project_llm_config", lambda *_: {})
    monkeypatch.setattr("app.services.image_composer_service.get_latest_world_guide", lambda *_: {})
    mock_composer_cls = MagicMock()
    mock_composer_cls.return_value.compose.return_value = {
        "cinematic": "c", "closeup": "cl", "composer_version": "v1",
    }
    monkeypatch.setattr("app.services.image_composer_service.T2IPromptComposer", mock_composer_cls)
    svc.compose_prompts("st1")
    compose_kwargs = mock_composer_cls.return_value.compose.call_args.kwargs
    assert compose_kwargs["camera_json"] == {}
    assert compose_kwargs["lighting_json"] == {}


# ── get_composer_prompt / update_composer_prompt ──────────────


def test_get_composer_prompt_no_settings_returns_nones(svc):
    """ProjectSettings 없으면 None 쌍 반환."""
    svc._db.query.return_value.filter.return_value.first.return_value = None
    result = svc.get_composer_prompt()
    assert result == {"composer_system_prompt": None, "composer_user_prompt": None}


def test_get_composer_prompt_with_settings(svc):
    """ProjectSettings 값 반환."""
    ps = SimpleNamespace(composer_system_prompt="sys", composer_user_prompt="user")
    svc._db.query.return_value.filter.return_value.first.return_value = ps
    result = svc.get_composer_prompt()
    assert result == {"composer_system_prompt": "sys", "composer_user_prompt": "user"}


def test_update_composer_prompt_creates_settings_if_absent(svc, monkeypatch):
    """ProjectSettings 없으면 신규 생성 + commit + log."""
    svc._db.query.return_value.filter.return_value.first.return_value = None
    monkeypatch.setattr("app.services.image_composer_service._new_id", lambda: "new-id")
    monkeypatch.setattr("app.services.image_composer_service._now", lambda: "2026-01-01T00:00:00Z")

    result = svc.update_composer_prompt(system_prompt="sys", user_prompt="user", ip="1.2.3.4")
    added = svc._db.add.call_args[0][0]
    assert added.composer_system_prompt == "sys"
    assert added.composer_user_prompt == "user"
    assert result == {"composer_system_prompt": "sys", "composer_user_prompt": "user"}
    svc._db.commit.assert_called_once()
    log_kwargs = svc._logger.log.call_args.kwargs
    assert log_kwargs["action"] == "project.update_composer_prompt"
    assert log_kwargs["ip_address"] == "1.2.3.4"


def test_update_composer_prompt_partial_overwrites(svc, monkeypatch):
    """일부 필드만 넘기면 해당 필드만 갱신, 나머지 유지."""
    ps = SimpleNamespace(
        id="ps1",
        composer_system_prompt="old_sys",
        composer_user_prompt="old_user",
        updated_at=None,
    )
    svc._db.query.return_value.filter.return_value.first.return_value = ps
    monkeypatch.setattr("app.services.image_composer_service._now", lambda: "2026-01-01T00:00:00Z")
    result = svc.update_composer_prompt(user_prompt="new_user")
    assert ps.composer_system_prompt == "old_sys"  # 유지
    assert ps.composer_user_prompt == "new_user"
    assert result == {"composer_system_prompt": "old_sys", "composer_user_prompt": "new_user"}


# ── facade delegation smoke ────────────────────────────────────


def test_image_service_facade_injects_composer_svc():
    """ImageService.__init__가 _composer_svc를 주입하고 delegate가 동일 함수 호출."""
    from app.services.image_service import ImageService

    facade = ImageService.__new__(ImageService)
    facade._db = MagicMock()
    facade._project_id = "p1"
    facade._actor_id = "u1"
    facade._logger = MagicMock()
    facade._composer_svc = MagicMock(spec=ImageComposerService)

    facade.compose_prompts("st1", ip="x")
    facade._composer_svc.compose_prompts.assert_called_once_with("st1", ip="x")

    facade.get_composer_prompt()
    facade._composer_svc.get_composer_prompt.assert_called_once_with()

    facade.update_composer_prompt(system_prompt="s", user_prompt="u", ip="x")
    facade._composer_svc.update_composer_prompt.assert_called_once_with(
        system_prompt="s", user_prompt="u", ip="x"
    )
