"""SceneVariationService 단위 테스트 — W5 F22 Phase B.1.

SceneImageService에서 분리된 variant 선택 로직의 실제 동작을 검증한다.
scene_image_service 쪽에는 delegation smoke test만 남긴다.
"""
from __future__ import annotations

from unittest.mock import MagicMock

import pytest

from app.core.errors import AppError
from app.services.scene_variation_service import SceneVariationService


@pytest.fixture
def svc() -> SceneVariationService:
    instance = SceneVariationService.__new__(SceneVariationService)
    instance._db = MagicMock()
    instance._project_id = "p1"
    instance._actor_id = "u1"
    instance._logger = MagicMock()
    return instance


# ──────────────────────────────────────────────────────────────────────
# select_variant
# ──────────────────────────────────────────────────────────────────────


def test_select_variant_invalid_variant_raises(svc):
    """variant가 original/A/B가 아니면 400."""
    with pytest.raises(AppError) as exc_info:
        svc.select_variant("still1", "Z")
    assert exc_info.value.code == "image.invalid_variant"


def test_select_variant_still_not_found_raises(svc):
    """still 없음 → still.not_found."""
    svc._db.query.return_value.filter.return_value.first.return_value = None

    with pytest.raises(AppError) as exc_info:
        svc.select_variant("missing_still", "A")
    assert "still" in exc_info.value.code.lower()


# ──────────────────────────────────────────────────────────────────────
# select_original
# ──────────────────────────────────────────────────────────────────────


def test_select_original_still_not_found_raises(svc):
    svc._db.query.return_value.filter.return_value.first.return_value = None

    with pytest.raises(AppError) as exc_info:
        svc.select_original("missing_still", "img1")
    assert "still" in exc_info.value.code.lower()


def test_select_original_image_not_found_raises(svc):
    """still은 있지만 image_id 조회 결과 없음 → image.not_found."""
    still = MagicMock()

    call_count = {"n": 0}

    def _query_side_effect(model):
        q = MagicMock()
        call_count["n"] += 1
        if call_count["n"] == 1:
            q.filter.return_value.first.return_value = still
        else:
            q.filter.return_value.first.return_value = None
        return q

    svc._db.query.side_effect = _query_side_effect

    with pytest.raises(AppError) as exc_info:
        svc.select_original("still1", "missing_img")
    assert exc_info.value.code == "image.not_found"


# ──────────────────────────────────────────────────────────────────────
# recommend_variations — W5 F22 Phase B.3
# ──────────────────────────────────────────────────────────────────────


def test_recommend_variations_no_openai_key_raises(svc, monkeypatch):
    """openai_api_key 없을 때 image.openai_key_missing."""
    import app.services.scene_variation_service as var_mod
    monkeypatch.setattr(var_mod.settings, "openai_api_key", "")
    # [2026-08-01] 키 유무의 권위가 브로커로 옮겼다 — 1차 슬롯만 비우면 보조
    # 슬롯이 살아 있어 '있음'이 된다. 브로커의 시야를 비운다.
    monkeypatch.delenv("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.recommend_variations("still1")
    assert exc_info.value.code == "image.openai_key_missing"


def test_recommend_variations_still_not_found_raises(svc, monkeypatch):
    """openai_key 있어도 still 조회 실패 → still.not_found."""
    import app.services.scene_variation_service as var_mod
    monkeypatch.setattr(var_mod.settings, "openai_api_key", "fake-key")

    svc._db.query.return_value.filter.return_value.first.return_value = None

    with pytest.raises(AppError) as exc_info:
        svc.recommend_variations("missing_still")
    assert exc_info.value.code == "still.not_found"


# ──────────────────────────────────────────────────────────────────────
# set_variant_primary (module-level helper)
# ──────────────────────────────────────────────────────────────────────


def test_set_variant_primary_updates_siblings_and_target():
    """sibling은 is_primary=0, 대상은 is_primary=1로 설정."""
    from app.services.scene_variation_service import set_variant_primary

    db = MagicMock()
    sib1 = MagicMock(is_primary=1)
    sib2 = MagicMock(is_primary=1)
    target = MagicMock(is_primary=0)

    call_count = {"n": 0}

    def _query_side_effect(model):
        q = MagicMock()
        call_count["n"] += 1
        if call_count["n"] == 1:
            # First query — siblings (image != target_id)
            q.filter.return_value.all.return_value = [sib1, sib2]
        else:
            # Second query — target (image == target_id)
            q.filter.return_value.first.return_value = target
        return q

    db.query.side_effect = _query_side_effect

    set_variant_primary(db, "p1", "img_target", "still_a")

    assert sib1.is_primary == 0
    assert sib2.is_primary == 0
    assert target.is_primary == 1


def test_set_variant_primary_no_target_is_noop():
    """image_id로 찾은 결과가 없으면 target 업데이트 없음 — AttributeError 방지."""
    from app.services.scene_variation_service import set_variant_primary

    db = MagicMock()

    call_count = {"n": 0}

    def _query_side_effect(model):
        q = MagicMock()
        call_count["n"] += 1
        if call_count["n"] == 1:
            q.filter.return_value.all.return_value = []
        else:
            q.filter.return_value.first.return_value = None
        return q

    db.query.side_effect = _query_side_effect

    # should not raise
    set_variant_primary(db, "p1", "img_missing", "still_a")


# ──────────────────────────────────────────────────────────────────────
# select_best_variation_idx — W5 F22 Phase B.22.5
# ──────────────────────────────────────────────────────────────────────

from app.services.scene_variation_service import select_best_variation_idx


def test_select_best_variation_idx_empty_list_returns_zero_false():
    """빈 리스트 → (0, False) fallback."""
    assert select_best_variation_idx([], "beat") == (0, False)


def test_select_best_variation_idx_single_existing_file(tmp_path):
    """file 1개만 있으면 Gemini Vision 호출 없이 (0, False) 반환."""
    img = tmp_path / "a.png"
    img.write_bytes(b"IMG")
    var_results = [{"file_path": str(img)}]
    assert select_best_variation_idx(var_results, "beat") == (0, False)


def test_select_best_variation_idx_calls_gemini_when_multiple(monkeypatch, tmp_path):
    """valid file 2개 이상 → _select_final_best 호출, (idx, True) 반환."""
    img1 = tmp_path / "a.png"; img1.write_bytes(b"A")
    img2 = tmp_path / "b.png"; img2.write_bytes(b"B")
    img3 = tmp_path / "c.png"; img3.write_bytes(b"C")
    var_results = [
        {"file_path": str(img1)},
        {"file_path": str(img2)},
        {"file_path": str(img3)},
    ]

    called_with = {}
    def _fake_select(bytes_list, beat):
        called_with["n_bytes"] = len(bytes_list)
        called_with["beat"] = beat
        return 2  # raw_best = 2
    import app.services.scene_variation_service as svs
    monkeypatch.setattr(svs, "_select_final_best", _fake_select)

    result = select_best_variation_idx(var_results, "my beat")
    assert result == (2, True)
    assert called_with["n_bytes"] == 3
    assert called_with["beat"] == "my beat"


def test_select_best_variation_idx_zero_raw_still_flagged(monkeypatch, tmp_path):
    """_select_final_best가 0 반환해도 was_selected=True (로그 출력용)."""
    img1 = tmp_path / "a.png"; img1.write_bytes(b"A")
    img2 = tmp_path / "b.png"; img2.write_bytes(b"B")
    var_results = [{"file_path": str(img1)}, {"file_path": str(img2)}]

    def _fake_select(bytes_list, beat):
        return 0
    import app.services.scene_variation_service as svs
    monkeypatch.setattr(svs, "_select_final_best", _fake_select)
    # idx=0이지만 실제 선택 수행됐으므로 (0, True)
    assert select_best_variation_idx(var_results, "x") == (0, True)


def test_select_best_variation_idx_missing_file_skipped(monkeypatch, tmp_path):
    """존재하지 않는 file은 스킵, 나머지만으로 선택."""
    img1 = tmp_path / "a.png"; img1.write_bytes(b"A")
    img3 = tmp_path / "c.png"; img3.write_bytes(b"C")
    var_results = [
        {"file_path": str(img1)},
        {"file_path": str(tmp_path / "missing.png")},  # 없음
        {"file_path": str(img3)},
    ]

    def _fake_select(bytes_list, beat):
        return 1  # 0 or 1 중 선택 (valid 2개)
    import app.services.scene_variation_service as svs
    monkeypatch.setattr(svs, "_select_final_best", _fake_select)

    result = select_best_variation_idx(var_results, "x")
    # valid_indices = [0, 2], raw_best=1 → valid_indices[1]=2
    assert result == (2, True)


def test_select_best_variation_idx_raw_out_of_bounds_clamped(monkeypatch, tmp_path):
    """_select_final_best가 범위 초과 인덱스 반환하면 clamp."""
    img1 = tmp_path / "a.png"; img1.write_bytes(b"A")
    img2 = tmp_path / "b.png"; img2.write_bytes(b"B")
    var_results = [{"file_path": str(img1)}, {"file_path": str(img2)}]

    def _fake_select(bytes_list, beat):
        return 99  # 범위 초과
    import app.services.scene_variation_service as svs
    monkeypatch.setattr(svs, "_select_final_best", _fake_select)

    # min(99, 1) = 1 → valid_indices[1] = 1
    assert select_best_variation_idx(var_results, "x") == (1, True)


def test_select_best_variation_idx_exception_returns_zero_false(monkeypatch, tmp_path):
    """_select_final_best 예외 → warning log + (0, False) fallback."""
    img1 = tmp_path / "a.png"; img1.write_bytes(b"A")
    img2 = tmp_path / "b.png"; img2.write_bytes(b"B")
    var_results = [{"file_path": str(img1)}, {"file_path": str(img2)}]

    def _fake_select(bytes_list, beat):
        raise RuntimeError("Gemini down")
    import app.services.scene_variation_service as svs
    monkeypatch.setattr(svs, "_select_final_best", _fake_select)

    assert select_best_variation_idx(var_results, "x") == (0, False)


# ──────────────────────────────────────────────────────────────────────
# apply_validation_override — W5 F22 Phase B.22.7
# ──────────────────────────────────────────────────────────────────────

from app.services.scene_variation_service import apply_validation_override


def test_apply_validation_override_high_score_no_override():
    """selected score >= 40이면 override 없음."""
    var_results = [
        {"validation_score": 80},
        {"validation_score": 90},
    ]
    assert apply_validation_override(var_results, 0, 2, False) == (0, None)


def test_apply_validation_override_low_but_no_better_alternative():
    """score < 40이지만 alternative도 차이 20 미만이면 override 없음."""
    var_results = [
        {"validation_score": 30},  # selected, low
        {"validation_score": 45},  # only +15, below threshold
    ]
    assert apply_validation_override(var_results, 0, 2, False) == (0, None)


def test_apply_validation_override_switches_to_higher():
    """score < 40이고 alternative가 +20 이상 높으면 override."""
    var_results = [
        {"validation_score": 25},  # selected
        {"validation_score": 80},  # much higher
    ]
    new_idx, scores = apply_validation_override(var_results, 0, 2, False)
    assert new_idx == 1
    assert scores == (25, 80)


def test_apply_validation_override_none_score_treated_as_50():
    """validation_score=None이면 50 fallback. selected=None(50)이면 < 40 조건 실패 → 변화 없음."""
    var_results = [
        {"validation_score": None},  # → 50
        {"validation_score": 90},
    ]
    # selected score = 50 >= 40 → no override
    assert apply_validation_override(var_results, 0, 2, False) == (0, None)


def test_apply_validation_override_skip_fal_result():
    """fal_generated=True이면 마지막 variation은 alternative 후보에서 제외."""
    var_results = [
        {"validation_score": 20},  # orig 0, selected, low
        {"validation_score": 30},  # orig 1, not enough (+10)
        {"validation_score": 95},  # fal — 제외되어야 함
    ]
    # num_saved_assets=3, fal_generated=True → orig = var_results[:2]
    # alternatives = [30, 20] → best=30, 차이 10, 임계값 미만 → no override
    new_idx, scores = apply_validation_override(var_results, 0, 3, True)
    assert new_idx == 0
    assert scores is None


def test_apply_validation_override_without_fal_considers_all():
    """fal_generated=False이면 전체 variation이 override 후보."""
    var_results = [
        {"validation_score": 20},  # selected
        {"validation_score": 90},  # alternative
    ]
    # num_saved_assets=2, fal_generated=False → orig = var_results[:2]
    new_idx, scores = apply_validation_override(var_results, 0, 2, False)
    assert new_idx == 1
    assert scores == (20, 90)


def test_apply_validation_override_single_variation_no_override():
    """variation 1개만 있으면 override 스킵 (len > 1 조건)."""
    var_results = [{"validation_score": 10}]
    assert apply_validation_override(var_results, 0, 1, False) == (0, None)


def test_apply_validation_override_best_idx_out_of_bounds_falls_back_to_zero():
    """best_idx가 범위 밖이면 [0] 사용."""
    var_results = [
        {"validation_score": 25},  # [0] selected as fallback
        {"validation_score": 90},
    ]
    new_idx, scores = apply_validation_override(var_results, 99, 2, False)
    # selected=[0] score=25 < 40, alternative [1]=90, diff=65 → override
    assert new_idx == 1
    assert scores == (25, 90)


def test_apply_validation_override_score_exactly_40_no_override():
    """경계: selected == 40 → < 40 실패 → override 없음."""
    var_results = [
        {"validation_score": 40},
        {"validation_score": 90},
    ]
    assert apply_validation_override(var_results, 0, 2, False) == (0, None)


def test_apply_validation_override_diff_exactly_20_no_override():
    """경계: alternative - selected == 20 → strict > 조건 실패 → override 없음."""
    var_results = [
        {"validation_score": 30},  # selected, low
        {"validation_score": 50},  # +20 exact, not > 20
    ]
    assert apply_validation_override(var_results, 0, 2, False) == (0, None)
