"""TDD 테스트 — image_router.py 이미지 생성 라우터 + fallback.

테스트 범위:
- ImageType enum 존재 및 값
- GenerationResult dataclass 필드
- route_image_type() 라우팅 로직 (한글/영문 모두)
- generate_image() 정상 경로
- generate_image() fallback 시나리오
- _extract_structured_json() JSON 추출 로직
- _render_json_to_png() Satori JSON 렌더링
- fallback 최대 2회 제한
- 로그 기록 (log.warning 포맷)
- 모든 외부 API는 unittest.mock으로 차단
"""

import logging
import sys
from pathlib import Path
from unittest.mock import MagicMock, patch

import pytest

sys.path.insert(0, str(Path(__file__).parent))

from image_router import (  # noqa: E402
    GenerationResult,
    ImageType,
    _extract_structured_json,
    _generate_infographic,
    _render_html_to_png,
    _render_json_to_png,
    generate_image,
    route_image_type,
)

# ─────────────────────────────────────────────────────────────────────────────
# ImageType enum
# ─────────────────────────────────────────────────────────────────────────────


class TestImageTypeEnum:
    def test_has_photorealistic(self) -> None:
        assert hasattr(ImageType, "PHOTOREALISTIC")

    def test_has_cardnews(self) -> None:
        assert hasattr(ImageType, "CARDNEWS")

    def test_has_hybrid(self) -> None:
        assert hasattr(ImageType, "HYBRID")

    def test_three_members(self) -> None:
        assert len(list(ImageType)) == 4

    def test_photorealistic_value(self) -> None:
        assert ImageType.PHOTOREALISTIC.value == "photorealistic"

    def test_cardnews_value(self) -> None:
        assert ImageType.CARDNEWS.value == "cardnews"

    def test_hybrid_value(self) -> None:
        assert ImageType.HYBRID.value == "hybrid"


# ─────────────────────────────────────────────────────────────────────────────
# GenerationResult dataclass
# ─────────────────────────────────────────────────────────────────────────────


class TestGenerationResultDataclass:
    def test_success_field(self) -> None:
        r = GenerationResult(
            success=True,
            image_path=Path("/tmp/test.png"),
            method_used="gemini",
            fallback_used=False,
            attempts=1,
            error_message=None,
            elapsed_seconds=1.5,
        )
        assert r.success is True

    def test_image_path_field(self) -> None:
        p = Path("/tmp/test.png")
        r = GenerationResult(
            success=True,
            image_path=p,
            method_used="gemini",
            fallback_used=False,
            attempts=1,
            error_message=None,
            elapsed_seconds=1.5,
        )
        assert r.image_path == p

    def test_method_used_field(self) -> None:
        r = GenerationResult(
            success=False,
            image_path=None,
            method_used="satori",
            fallback_used=True,
            attempts=2,
            error_message="타임아웃",
            elapsed_seconds=0.5,
        )
        assert r.method_used == "satori"

    def test_fallback_used_field(self) -> None:
        r = GenerationResult(
            success=False,
            image_path=None,
            method_used="satori",
            fallback_used=True,
            attempts=2,
            error_message="에러",
            elapsed_seconds=0.1,
        )
        assert r.fallback_used is True

    def test_attempts_field(self) -> None:
        r = GenerationResult(
            success=True,
            image_path=Path("/tmp/x.png"),
            method_used="gemini",
            fallback_used=False,
            attempts=1,
            error_message=None,
            elapsed_seconds=2.0,
        )
        assert r.attempts == 1

    def test_error_message_none_when_success(self) -> None:
        r = GenerationResult(
            success=True,
            image_path=Path("/tmp/x.png"),
            method_used="gemini",
            fallback_used=False,
            attempts=1,
            error_message=None,
            elapsed_seconds=2.0,
        )
        assert r.error_message is None

    def test_elapsed_seconds_field(self) -> None:
        r = GenerationResult(
            success=True,
            image_path=Path("/tmp/x.png"),
            method_used="satori",
            fallback_used=False,
            attempts=1,
            error_message=None,
            elapsed_seconds=0.3,
        )
        assert r.elapsed_seconds == pytest.approx(0.3)


# ─────────────────────────────────────────────────────────────────────────────
# route_image_type() — 영문 키워드
# ─────────────────────────────────────────────────────────────────────────────


class TestRouteImageTypeEnglish:
    def test_photorealistic_keyword(self) -> None:
        assert route_image_type("photorealistic") == ImageType.PHOTOREALISTIC

    def test_cardnews_keyword(self) -> None:
        assert route_image_type("cardnews") == ImageType.CARDNEWS

    def test_hybrid_keyword(self) -> None:
        assert route_image_type("hybrid") == ImageType.HYBRID

    def test_case_insensitive_photorealistic(self) -> None:
        assert route_image_type("PHOTOREALISTIC") == ImageType.PHOTOREALISTIC

    def test_case_insensitive_cardnews(self) -> None:
        assert route_image_type("CardNews") == ImageType.CARDNEWS

    def test_case_insensitive_hybrid(self) -> None:
        assert route_image_type("HYBRID") == ImageType.HYBRID


# ─────────────────────────────────────────────────────────────────────────────
# route_image_type() — 한글/도메인 키워드
# ─────────────────────────────────────────────────────────────────────────────


class TestRouteImageTypeKorean:
    def test_gwanggo_maps_to_photorealistic(self) -> None:
        assert route_image_type("광고") == ImageType.PHOTOREALISTIC

    def test_photo_maps_to_photorealistic(self) -> None:
        assert route_image_type("포토") == ImageType.PHOTOREALISTIC

    def test_cardnews_korean_maps_to_cardnews(self) -> None:
        assert route_image_type("카드뉴스") == ImageType.CARDNEWS

    def test_banner_maps_to_cardnews(self) -> None:
        assert route_image_type("배너") == ImageType.CARDNEWS

    def test_infographic_maps_to_cardnews(self) -> None:
        assert route_image_type("인포그래픽") == ImageType.CARDNEWS

    def test_korean_photo_maps_to_hybrid(self) -> None:
        assert route_image_type("한글+사진") == ImageType.HYBRID

    def test_text_overlay_maps_to_hybrid(self) -> None:
        assert route_image_type("텍스트오버레이") == ImageType.HYBRID


# ─────────────────────────────────────────────────────────────────────────────
# route_image_type() — 알 수 없는 용도
# ─────────────────────────────────────────────────────────────────────────────


class TestRouteImageTypeUnknown:
    def test_unknown_purpose_raises_value_error(self) -> None:
        with pytest.raises(ValueError, match="알 수 없는 용도"):
            route_image_type("알수없는용도xyz")

    def test_empty_string_raises_value_error(self) -> None:
        with pytest.raises(ValueError):
            route_image_type("")


# ─────────────────────────────────────────────────────────────────────────────
# generate_image() — 정상 경로 (Gemini 성공)
# ─────────────────────────────────────────────────────────────────────────────


class TestGenerateImagePhotorealisticSuccess:
    def test_returns_generation_result(self, tmp_path: Path) -> None:
        with patch("image_router._generate_gemini", return_value=True):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트 프롬프트",
                brand="테스트브랜드",
                output_dir=tmp_path,
            )
        assert isinstance(result, GenerationResult)

    def test_success_is_true(self, tmp_path: Path) -> None:
        with patch("image_router._generate_gemini", return_value=True):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is True

    def test_method_used_is_gemini(self, tmp_path: Path) -> None:
        with patch("image_router._generate_gemini", return_value=True):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.method_used == "gemini"

    def test_fallback_not_used(self, tmp_path: Path) -> None:
        with patch("image_router._generate_gemini", return_value=True):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.fallback_used is False

    def test_attempts_is_one(self, tmp_path: Path) -> None:
        with patch("image_router._generate_gemini", return_value=True):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.attempts == 1

    def test_error_message_is_none(self, tmp_path: Path) -> None:
        with patch("image_router._generate_gemini", return_value=True):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.error_message is None

    def test_elapsed_seconds_positive(self, tmp_path: Path) -> None:
        with patch("image_router._generate_gemini", return_value=True):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.elapsed_seconds >= 0.0


# ─────────────────────────────────────────────────────────────────────────────
# generate_image() — 정상 경로 (Satori 성공)
# ─────────────────────────────────────────────────────────────────────────────


class TestGenerateImageCardnewsSuccess:
    def test_method_used_is_satori(self, tmp_path: Path) -> None:
        with patch("image_router._generate_satori", return_value=True):
            result = generate_image(
                purpose="카드뉴스",
                prompt="카드뉴스 프롬프트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.method_used == "satori"

    def test_success_true_satori(self, tmp_path: Path) -> None:
        with patch("image_router._generate_satori", return_value=True):
            result = generate_image(
                purpose="배너",
                prompt="배너 프롬프트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is True


# ─────────────────────────────────────────────────────────────────────────────
# generate_image() — 정상 경로 (하이브리드 성공)
# ─────────────────────────────────────────────────────────────────────────────


class TestGenerateImageHybridSuccess:
    def test_method_used_is_hybrid(self, tmp_path: Path) -> None:
        with patch("image_router._generate_hybrid", return_value=True):
            result = generate_image(
                purpose="hybrid",
                prompt="하이브리드 프롬프트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.method_used == "hybrid"

    def test_success_true_hybrid(self, tmp_path: Path) -> None:
        with patch("image_router._generate_hybrid", return_value=True):
            result = generate_image(
                purpose="한글+사진",
                prompt="하이브리드",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is True


# ─────────────────────────────────────────────────────────────────────────────
# generate_image() — Gemini 실패 시 에러 반환 (GPT 제거됨)
# ─────────────────────────────────────────────────────────────────────────────


class TestGenerateImageGeminiFailure:
    """Gemini 실패 시 fallback 없이 에러 반환 (GPT 제거됨)."""

    def test_gemini_failure_returns_error(self, tmp_path: Path) -> None:
        with patch("image_router._generate_gemini", return_value=False):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is False

    def test_gemini_failure_error_message_set(self, tmp_path: Path) -> None:
        with patch("image_router._generate_gemini", return_value=False):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.error_message is not None

    def test_gemini_failure_no_fallback(self, tmp_path: Path) -> None:
        with patch("image_router._generate_gemini", return_value=False):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.fallback_used is False

    def test_gemini_failure_attempts_one(self, tmp_path: Path) -> None:
        with patch("image_router._generate_gemini", return_value=False):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.attempts == 1


# ─────────────────────────────────────────────────────────────────────────────
# generate_image() — fallback: Satori 실패 → 에러 반환
# ─────────────────────────────────────────────────────────────────────────────


class TestGenerateImageSatoriFailure:
    def test_satori_failure_returns_error(self, tmp_path: Path) -> None:
        with patch("image_router._generate_satori", return_value=False):
            result = generate_image(
                purpose="카드뉴스",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is False

    def test_satori_failure_has_error_message(self, tmp_path: Path) -> None:
        with patch("image_router._generate_satori", return_value=False):
            result = generate_image(
                purpose="카드뉴스",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.error_message is not None
        assert len(result.error_message) > 0


# ─────────────────────────────────────────────────────────────────────────────
# generate_image() — fallback: 하이브리드 실패 → Gemini 단독
# ─────────────────────────────────────────────────────────────────────────────


class TestGenerateImageHybridFallback:
    def test_hybrid_failure_falls_back_to_gemini(self, tmp_path: Path) -> None:
        with (
            patch("image_router._generate_hybrid", return_value=False),
            patch("image_router._generate_gemini", return_value=True),
        ):
            result = generate_image(
                purpose="hybrid",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is True

    def test_hybrid_fallback_method_used_is_gemini(self, tmp_path: Path) -> None:
        with (
            patch("image_router._generate_hybrid", return_value=False),
            patch("image_router._generate_gemini", return_value=True),
        ):
            result = generate_image(
                purpose="hybrid",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.method_used == "gemini"

    def test_hybrid_fallback_used_is_true(self, tmp_path: Path) -> None:
        with (
            patch("image_router._generate_hybrid", return_value=False),
            patch("image_router._generate_gemini", return_value=True),
        ):
            result = generate_image(
                purpose="hybrid",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.fallback_used is True

    def test_hybrid_fallback_logged(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
        with (
            patch("image_router._generate_hybrid", return_value=False),
            patch("image_router._generate_gemini", return_value=True),
            caplog.at_level(logging.WARNING, logger="image_router"),
        ):
            generate_image(
                purpose="hybrid",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        fallback_logs = [r for r in caplog.records if "[FALLBACK]" in r.message]
        assert len(fallback_logs) >= 1


# ─────────────────────────────────────────────────────────────────────────────
# generate_image() — fallback 최대 2회 제한
# ─────────────────────────────────────────────────────────────────────────────


class TestFallbackMaxAttempts:
    def test_gemini_failure_returns_error_no_fallback(self, tmp_path: Path) -> None:
        """Gemini 실패 → 에러 반환 (GPT fallback 제거됨)."""
        with patch("image_router._generate_gemini", return_value=False):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is False
        assert result.attempts == 1

    def test_hybrid_both_fail_returns_error(self, tmp_path: Path) -> None:
        """Hybrid + Gemini fallback 모두 실패 시 에러 반환."""
        with (
            patch("image_router._generate_hybrid", return_value=False),
            patch("image_router._generate_gemini", return_value=False),
        ):
            result = generate_image(
                purpose="hybrid",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is False
        assert result.attempts == 2

    def test_infographic_both_fail_returns_error(self, tmp_path: Path) -> None:
        """Infographic + Satori fallback 모두 실패 시 에러 반환."""
        with (
            patch("image_router._generate_infographic", return_value=False),
            patch("image_router._generate_satori", return_value=False),
        ):
            result = generate_image(
                purpose="infographic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is False
        assert result.attempts == 2


# ─────────────────────────────────────────────────────────────────────────────
# generate_image() — 예외 처리 (Exception 발생 시)
# ─────────────────────────────────────────────────────────────────────────────


class TestGenerateImageExceptionHandling:
    def test_gemini_raises_exception_returns_error(self, tmp_path: Path) -> None:
        """Gemini가 예외를 발생시키면 에러 반환 (fallback 없음)."""
        with patch(
            "image_router._generate_gemini",
            side_effect=RuntimeError("API 연결 실패"),
        ):
            result = generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is False
        assert result.error_message is not None

    def test_gemini_exception_logged(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
        """예외 발생 시 경고 로그가 기록된다."""
        with (
            patch(
                "image_router._generate_gemini",
                side_effect=TimeoutError("타임아웃"),
            ),
            caplog.at_level(logging.WARNING, logger="image_router"),
        ):
            generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        fallback_logs = [r for r in caplog.records if r.levelno == logging.WARNING]
        assert len(fallback_logs) >= 1

    def test_satori_raises_returns_error(self, tmp_path: Path) -> None:
        """Satori 예외 발생 시 에러 반환 (fallback 없음)."""
        with patch(
            "image_router._generate_satori",
            side_effect=RuntimeError("HTML 파싱 오류"),
        ):
            result = generate_image(
                purpose="카드뉴스",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is False
        assert result.error_message is not None


# ─────────────────────────────────────────────────────────────────────────────
# generate_image() — 로그 기록 검증
# ─────────────────────────────────────────────────────────────────────────────


class TestGenerateImageLogging:
    def test_info_log_on_start(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
        """생성 시작 시 INFO 레벨 로그가 기록된다."""
        with (
            patch("image_router._generate_gemini", return_value=True),
            caplog.at_level(logging.INFO, logger="image_router"),
        ):
            generate_image(
                purpose="photorealistic",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        info_logs = [r for r in caplog.records if r.levelno == logging.INFO]
        assert len(info_logs) >= 1

    def test_warning_log_level_for_fallback(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
        """fallback 로그는 WARNING 레벨이어야 한다."""
        with (
            patch("image_router._generate_hybrid", return_value=False),
            patch("image_router._generate_gemini", return_value=True),
            caplog.at_level(logging.WARNING, logger="image_router"),
        ):
            generate_image(
                purpose="hybrid",
                prompt="테스트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        fallback_logs = [r for r in caplog.records if "[FALLBACK]" in r.message and r.levelno == logging.WARNING]
        assert len(fallback_logs) >= 1


# ─────────────────────────────────────────────────────────────────────────────
# ImageType.INFOGRAPHIC enum
# ─────────────────────────────────────────────────────────────────────────────


class TestImageTypeInfographic:
    def test_has_infographic(self) -> None:
        """ImageType에 INFOGRAPHIC 멤버가 존재한다."""
        assert hasattr(ImageType, "INFOGRAPHIC")

    def test_infographic_value(self) -> None:
        """INFOGRAPHIC의 값은 'infographic' 문자열이다."""
        assert ImageType.INFOGRAPHIC.value == "infographic"


# ─────────────────────────────────────────────────────────────────────────────
# route_image_type() — INFOGRAPHIC 키워드 라우팅
# ─────────────────────────────────────────────────────────────────────────────


class TestRouteImageTypeInfographic:
    def test_infographic_keyword(self) -> None:
        """'infographic' 키워드는 INFOGRAPHIC으로 라우팅된다."""
        assert route_image_type("infographic") == ImageType.INFOGRAPHIC

    def test_comparison_table_keyword(self) -> None:
        """'comparison_table' 키워드는 INFOGRAPHIC으로 라우팅된다."""
        assert route_image_type("comparison_table") == ImageType.INFOGRAPHIC

    def test_checklist_keyword(self) -> None:
        """'checklist' 키워드는 INFOGRAPHIC으로 라우팅된다."""
        assert route_image_type("checklist") == ImageType.INFOGRAPHIC

    def test_process_flow_keyword(self) -> None:
        """'process_flow' 키워드는 INFOGRAPHIC으로 라우팅된다."""
        assert route_image_type("process_flow") == ImageType.INFOGRAPHIC

    def test_chart_keyword(self) -> None:
        """'chart' 키워드는 INFOGRAPHIC으로 라우팅된다."""
        assert route_image_type("chart") == ImageType.INFOGRAPHIC


# ─────────────────────────────────────────────────────────────────────────────
# generate_image() — 인포그래픽 정상 경로
# ─────────────────────────────────────────────────────────────────────────────


class TestGenerateImageInfographicSuccess:
    def test_method_used_is_infographic(self, tmp_path: Path) -> None:
        """인포그래픽 생성 성공 시 method_used는 'infographic'이다."""
        with patch("image_router._generate_infographic", return_value=True):
            result = generate_image(
                purpose="infographic",
                prompt="인포그래픽 프롬프트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.method_used == "infographic"

    def test_success_true_infographic(self, tmp_path: Path) -> None:
        """comparison_table 목적으로 생성 성공 시 success는 True다."""
        with patch("image_router._generate_infographic", return_value=True):
            result = generate_image(
                purpose="comparison_table",
                prompt="비교표 프롬프트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is True


# ─────────────────────────────────────────────────────────────────────────────
# generate_image() — 인포그래픽 fallback (infographic → satori)
# ─────────────────────────────────────────────────────────────────────────────


class TestGenerateImageInfographicFallback:
    def test_infographic_failure_falls_back_to_satori(self, tmp_path: Path) -> None:
        """_generate_infographic 실패 시 satori fallback으로 성공한다."""
        with (
            patch("image_router._generate_infographic", return_value=False),
            patch("image_router._generate_satori", return_value=True),
        ):
            result = generate_image(
                purpose="infographic",
                prompt="인포그래픽 프롬프트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is True
        assert result.fallback_used is True

    def test_infographic_fallback_method_used_is_satori(self, tmp_path: Path) -> None:
        """infographic → satori fallback 시 method_used는 'satori'다."""
        with (
            patch("image_router._generate_infographic", return_value=False),
            patch("image_router._generate_satori", return_value=True),
        ):
            result = generate_image(
                purpose="infographic",
                prompt="인포그래픽 프롬프트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.method_used == "satori"

    def test_infographic_both_fail_returns_error(self, tmp_path: Path) -> None:
        """infographic과 satori 모두 실패하면 success는 False다."""
        with (
            patch("image_router._generate_infographic", return_value=False),
            patch("image_router._generate_satori", return_value=False),
        ):
            result = generate_image(
                purpose="infographic",
                prompt="인포그래픽 프롬프트",
                brand="브랜드",
                output_dir=tmp_path,
            )
        assert result.success is False

    def test_infographic_fails_when_json_none(self, tmp_path: Path) -> None:
        """_extract_structured_json이 None 반환 시 _generate_infographic은 False."""
        output_file = tmp_path / "out.png"
        with patch("image_router._extract_structured_json", return_value=None):
            result = _generate_infographic("[infographic] 설명", output_file)
        assert result is False


# ─────────────────────────────────────────────────────────────────────────────
# _extract_structured_json() — Claude CLI 호출 및 JSON 반환
# ─────────────────────────────────────────────────────────────────────────────


class TestExtractStructuredJson:
    def test_returns_dict_on_valid_json(self) -> None:
        """_extract_structured_json은 유효한 JSON에서 dict를 반환한다."""
        json_response = '{"type": "infographic", "title": "테스트", "items": []}'
        mock_result = MagicMock()
        mock_result.stdout = json_response
        mock_result.returncode = 0
        with patch("subprocess.run", return_value=mock_result):
            result = _extract_structured_json("설명", "infographic")
        assert isinstance(result, dict)
        assert result["type"] == "infographic"

    def test_calls_claude_cli(self) -> None:
        """subprocess.run 호출 시 claude CLI 경로가 포함된다."""
        mock_result = MagicMock()
        mock_result.stdout = '{"type": "checklist", "title": "t", "items": []}'
        mock_result.returncode = 0
        with patch("subprocess.run", return_value=mock_result) as mock_run:
            _extract_structured_json("설명", "checklist")
        mock_run.assert_called_once()
        call_args = mock_run.call_args
        cmd = call_args[0][0] if call_args[0] else call_args[1].get("args", [])
        cmd_str = str(cmd)
        assert "/home/jay/.local/bin/claude" in cmd_str

    def test_empty_result_returns_none(self) -> None:
        """stdout이 빈 문자열일 때 None을 반환한다."""
        mock_result = MagicMock()
        mock_result.stdout = ""
        mock_result.returncode = 0
        with patch("subprocess.run", return_value=mock_result), patch("time.sleep"):
            result = _extract_structured_json("설명", "infographic")
        assert result is None

    def test_non_json_result_returns_none(self) -> None:
        """stdout이 JSON이 아닌 일반 텍스트일 때 None을 반환한다."""
        mock_result = MagicMock()
        mock_result.stdout = "이것은 JSON이 아닌 일반 텍스트입니다."
        mock_result.returncode = 0
        with patch("subprocess.run", return_value=mock_result), patch("time.sleep"):
            result = _extract_structured_json("설명", "infographic")
        assert result is None

    def test_valid_json_parsed_correctly(self) -> None:
        """유효한 JSON 응답이 올바르게 파싱된다."""
        expected = {"type": "comparison_table", "title": "비교", "items": [{"label": "A", "features": ["x"]}]}
        mock_result = MagicMock()
        mock_result.stdout = (
            '{"type": "comparison_table", "title": "비교", "items": [{"label": "A", "features": ["x"]}]}'
        )
        mock_result.returncode = 0
        with patch("subprocess.run", return_value=mock_result):
            result = _extract_structured_json("설명", "comparison_table")
        assert result == expected

    def test_prompt_contains_img_type(self) -> None:
        """_extract_structured_json 프롬프트에 img_type이 포함된다."""
        mock_result = MagicMock()
        mock_result.stdout = '{"type": "checklist", "title": "t", "items": []}'
        mock_result.returncode = 0
        with patch("subprocess.run", return_value=mock_result) as mock_run:
            _extract_structured_json("설명", "checklist")
        call_args = mock_run.call_args
        cmd = call_args[0][0]
        prompt_arg = cmd[2]
        assert "checklist" in prompt_arg


# ─────────────────────────────────────────────────────────────────────────────
# _extract_structured_json() — 재시도 로직
# ─────────────────────────────────────────────────────────────────────────────


class TestExtractStructuredJsonRetry:
    def test_retries_on_empty_response(self) -> None:
        """빈 응답 후 재시도하여 성공한다."""
        mock_empty = MagicMock()
        mock_empty.stdout = ""
        mock_empty.returncode = 0
        mock_success = MagicMock()
        mock_success.stdout = '{"type": "infographic", "title": "성공", "items": []}'
        mock_success.returncode = 0
        with patch("subprocess.run", side_effect=[mock_empty, mock_success]) as mock_run, patch("time.sleep"):
            result = _extract_structured_json("설명", "infographic")
        assert result == {"type": "infographic", "title": "성공", "items": []}
        assert mock_run.call_count == 2

    def test_retries_on_non_json_response(self) -> None:
        """JSON이 아닌 응답 후 재시도하여 성공한다."""
        mock_text = MagicMock()
        mock_text.stdout = "일반 텍스트입니다"
        mock_text.returncode = 0
        mock_success = MagicMock()
        mock_success.stdout = '{"type": "checklist", "title": "성공", "items": []}'
        mock_success.returncode = 0
        with patch("subprocess.run", side_effect=[mock_text, mock_success]) as mock_run, patch("time.sleep"):
            result = _extract_structured_json("설명", "checklist")
        assert result is not None
        assert mock_run.call_count == 2

    def test_retries_on_nonzero_return_code(self) -> None:
        """비정상 종료 후 재시도하여 성공한다."""
        mock_fail = MagicMock()
        mock_fail.stdout = ""
        mock_fail.stderr = "error"
        mock_fail.returncode = 1
        mock_success = MagicMock()
        mock_success.stdout = '{"type": "infographic", "title": "성공", "items": []}'
        mock_success.returncode = 0
        with patch("subprocess.run", side_effect=[mock_fail, mock_success]) as mock_run, patch("time.sleep"):
            result = _extract_structured_json("설명", "infographic")
        assert result is not None
        assert mock_run.call_count == 2

    def test_returns_none_after_max_retries(self) -> None:
        """3회 모두 빈 응답이면 None을 반환한다."""
        mock_empty = MagicMock()
        mock_empty.stdout = ""
        mock_empty.returncode = 0
        with patch("subprocess.run", return_value=mock_empty) as mock_run, patch("time.sleep"):
            result = _extract_structured_json("설명", "infographic")
        assert result is None
        assert mock_run.call_count == 3

    def test_no_sleep_on_first_success(self) -> None:
        """첫 시도 성공 시 sleep 호출 없다."""
        mock_success = MagicMock()
        mock_success.stdout = '{"type": "infographic", "title": "성공", "items": []}'
        mock_success.returncode = 0
        with patch("subprocess.run", return_value=mock_success), patch("time.sleep") as mock_sleep:
            _extract_structured_json("설명", "infographic")
        mock_sleep.assert_not_called()

    def test_sleep_called_between_retries(self) -> None:
        """재시도 시 time.sleep(1)이 호출된다."""
        mock_empty = MagicMock()
        mock_empty.stdout = ""
        mock_empty.returncode = 0
        mock_success = MagicMock()
        mock_success.stdout = '{"type": "infographic", "title": "성공", "items": []}'
        mock_success.returncode = 0
        with patch("subprocess.run", side_effect=[mock_empty, mock_success]), patch("time.sleep") as mock_sleep:
            _extract_structured_json("설명", "infographic")
        mock_sleep.assert_called_once_with(1)

    def test_json_parse_error_triggers_feedback_retry(self) -> None:
        """JSON 파싱 에러 시 피드백 포함 4번째 재시도가 실행된다."""
        mock_invalid = MagicMock()
        mock_invalid.stdout = '{"type": "infographic", "title": 잘못된}'
        mock_invalid.returncode = 0
        mock_success = MagicMock()
        mock_success.stdout = '{"type": "infographic", "title": "성공", "items": []}'
        mock_success.returncode = 0
        with (
            patch("subprocess.run", side_effect=[mock_invalid, mock_invalid, mock_invalid, mock_success]) as mock_run,
            patch("time.sleep"),
        ):
            result = _extract_structured_json("설명", "infographic")
        assert result is not None
        assert mock_run.call_count == 4


# ─────────────────────────────────────────────────────────────────────────────
# _render_html_to_png() — Playwright 기반 PNG 렌더링
# ─────────────────────────────────────────────────────────────────────────────


class TestRenderHtmlToPng:
    def test_returns_true_on_success(self, tmp_path: Path) -> None:
        """Playwright 렌더링 성공 시 True를 반환한다."""
        output_file = tmp_path / "out.png"

        def fake_screenshot(**kwargs: object) -> None:
            p = str(kwargs.get("path", output_file))
            Path(p).write_bytes(b"fake png data")

        mock_page = MagicMock()
        mock_page.screenshot.side_effect = fake_screenshot
        mock_browser = MagicMock()
        mock_browser.new_page.return_value = mock_page
        mock_context_manager = MagicMock()
        mock_playwright = MagicMock()
        mock_playwright.chromium.launch.return_value = mock_browser
        mock_context_manager.__enter__ = MagicMock(return_value=mock_playwright)
        mock_context_manager.__exit__ = MagicMock(return_value=False)

        with patch("playwright.sync_api.sync_playwright", return_value=mock_context_manager):
            result = _render_html_to_png("<div>test</div>", output_file)
        assert result is True

    def test_returns_false_on_failure(self, tmp_path: Path) -> None:
        """Playwright 렌더링 실패(예외 발생) 시 False를 반환한다."""
        mock_context_manager = MagicMock()
        mock_context_manager.__enter__ = MagicMock(side_effect=RuntimeError("Playwright 초기화 실패"))
        mock_context_manager.__exit__ = MagicMock(return_value=False)

        with patch("playwright.sync_api.sync_playwright", return_value=mock_context_manager):
            result = _render_html_to_png("<div>test</div>", tmp_path / "out.png")
        assert result is False

    def test_screenshot_called_with_full_page_true(self, tmp_path: Path) -> None:
        """screenshot() 호출 시 full_page=True가 전달된다."""
        output_file = tmp_path / "out.png"

        def fake_screenshot(**kwargs: object) -> None:
            p = str(kwargs.get("path", output_file))
            Path(p).write_bytes(b"fake png data")

        mock_page = MagicMock()
        mock_page.screenshot.side_effect = fake_screenshot
        mock_browser = MagicMock()
        mock_browser.new_page.return_value = mock_page
        mock_context_manager = MagicMock()
        mock_playwright = MagicMock()
        mock_playwright.chromium.launch.return_value = mock_browser
        mock_context_manager.__enter__ = MagicMock(return_value=mock_playwright)
        mock_context_manager.__exit__ = MagicMock(return_value=False)

        with patch("playwright.sync_api.sync_playwright", return_value=mock_context_manager):
            _render_html_to_png("<div>test</div>", output_file)

        # screenshot()이 full_page=True로 호출되었는지 검증
        call_kwargs = mock_page.screenshot.call_args[1]
        assert call_kwargs.get("full_page") is True

    def test_body_css_uses_min_height(self, tmp_path: Path) -> None:
        """생성된 HTML body CSS에 min-height가 사용된다 (height 대신)."""
        output_file = tmp_path / "out.png"
        captured_html: list[str] = []

        def fake_screenshot(**kwargs: object) -> None:
            p = str(kwargs.get("path", output_file))
            Path(p).write_bytes(b"fake png data")

        mock_page = MagicMock()
        mock_page.screenshot.side_effect = fake_screenshot

        def capture_write_text(content: str, **_: str) -> None:
            captured_html.append(content)

        mock_browser = MagicMock()
        mock_browser.new_page.return_value = mock_page
        mock_context_manager = MagicMock()
        mock_playwright = MagicMock()
        mock_playwright.chromium.launch.return_value = mock_browser
        mock_context_manager.__enter__ = MagicMock(return_value=mock_playwright)
        mock_context_manager.__exit__ = MagicMock(return_value=False)

        with (
            patch("playwright.sync_api.sync_playwright", return_value=mock_context_manager),
            patch("pathlib.Path.write_text", side_effect=capture_write_text),
        ):
            _render_html_to_png("<div>test</div>", output_file)

        assert len(captured_html) > 0
        html_content = captured_html[0]
        assert "min-height" in html_content


# ─────────────────────────────────────────────────────────────────────────────
# _render_json_to_png() — Satori JSON 렌더링
# ─────────────────────────────────────────────────────────────────────────────


class TestRenderJsonToPng:
    def test_returns_true_on_success(self, tmp_path: Path) -> None:
        """Satori JSON 렌더링 성공 시 True를 반환한다."""
        output_file = tmp_path / "out.png"

        def fake_run(*args: object, **kwargs: object) -> MagicMock:
            output_file.write_bytes(b"fake png data")
            mock = MagicMock()
            mock.returncode = 0
            return mock

        with patch("subprocess.run", side_effect=fake_run):
            result = _render_json_to_png({"type": "checklist", "title": "t", "items": ["a"]}, output_file)
        assert result is True

    def test_returns_false_on_nonzero_exit(self, tmp_path: Path) -> None:
        """subprocess 비정상 종료 시 False를 반환한다."""
        mock_result = MagicMock()
        mock_result.returncode = 1
        mock_result.stderr = "error"
        with patch("subprocess.run", return_value=mock_result):
            result = _render_json_to_png({"type": "infographic", "title": "t", "items": []}, tmp_path / "out.png")
        assert result is False

    def test_calls_satori_with_json_flag(self, tmp_path: Path) -> None:
        """subprocess.run 호출 시 --json 플래그가 포함된다."""
        output_file = tmp_path / "out.png"

        def fake_run(*args: object, **kwargs: object) -> MagicMock:
            output_file.write_bytes(b"fake png data")
            mock = MagicMock()
            mock.returncode = 0
            return mock

        with patch("subprocess.run", side_effect=fake_run) as mock_run:
            _render_json_to_png({"type": "chart", "title": "t", "items": []}, output_file)
        call_args = mock_run.call_args[0][0]
        assert "--json" in call_args

    def test_json_data_serialized_correctly(self, tmp_path: Path) -> None:
        """JSON 데이터가 올바르게 직렬화되어 전달된다."""
        output_file = tmp_path / "out.png"
        test_data = {"type": "checklist", "title": "테스트", "items": ["항목1", "항목2"]}

        def fake_run(*args: object, **kwargs: object) -> MagicMock:
            output_file.write_bytes(b"fake png data")
            mock = MagicMock()
            mock.returncode = 0
            return mock

        with patch("subprocess.run", side_effect=fake_run) as mock_run:
            _render_json_to_png(test_data, output_file)
        call_args = mock_run.call_args[0][0]
        json_arg_idx = call_args.index("--json") + 1
        import json

        parsed = json.loads(call_args[json_arg_idx])
        assert parsed["title"] == "테스트"
        assert parsed["items"] == ["항목1", "항목2"]

    def test_returns_false_on_exception(self, tmp_path: Path) -> None:
        """예외 발생 시 False를 반환한다."""
        with patch("subprocess.run", side_effect=RuntimeError("node not found")):
            result = _render_json_to_png({"type": "infographic"}, tmp_path / "out.png")
        assert result is False


# ─────────────────────────────────────────────────────────────────────────────
# _extract_structured_json() — JSON 스키마 프롬프트 검증
# ─────────────────────────────────────────────────────────────────────────────


class TestExtractStructuredJsonSchema:
    def test_prompt_contains_json_schema_keyword(self) -> None:
        """_extract_structured_json 프롬프트에 'JSON 스키마' 키워드가 포함된다."""
        mock_result = MagicMock()
        mock_result.stdout = '{"type": "infographic", "title": "t", "items": []}'
        mock_result.returncode = 0
        with patch("subprocess.run", return_value=mock_result) as mock_run:
            _extract_structured_json("설명", "infographic")
        call_args = mock_run.call_args
        cmd = call_args[0][0]
        prompt_arg = cmd[2]
        assert "JSON 스키마" in prompt_arg

    def test_prompt_contains_items_keyword(self) -> None:
        """_extract_structured_json 프롬프트에 'items' 키워드가 포함된다."""
        mock_result = MagicMock()
        mock_result.stdout = '{"type": "infographic", "title": "t", "items": []}'
        mock_result.returncode = 0
        with patch("subprocess.run", return_value=mock_result) as mock_run:
            _extract_structured_json("설명", "infographic")
        call_args = mock_run.call_args
        cmd = call_args[0][0]
        prompt_arg = cmd[2]
        assert "items" in prompt_arg

    def test_prompt_uses_haiku_model(self) -> None:
        """_extract_structured_json은 haiku 모델을 사용한다."""
        mock_result = MagicMock()
        mock_result.stdout = '{"type": "infographic", "title": "t", "items": []}'
        mock_result.returncode = 0
        with patch("subprocess.run", return_value=mock_result) as mock_run:
            _extract_structured_json("설명", "infographic")
        call_args = mock_run.call_args
        cmd = call_args[0][0]
        assert "haiku" in cmd


class TestValidateImageQuality:
    """_validate_image_quality() QC 게이트 테스트."""

    def test_nonexistent_file_fails(self, tmp_path: Path) -> None:
        """존재하지 않는 파일 → 실패."""
        from image_router import _validate_image_quality

        passed, warnings = _validate_image_quality(tmp_path / "no_such.png")
        assert passed is False
        assert "존재하지 않습니다" in warnings[0]

    def test_tiny_file_fails(self, tmp_path: Path) -> None:
        """15KB 미만 파일 → 실패 (빈 이미지 의심)."""
        from image_router import _validate_image_quality

        small_file = tmp_path / "tiny.png"
        small_file.write_bytes(b"\x00" * 100)
        passed, warnings = _validate_image_quality(small_file)
        assert passed is False
        assert "최소 15KB" in warnings[0]

    def test_valid_image_passes(self, tmp_path: Path) -> None:
        """정상 크기+해상도 이미지 → 통과."""
        from image_router import _validate_image_quality

        try:
            import numpy as np
            from PIL import Image
        except ImportError:
            pytest.skip("PIL/numpy 미설치")

        img = Image.fromarray(np.random.randint(50, 200, (500, 900, 3), dtype=np.uint8))
        out = tmp_path / "valid.png"
        img.save(out)
        passed, warnings = _validate_image_quality(out)
        assert passed is True

    def test_small_resolution_warns(self, tmp_path: Path) -> None:
        """해상도가 최소 기준 미달 → 경고."""
        from image_router import _validate_image_quality

        try:
            import numpy as np
            from PIL import Image
        except ImportError:
            pytest.skip("PIL/numpy 미설치")

        img = Image.fromarray(np.random.randint(50, 200, (200, 400, 3), dtype=np.uint8))
        out = tmp_path / "small.png"
        img.save(out)
        passed, warnings = _validate_image_quality(out)
        assert any("최소 800px" in w for w in warnings)
        assert any("최소 400px" in w for w in warnings)

    def test_very_dark_image_warns(self, tmp_path: Path) -> None:
        """매우 어두운 이미지 → 경고."""
        from image_router import _validate_image_quality

        try:
            import numpy as np
            from PIL import Image
        except ImportError:
            pytest.skip("PIL/numpy 미설치")

        # PNG 압축 우회를 위해 노이즈 섞어 15KB 이상 확보 (0~9 범위 → 평균 밝기 < 20)
        arr = np.random.randint(0, 10, (500, 900, 3), dtype=np.uint8)
        img = Image.fromarray(arr)
        out = tmp_path / "dark.png"
        img.save(out)
        passed, warnings = _validate_image_quality(out)
        assert any("너무 어둡습니다" in w for w in warnings)

    def test_very_bright_image_warns(self, tmp_path: Path) -> None:
        """매우 밝은 이미지 → 경고 (빈 이미지 의심)."""
        from image_router import _validate_image_quality

        try:
            import numpy as np
            from PIL import Image
        except ImportError:
            pytest.skip("PIL/numpy 미설치")

        # PNG 압축 우회를 위해 노이즈 섞어 15KB 이상 확보 (240~255 범위 → 평균 밝기 > 245)
        arr = np.random.randint(246, 256, (500, 900, 3), dtype=np.uint8)
        img = Image.fromarray(arr)
        out = tmp_path / "bright.png"
        img.save(out)
        passed, warnings = _validate_image_quality(out)
        assert any("너무 밝습니다" in w for w in warnings)

    def test_low_color_diversity_warns(self, tmp_path: Path) -> None:
        """색상이 매우 단조로운 이미지 → 경고."""
        from image_router import _validate_image_quality

        try:
            import numpy as np
            from PIL import Image
        except ImportError:
            pytest.skip("PIL/numpy 미설치")

        # 단색 기반에 0~2 미세 노이즈 → 고유 색상 50개 미만, 파일 크기 15KB 이상
        arr = np.full((500, 900, 3), 128, dtype=np.uint8)
        arr = np.clip(arr.astype(int) + np.random.randint(0, 3, arr.shape), 0, 255).astype(np.uint8)
        img = Image.fromarray(arr)
        out = tmp_path / "monotone.png"
        img.save(out)
        passed, warnings = _validate_image_quality(out)
        assert any("단조롭습니다" in w for w in warnings)
