"""ImageReviewService 테스트 (W5 F23 Phase 2).

image_service.py의 리뷰/primary/validation 8 메서드를 ImageReviewService로 이관.
각 메서드는 literal lift이므로 핵심 단위 동작 + delegation smoke만 검증.
"""
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_review_service import ImageReviewService


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


# ── get_image ──────────────────────────────────────────────────


def test_get_image_returns_asset(svc):
    """get_image: 조회 성공 시 ImageAsset 반환."""
    mock_img = SimpleNamespace(id="i1")
    svc._db.query.return_value.filter.return_value.first.return_value = mock_img
    result = svc.get_image("i1")
    assert result is mock_img


def test_get_image_not_found_raises(svc):
    """get_image: 없으면 AppError(404)."""
    svc._db.query.return_value.filter.return_value.first.return_value = None
    with pytest.raises(AppError) as exc_info:
        svc.get_image("i1")
    assert exc_info.value.code == "image.not_found"
    assert exc_info.value.status_code == 404


# ── update_review ──────────────────────────────────────────────


def test_update_review_invalid_status_raises(svc):
    """update_review: 허용 외 status는 AppError(400)."""
    with pytest.raises(AppError) as exc_info:
        svc.update_review("i1", "bad", "notes")
    assert exc_info.value.code == "image.invalid_review_status"
    assert exc_info.value.status_code == 400


def test_update_review_approved_updates_fields(svc):
    """update_review approved: status/notes 저장 + commit + log."""
    mock_img = SimpleNamespace(id="i1", status="generated", review_notes="")
    svc._db.query.return_value.filter.return_value.first.return_value = mock_img
    result = svc.update_review("i1", "approved", "looks good", ip="1.2.3.4")
    assert result is mock_img
    assert mock_img.status == "approved"
    assert mock_img.review_notes == "looks good"
    svc._db.commit.assert_called_once()
    svc._logger.log.assert_called_once()
    call_kwargs = svc._logger.log.call_args.kwargs
    assert call_kwargs["action"] == "image.review"
    assert call_kwargs["detail"] == {"status": "approved", "notes": "looks good"}
    assert call_kwargs["ip_address"] == "1.2.3.4"


# ── regenerate_image ──────────────────────────────────────────


def test_regenerate_image_marks_regenerating(svc):
    """regenerate_image: status='regenerating' 저장 + log."""
    mock_img = SimpleNamespace(id="i1", status="generated")
    svc._db.query.return_value.filter.return_value.first.return_value = mock_img
    svc.regenerate_image("i1", ip="1.2.3.4")
    assert mock_img.status == "regenerating"
    svc._db.commit.assert_called_once()
    svc._logger.log.assert_called_once_with(
        actor_id="u1",
        action="image.regenerate",
        resource_type="image",
        resource_id="i1",
        project_id="p1",
        ip_address="1.2.3.4",
    )


# ── regenerate_needs_fix ──────────────────────────────────────


def test_regenerate_needs_fix_empty_raises(svc):
    """regenerate_needs_fix: 이미지 없으면 AppError(400)."""
    svc._db.query.return_value.filter.return_value.all.return_value = []
    with pytest.raises(AppError) as exc_info:
        svc.regenerate_needs_fix("ep1")
    assert exc_info.value.code == "image.no_needs_fix"
    assert exc_info.value.status_code == 400


def test_regenerate_needs_fix_marks_all(svc):
    """regenerate_needs_fix: 모두 status='regenerating' 일괄 처리."""
    imgs = [
        SimpleNamespace(id="i1", status="needs_fix"),
        SimpleNamespace(id="i2", status="needs_fix"),
    ]
    svc._db.query.return_value.filter.return_value.all.return_value = imgs
    count = svc.regenerate_needs_fix("ep1", ip="1.2.3.4")
    assert count == 2
    assert all(i.status == "regenerating" for i in imgs)
    svc._db.commit.assert_called_once()
    call_kwargs = svc._logger.log.call_args.kwargs
    assert call_kwargs["action"] == "image.regenerate_batch"
    assert call_kwargs["detail"] == {"count": 2}


# ── set_primary_image ─────────────────────────────────────────


def test_set_primary_non_composite_entity(svc, monkeypatch):
    """set_primary_image: 비-composite entity 이미지 → 같은 entity sibling primary 해제."""
    mock_img = SimpleNamespace(
        id="i1",
        entity_id="e1",
        still_id=None,
        prompt_used="normal prompt",
        is_primary=0,
    )
    sibling = SimpleNamespace(
        id="s1",
        entity_id="e1",
        prompt_used="normal prompt",
        is_primary=1,
    )
    # get_image과 sibling 쿼리를 다른 결과로 반환하도록 분기
    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_img
        else:
            q.filter.return_value.all.return_value = [sibling]
        return q

    svc._db.query.side_effect = _query
    monkeypatch.setattr(
        "app.services.image_review_service.image_to_dict",
        lambda img: {"id": img.id, "is_primary": img.is_primary},
    )
    result = svc.set_primary_image("i1")
    assert result == {"id": "i1", "is_primary": 1}
    assert mock_img.is_primary == 1
    assert sibling.is_primary == 0


def test_set_primary_composite_same_key_unsets_sibling(svc, monkeypatch):
    """set_primary_image composite: 같은 composite_key sibling만 해제."""
    mock_img = SimpleNamespace(
        id="i1",
        entity_id="e1",
        still_id=None,
        prompt_used="[composite:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb] rest",
        is_primary=0,
    )
    sibling_same = SimpleNamespace(
        id="s1",
        entity_id="e1",
        prompt_used="[composite:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb] other",
        is_primary=1,
    )
    sibling_diff = SimpleNamespace(
        id="s2",
        entity_id="e1",
        prompt_used="[composite:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa:cccccccc-cccc-cccc-cccc-cccccccccccc] other",
        is_primary=1,
    )
    sibling_noncomp = SimpleNamespace(
        id="s3",
        entity_id="e1",
        prompt_used="plain",
        is_primary=1,
    )
    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_img
        else:
            q.filter.return_value.all.return_value = [sibling_same, sibling_diff, sibling_noncomp]
        return q

    svc._db.query.side_effect = _query
    monkeypatch.setattr(
        "app.services.image_review_service.image_to_dict",
        lambda img: {"id": img.id},
    )
    svc.set_primary_image("i1")
    assert sibling_same.is_primary == 0
    # 다른 composite_key는 유지
    assert sibling_diff.is_primary == 1
    # composite ↔ 비-composite는 서로 영향 없음
    assert sibling_noncomp.is_primary == 1


def test_set_primary_still_unsets_all_siblings(svc, monkeypatch):
    """set_primary_image still: 같은 still sibling 모두 해제."""
    mock_img = SimpleNamespace(
        id="i1",
        entity_id=None,
        still_id="st1",
        prompt_used="",
        is_primary=0,
    )
    sibling = SimpleNamespace(is_primary=1)
    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_img
        else:
            q.filter.return_value.all.return_value = [sibling]
        return q

    svc._db.query.side_effect = _query
    monkeypatch.setattr(
        "app.services.image_review_service.image_to_dict",
        lambda img: {"id": img.id},
    )
    svc.set_primary_image("i1")
    assert sibling.is_primary == 0
    assert mock_img.is_primary == 1


# ── _create_validator ──────────────────────────────────────────


def test_create_validator_no_openai_key_returns_none(svc, monkeypatch):
    """_create_validator: openai_api_key 없으면 None + info log."""
    monkeypatch.setattr("app.services.image_review_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)
    assert svc._create_validator() is None


def test_create_validator_with_key_returns_instance(svc, monkeypatch):
    """_create_validator: key 있으면 ImageValidator 인스턴스."""
    fake_validator = MagicMock(name="ImageValidator")
    monkeypatch.setattr("app.services.image_review_service.settings", SimpleNamespace(openai_api_key="sk-x"))
    monkeypatch.setattr("app.services.image_review_service.ImageValidator", fake_validator)
    result = svc._create_validator()
    fake_validator.assert_called_once_with()
    assert result is fake_validator.return_value


# ── validate_image ─────────────────────────────────────────────


def test_validate_image_no_openai_key_raises(svc, monkeypatch):
    """validate_image: openai_api_key 없으면 AppError(400)."""
    monkeypatch.setattr("app.services.image_review_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.validate_image("i1")
    assert exc_info.value.code == "image.openai_key_missing"
    assert exc_info.value.status_code == 400


def test_validate_image_file_missing_raises(svc, monkeypatch, tmp_path):
    """validate_image: 파일 없으면 AppError(404)."""
    missing = tmp_path / "nope.png"
    mock_img = SimpleNamespace(id="i1", file_path=str(missing))
    svc._db.query.return_value.filter.return_value.first.return_value = mock_img
    monkeypatch.setattr("app.services.image_review_service.settings", SimpleNamespace(openai_api_key="sk-x"))
    with pytest.raises(AppError) as exc_info:
        svc.validate_image("i1")
    assert exc_info.value.code == "image.not_found"
    assert exc_info.value.status_code == 404


# ── get_validation ─────────────────────────────────────────────


def test_get_validation_no_result_returns_defaults(svc):
    """get_validation: validation_result 없으면 기본값 반환."""
    mock_img = SimpleNamespace(id="i1", validation_score=None, validation_result=None)
    svc._db.query.return_value.filter.return_value.first.return_value = mock_img
    result = svc.get_validation("i1")
    assert result == {
        "image_id": "i1",
        "score": None,
        "passed": None,
        "issues": [],
        "description": "",
    }


def test_get_validation_parses_result(svc):
    """get_validation: validation_result JSON 파싱."""
    mock_img = SimpleNamespace(
        id="i1",
        validation_score=72,
        validation_result=json.dumps({"passed": True, "issues": ["minor"], "description": "ok"}),
    )
    svc._db.query.return_value.filter.return_value.first.return_value = mock_img
    result = svc.get_validation("i1")
    assert result == {
        "image_id": "i1",
        "score": 72,
        "passed": True,
        "issues": ["minor"],
        "description": "ok",
    }


def test_get_validation_malformed_json_keeps_defaults(svc):
    """get_validation: JSON 실패 시 passed/issues/description 기본값 유지."""
    mock_img = SimpleNamespace(id="i1", validation_score=50, validation_result="{not-json")
    svc._db.query.return_value.filter.return_value.first.return_value = mock_img
    result = svc.get_validation("i1")
    assert result["score"] == 50
    assert result["passed"] is None
    assert result["issues"] == []
    assert result["description"] == ""


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


def test_image_service_facade_injects_review_svc():
    """ImageService.__init__가 _review_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._review_svc = MagicMock(spec=ImageReviewService)

    facade.get_image("i1")
    facade._review_svc.get_image.assert_called_once_with("i1")

    facade.update_review("i1", "approved", "n", ip="x")
    facade._review_svc.update_review.assert_called_once_with("i1", "approved", "n", ip="x")

    facade.regenerate_image("i1", ip="x")
    facade._review_svc.regenerate_image.assert_called_once_with("i1", ip="x")

    facade.regenerate_needs_fix("ep1", ip="x")
    facade._review_svc.regenerate_needs_fix.assert_called_once_with("ep1", ip="x")

    facade.set_primary_image("i1", ip="x")
    facade._review_svc.set_primary_image.assert_called_once_with("i1", ip="x")

    facade.validate_image("i1", ip="x")
    facade._review_svc.validate_image.assert_called_once_with("i1", ip="x")

    facade.get_validation("i1")
    facade._review_svc.get_validation.assert_called_once_with("i1")
