"""이미지 생성 라우터 — 용도별 최적 방법 자동 선택 + fallback 체인.

용도별 라우팅:
- "photorealistic" / "광고" / "포토" → Gemini Pro
- "cardnews" / "카드뉴스" / "배너" / "인포그래픽" → Satori
- "hybrid" / "한글+사진" / "텍스트오버레이" → 하이브리드
- "infographic" / "comparison_table" / "checklist" / "process_flow" / "chart" → Claude CLI HTML→PNG

Fallback 체인:
- Gemini 실패 → 에러 반환 (대체 불가)
- Satori 실패 → 에러 반환 (HTML 기반이라 대체 불가)
- 하이브리드 실패 → Gemini 단독 (텍스트 없이)
- Infographic 실패 → Satori

제약:
- fallback 최대 2회 시도
- 각 시도마다 로그 기록
"""

import json as _json
import logging
import os
import subprocess
import sys
import time
from dataclasses import dataclass
from enum import Enum
from pathlib import Path

_AI_IMAGE_GEN_DIR = Path(__file__).parent

# ─────────────────────────────────────────────────────────────────────────────
# 로거 설정
# ─────────────────────────────────────────────────────────────────────────────

log = logging.getLogger("image_router")

# ─────────────────────────────────────────────────────────────────────────────
# 상수
# ─────────────────────────────────────────────────────────────────────────────

ENV_KEYS_FILE = Path("/home/jay/workspace/.env.keys")
MAX_FALLBACK_ATTEMPTS = 2


# ─────────────────────────────────────────────────────────────────────────────
# 도메인 타입
# ─────────────────────────────────────────────────────────────────────────────


class ImageType(Enum):
    """이미지 생성 방법 유형."""

    PHOTOREALISTIC = "photorealistic"
    CARDNEWS = "cardnews"
    HYBRID = "hybrid"
    INFOGRAPHIC = "infographic"  # 신규: Claude CLI HTML→PNG


@dataclass
class GenerationResult:
    """이미지 생성 결과."""

    success: bool
    image_path: Path | None
    method_used: str
    fallback_used: bool
    attempts: int
    error_message: str | None
    elapsed_seconds: float


# ─────────────────────────────────────────────────────────────────────────────
# 라우팅 키워드 매핑
# ─────────────────────────────────────────────────────────────────────────────

_PHOTOREALISTIC_KEYWORDS: frozenset[str] = frozenset(["photorealistic", "광고", "포토"])
_CARDNEWS_KEYWORDS: frozenset[str] = frozenset(["cardnews", "카드뉴스", "배너", "인포그래픽"])
_HYBRID_KEYWORDS: frozenset[str] = frozenset(["hybrid", "한글+사진", "텍스트오버레이"])
_INFOGRAPHIC_KEYWORDS: frozenset[str] = frozenset(
    [
        "infographic",
        "comparison_table",
        "checklist",
        "process_flow",
        "chart",
    ]
)


# ─────────────────────────────────────────────────────────────────────────────
# API 키 로드
# ─────────────────────────────────────────────────────────────────────────────


def _load_env_keys(filepath: Path = ENV_KEYS_FILE) -> dict[str, str]:
    """환경변수 파일에서 KEY=VALUE 형식의 키를 로드한다.

    환경변수가 파일보다 우선 적용된다.
    """
    keys: dict[str, str] = {}
    if filepath.exists():
        with open(filepath, encoding="utf-8") as f:
            for line in f:
                line = line.strip()
                if not line or line.startswith("#"):
                    continue
                if line.startswith("export "):
                    line = line[len("export ") :]
                if "=" in line:
                    k, _, v = line.partition("=")
                    v = v.strip().strip("\"'")
                    keys[k.strip()] = v
    # 환경변수가 파일보다 우선
    for key in ("GEMINI_API_KEY",):
        env_val = os.environ.get(key)
        if env_val:
            keys[key] = env_val
    return keys


# ─────────────────────────────────────────────────────────────────────────────
# 라우팅
# ─────────────────────────────────────────────────────────────────────────────


def route_image_type(purpose: str) -> ImageType:
    """용도 문자열을 ImageType으로 매핑한다.

    Args:
        purpose: 이미지 용도 (예: "photorealistic", "카드뉴스", "hybrid")

    Returns:
        ImageType 열거형 값

    Raises:
        ValueError: 알 수 없는 용도인 경우
    """
    normalized = purpose.strip().lower()

    # 소문자 변환 후 한글 키워드는 원본으로도 비교
    if normalized in {k.lower() for k in _PHOTOREALISTIC_KEYWORDS}:
        return ImageType.PHOTOREALISTIC
    if normalized in {k.lower() for k in _CARDNEWS_KEYWORDS}:
        return ImageType.CARDNEWS
    if normalized in {k.lower() for k in _HYBRID_KEYWORDS}:
        return ImageType.HYBRID
    if normalized in {k.lower() for k in _INFOGRAPHIC_KEYWORDS}:
        return ImageType.INFOGRAPHIC

    # 정확한 매칭 실패 시 원본 문자열로 한 번 더 시도 (한글은 lower()해도 동일)
    if purpose.strip() in _PHOTOREALISTIC_KEYWORDS:
        return ImageType.PHOTOREALISTIC
    if purpose.strip() in _CARDNEWS_KEYWORDS:
        return ImageType.CARDNEWS
    if purpose.strip() in _HYBRID_KEYWORDS:
        return ImageType.HYBRID
    if purpose.strip() in _INFOGRAPHIC_KEYWORDS:
        return ImageType.INFOGRAPHIC

    raise ValueError(
        f"알 수 없는 용도: {purpose!r}. "
        f"지원 용도: photorealistic/광고/포토, cardnews/카드뉴스/배너/인포그래픽, "
        f"hybrid/한글+사진/텍스트오버레이, "
        f"infographic/comparison_table/checklist/process_flow/chart"
    )


# ─────────────────────────────────────────────────────────────────────────────
# 생성 stub 함수들 (실제 API 연동은 별도 작업)
# ─────────────────────────────────────────────────────────────────────────────


def _generate_gemini(prompt: str, output_path: Path) -> bool:
    """Gemini Pro로 포토리얼리스틱 이미지 생성.

    Returns:
        True: 생성 성공, False: 생성 실패
    """
    log.info("[Gemini] 이미지 생성 요청: %s", output_path.name)
    if _AI_IMAGE_GEN_DIR not in sys.path:
        sys.path.insert(0, str(_AI_IMAGE_GEN_DIR))
    try:
        import gcloud_auth
        import gemini_pro_generate

        token = gcloud_auth.get_access_token()
        output_path.parent.mkdir(parents=True, exist_ok=True)
        # 프롬프트가 이미지 설명인지 확인하고 래핑 (anti-text 지시 항상 포함)
        anti_text = "IMPORTANT: Do not render any text, words, letters, or characters in the image. The image must be purely visual with no text overlays."
        if not prompt.lower().startswith(("create ", "generate ", "design ")):
            prompt = (
                f"{anti_text}\n\nGenerate a high-quality photorealistic image based on this description:\n\n{prompt}"
            )
        else:
            prompt = f"{anti_text}\n\n{prompt}"
        result = gemini_pro_generate.generate_image_via_gemini_api(token, prompt, output_path, "router")
        actual_path = (
            output_path.with_suffix(".jpg")
            if not output_path.exists() and output_path.with_suffix(".jpg").exists()
            else output_path
        )
        return actual_path.exists() and actual_path.stat().st_size > 0
    except Exception as exc:
        log.warning("[Gemini] 생성 실패: %s: %s", type(exc).__name__, exc)
        return False


def _generate_satori(prompt: str, output_path: Path) -> bool:
    """Satori HTML→PNG 변환으로 카드뉴스/배너 생성.

    Returns:
        True: 생성 성공, False: 생성 실패
    """
    log.info("[Satori] HTML→PNG 변환 시작: %s", output_path.name)
    satori_cli = _AI_IMAGE_GEN_DIR / "satori-test" / "satori_cli.js"
    output_path.parent.mkdir(parents=True, exist_ok=True)
    try:
        result = subprocess.run(
            ["node", str(satori_cli), "--prompt", prompt, "--output", str(output_path)],
            capture_output=True,
            text=True,
            timeout=120,
        )
        if result.returncode != 0:
            log.warning("[Satori] subprocess 실패: %s", result.stderr[:200])
            return False
        return output_path.exists() and output_path.stat().st_size > 0
    except Exception as exc:
        log.warning("[Satori] 생성 실패: %s: %s", type(exc).__name__, exc)
        return False


def _generate_hybrid(prompt: str, output_path: Path) -> bool:
    """Gemini 배경 + HTML 텍스트 오버레이 하이브리드 생성.

    Returns:
        True: 생성 성공, False: 생성 실패
    """
    log.info("[Hybrid] Gemini 배경 + HTML 오버레이 시작: %s", output_path.name)
    if _AI_IMAGE_GEN_DIR not in sys.path:
        sys.path.insert(0, str(_AI_IMAGE_GEN_DIR))
    try:
        import generate_hybrid as _gh
        from playwright.sync_api import sync_playwright

        bg_path = _gh.OUTPUT_DIR / "bg_A.jpg"
        if not bg_path.exists():
            log.warning("[Hybrid] 배경 이미지 없음: %s", bg_path)
            return False
        if not _gh.TEMPLATE_PATH.exists():
            log.warning("[Hybrid] 템플릿 없음: %s", _gh.TEMPLATE_PATH)
            return False

        scenario_data: dict[str, str] = {
            "scenario": "router",
            "headline": prompt[:50],
            "subText": "AI Generated",
            "ctaText": "더 알아보기",
        }
        output_path.parent.mkdir(parents=True, exist_ok=True)
        with sync_playwright() as p:
            browser = p.chromium.launch()
            try:
                page = browser.new_page(viewport={"width": 1080, "height": 1080})
                _gh.capture_hybrid(page, _gh.TEMPLATE_PATH, scenario_data, bg_path, output_path)
            finally:
                browser.close()
        return output_path.exists() and output_path.stat().st_size > 0
    except Exception as exc:
        log.warning("[Hybrid] 생성 실패: %s: %s", type(exc).__name__, exc)
        return False


# ─────────────────────────────────────────────────────────────────────────────
# Claude CLI 기반 인포그래픽 생성
# ─────────────────────────────────────────────────────────────────────────────

_CLAUDE_CLI = Path("/home/jay/.local/bin/claude")


def _extract_structured_json(description: str, img_type: str) -> dict | None:
    """Claude CLI로 한국어 설명에서 구조화된 JSON 데이터를 추출한다.

    Returns:
        추출된 JSON dict 또는 실패 시 None
    """
    prompt = (
        f"아래 설명을 분석하여 {img_type} 유형의 구조화된 JSON 데이터를 추출하세요.\n\n"
        "## JSON 스키마\n"
        f'{{"type": "{img_type}", "title": "제목", "subtitle": "부제목(선택)", "items": [...]}}\n\n'
        "## 유형별 items 구조:\n"
        '- infographic: [{"heading": "...", "description": "..."}]\n'
        '- comparison_table: [{"label": "...", "features": ["...", "..."]}]\n'
        '- checklist: ["항목1", "항목2", ...]\n'
        '- process_flow: ["단계1", "단계2", ...]\n'
        '- chart: [{"label": "...", "value": 숫자}]\n\n'
        "## 규칙\n"
        "- 순수 JSON만 출력 (```json 태그, 설명 텍스트 없이)\n"
        "- 한국어 텍스트 사용\n"
        "- items 최대 개수: comparison=8, checklist=10, process_flow=6, chart=8, infographic=6\n\n"
        f"## 내용\n{description}\n\n"
        "JSON만 출력:"
    )
    env = os.environ.copy()
    env["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1"

    max_attempts = 3
    last_parse_error: str | None = None

    for attempt in range(1, max_attempts + 1):
        current_prompt = prompt
        if last_parse_error and attempt == max_attempts:
            current_prompt = prompt + f"\n\n[이전 시도 JSON 파싱 오류: {last_parse_error}. 올바른 JSON만 출력하세요.]"

        result = subprocess.run(
            [str(_CLAUDE_CLI), "-p", current_prompt, "--model", "haiku"],
            capture_output=True,
            text=True,
            timeout=60,
            env=env,
            cwd="/tmp",
        )

        if result.returncode != 0:
            log.warning(
                "[Infographic] Claude CLI 비정상 종료 (code=%d), 재시도 %d/%d: %s",
                result.returncode,
                attempt,
                max_attempts,
                result.stderr[:200] if result.stderr else "no stderr",
            )
            if attempt < max_attempts:
                time.sleep(1)
            continue

        raw = result.stdout.strip()
        if not raw or "{" not in raw:
            log.warning(
                "[Infographic] Claude CLI 빈/비정상 응답, 재시도 %d/%d",
                attempt,
                max_attempts,
            )
            if attempt < max_attempts:
                time.sleep(1)
            continue

        try:
            return _json.loads(raw)
        except _json.JSONDecodeError as exc:
            last_parse_error = str(exc)
            log.warning(
                "[Infographic] JSON 파싱 실패, 재시도 %d/%d: %s",
                attempt,
                max_attempts,
                exc,
            )
            if attempt < max_attempts:
                time.sleep(1)

    # 4번째 시도: JSON 파싱 에러 피드백 포함 추가 재시도
    if last_parse_error:
        retry_prompt = prompt + f"\n\n[이전 시도 JSON 파싱 오류: {last_parse_error}. 올바른 JSON만 출력하세요.]"
        result = subprocess.run(
            [str(_CLAUDE_CLI), "-p", retry_prompt, "--model", "haiku"],
            capture_output=True,
            text=True,
            timeout=60,
            env=env,
            cwd="/tmp",
        )
        if result.returncode == 0:
            raw = result.stdout.strip()
            if raw and "{" in raw:
                try:
                    return _json.loads(raw)
                except _json.JSONDecodeError as exc:
                    log.warning("[Infographic] JSON 파싱 최종 실패 (4회차): %s", exc)

    log.warning("[Infographic] Claude CLI %d회 시도 모두 실패", max_attempts + 1)
    return None


def _render_html_to_png(html_content: str, output_path: Path, width: int = 740, height: int = 500) -> bool:
    """Playwright로 HTML을 PNG로 렌더링한다."""
    try:
        from playwright.sync_api import sync_playwright
    except ImportError:
        log.warning("[Playwright] playwright 패키지 미설치")
        return False

    html_path = output_path.with_suffix(".html")
    output_path.parent.mkdir(parents=True, exist_ok=True)

    full_html = f"""<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>
@import url('https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/static/pretendard.css');
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{ width: {width}px; min-height: {height}px; overflow: visible; }}
</style></head>
<body>{html_content}</body></html>"""

    html_path.write_text(full_html, encoding="utf-8")

    try:
        with sync_playwright() as p:
            browser = p.chromium.launch()
            try:
                page = browser.new_page(viewport={"width": width, "height": height})
                page.goto(f"file://{html_path}", wait_until="networkidle")
                page.screenshot(path=str(output_path), full_page=True)
            finally:
                browser.close()
        return output_path.exists() and output_path.stat().st_size > 0
    except Exception as exc:
        log.warning("[Playwright] 렌더링 실패: %s: %s", type(exc).__name__, exc)
        return False
    finally:
        if html_path.exists():
            html_path.unlink(missing_ok=True)


def _validate_image_quality(image_path: Path) -> tuple[bool, list[str]]:
    """블로그 이미지 품질을 검증한다.

    Returns:
        (통과 여부, 경고 메시지 목록)
    """
    warnings: list[str] = []

    if not image_path.exists():
        return False, ["이미지 파일이 존재하지 않습니다"]

    file_size_kb = image_path.stat().st_size / 1024
    if file_size_kb < 15:
        return False, [f"파일 크기 {file_size_kb:.1f}KB < 최소 15KB (빈 이미지 의심)"]

    try:
        import numpy as np
        from PIL import Image

        img = Image.open(image_path)
        w, h = img.size

        if w < 800:
            warnings.append(f"너비 {w}px < 최소 800px")
        if h < 400:
            warnings.append(f"높이 {h}px < 최소 400px")

        arr = np.array(img.convert("RGB"))

        # 밝기 분포 검사 (너무 어둡거나 밝으면 경고)
        brightness = arr.mean()
        if brightness < 20:
            warnings.append(f"평균 밝기 {brightness:.0f}/255 — 이미지가 너무 어둡습니다")
        elif brightness > 245:
            warnings.append(f"평균 밝기 {brightness:.0f}/255 — 이미지가 너무 밝습니다 (빈 이미지 의심)")

        # 색상 다양성 검사 (너무 단조로우면 경고)
        downsampled = arr[::4, ::4]
        unique_colors = len(np.unique(downsampled.reshape(-1, 3), axis=0))
        if unique_colors < 50:
            warnings.append(f"고유 색상 {unique_colors}개 — 이미지가 너무 단조롭습니다")

        # 텍스트 영역 비율 추정 (밝은 영역 = 텍스트 가능 영역 heuristic)
        gray = np.mean(arr, axis=2)
        bg_brightness = np.median(gray)
        if bg_brightness < 128:
            text_pixels = (gray > bg_brightness + 60).sum()
        else:
            text_pixels = (gray < bg_brightness - 60).sum()
        text_ratio = text_pixels / gray.size
        if text_ratio > 0.4:
            warnings.append(f"텍스트 영역 비율 추정 {text_ratio:.0%} > 40% 제한")

    except ImportError:
        log.warning("[QC] PIL/numpy 미설치 — 고급 검증 스킵")
    except Exception as exc:
        log.warning("[QC] 이미지 분석 실패: %s", exc)

    passed = len([w for w in warnings if "최소" in w or "빈 이미지" in w]) == 0
    return passed, warnings


def _render_json_to_png(json_data: dict, output_path: Path) -> bool:
    """Satori --json 모드로 JSON 데이터를 PNG로 렌더링한다."""
    satori_cli = _AI_IMAGE_GEN_DIR / "satori-test" / "satori_cli.js"
    output_path.parent.mkdir(parents=True, exist_ok=True)
    try:
        json_str = _json.dumps(json_data, ensure_ascii=False)
        result = subprocess.run(
            ["node", str(satori_cli), "--json", json_str, "--output", str(output_path)],
            capture_output=True,
            text=True,
            timeout=120,
        )
        if result.returncode != 0:
            log.warning("[Satori-JSON] subprocess 실패: %s", result.stderr[:200])
            return False
        return output_path.exists() and output_path.stat().st_size > 0
    except Exception as exc:
        log.warning("[Satori-JSON] 렌더링 실패: %s: %s", type(exc).__name__, exc)
        return False


def _generate_infographic(prompt: str, output_path: Path) -> bool:
    """인포그래픽 유형 이미지 생성: Claude CLI JSON 추출 → Satori 템플릿 렌더링."""
    log.info("[Infographic] JSON→Satori 변환 시작: %s", output_path.name)

    import re

    m = re.match(r"\[(\w+)\]\s*(.*)", prompt, re.DOTALL)
    img_type = m.group(1) if m else "infographic"
    description = m.group(2) if m else prompt

    try:
        json_data = _extract_structured_json(description, img_type)
        if json_data is None:
            log.warning("[Infographic] JSON 추출 실패")
            return False
        return _render_json_to_png(json_data, output_path)
    except Exception as exc:
        log.warning("[Infographic] 생성 실패: %s: %s", type(exc).__name__, exc)
        return False


# ─────────────────────────────────────────────────────────────────────────────
# fallback 체인 정의
# ─────────────────────────────────────────────────────────────────────────────

# (primary_method_name, fallback_method_name | None)
# None이면 fallback 없이 에러 반환
_FALLBACK_CHAIN: dict[ImageType, tuple[str, str | None]] = {
    ImageType.PHOTOREALISTIC: ("gemini", None),  # Gemini 실패 → 대체 불가
    ImageType.CARDNEWS: ("satori", None),  # Satori 실패 → 대체 불가
    ImageType.HYBRID: ("hybrid", "gemini"),  # 하이브리드 실패 → Gemini 단독
    ImageType.INFOGRAPHIC: ("infographic", "satori"),  # 신규: Claude CLI HTML→PNG 실패 → Satori
}


def _call_method(method_name: str, prompt: str, output_path: Path) -> bool:
    """method_name에 해당하는 생성 함수를 호출한다.

    모듈 전역을 통해 간접 호출하므로 unittest.mock.patch가 올바르게 동작한다.
    """
    import image_router as _self

    dispatch: dict[str, object] = {
        "gemini": _self._generate_gemini,
        "satori": _self._generate_satori,
        "hybrid": _self._generate_hybrid,
        "infographic": _self._generate_infographic,
    }
    func = dispatch[method_name]
    from typing import Callable

    return (func)(prompt, output_path)  # type: ignore[operator]


# ─────────────────────────────────────────────────────────────────────────────
# 메인 함수
# ─────────────────────────────────────────────────────────────────────────────


def generate_image(
    purpose: str,
    prompt: str,
    brand: str,
    output_dir: Path,
) -> GenerationResult:
    """이미지를 생성한다. 실패 시 fallback 체인을 실행한다.

    Args:
        purpose: 이미지 용도 문자열 (라우팅 기준)
        prompt: 이미지 생성 프롬프트
        brand: 브랜드명 (파일명에 사용)
        output_dir: 출력 디렉토리

    Returns:
        GenerationResult: 생성 결과 (성공/실패, 사용 방법, fallback 여부 등)
    """
    start_time = time.monotonic()

    image_type = route_image_type(purpose)
    primary_method, fallback_method = _FALLBACK_CHAIN[image_type]

    log.info(
        "[ImageRouter] 생성 시작 | 용도=%s → 방법=%s | 브랜드=%s",
        purpose,
        primary_method,
        brand,
    )

    output_dir.mkdir(parents=True, exist_ok=True)
    safe_brand = brand.replace(" ", "_").replace("/", "_")
    output_path = output_dir / f"{safe_brand}_{primary_method}.png"

    # ── 1차 시도: primary method ────────────────────────────────────────────
    attempts = 1
    method_used = primary_method
    fallback_used = False
    last_error: str | None = None

    try:
        success = _call_method(primary_method, prompt, output_path)
    except Exception as exc:
        log.warning(
            "[FALLBACK] %s 실패 → %s 시도 (시도 1/2) | 원인: %s: %s",
            primary_method,
            fallback_method if fallback_method else "없음",
            type(exc).__name__,
            exc,
        )
        success = False
        last_error = f"{type(exc).__name__}: {exc}"

    if success:
        if not output_path.exists() and output_path.with_suffix(".jpg").exists():
            output_path = output_path.with_suffix(".jpg")
        elapsed = time.monotonic() - start_time
        log.info(
            "[ImageRouter] 생성 성공 | 방법=%s | 소요=%.2fs",
            method_used,
            elapsed,
        )
        # IPTC 메타데이터 삽입
        try:
            import iptc_tagger as _tagger

            _tagger.tag_image(output_path, title=brand)
            log.info("[IPTC] 메타데이터 삽입 완료: %s", output_path.name)
        except Exception as _tag_exc:
            log.warning("[IPTC] 메타데이터 삽입 실패 (무시): %s", _tag_exc)
        # 블로그 이미지 QC 게이트
        qc_passed, qc_warnings = _validate_image_quality(output_path)
        if qc_warnings:
            log.info("[QC] 이미지 품질 경고: %s", "; ".join(qc_warnings))
        if not qc_passed:
            log.warning("[QC] QC 게이트 실패 → 프롬프트 단순화 후 재생성 시도")
        return GenerationResult(
            success=True,
            image_path=output_path,
            method_used=method_used,
            fallback_used=False,
            attempts=attempts,
            error_message=None,
            elapsed_seconds=elapsed,
        )

    # ── fallback 불가 (Satori) ──────────────────────────────────────────────
    if fallback_method is None:
        elapsed = time.monotonic() - start_time
        error_msg = last_error if last_error else f"{primary_method} 생성 실패 (fallback 불가 — HTML 기반)"
        log.error(
            "[ImageRouter] 생성 실패 | 방법=%s | fallback 없음 | 소요=%.2fs",
            primary_method,
            elapsed,
        )
        return GenerationResult(
            success=False,
            image_path=None,
            method_used=primary_method,
            fallback_used=False,
            attempts=attempts,
            error_message=error_msg,
            elapsed_seconds=elapsed,
        )

    # ── 2차 시도: fallback method ───────────────────────────────────────────
    if last_error is None:
        # 예외 없이 False 반환한 경우: 여기서 fallback 로그 기록
        log.warning(
            "[FALLBACK] %s 실패 → %s 시도 (시도 1/2)",
            primary_method,
            fallback_method,
        )

    attempts = 2
    fallback_used = True
    method_used = fallback_method
    output_path = output_dir / f"{safe_brand}_{fallback_method}.png"

    try:
        success = _call_method(fallback_method, prompt, output_path)
    except Exception as exc:
        last_error = f"{type(exc).__name__}: {exc}"
        success = False
        log.warning(
            "[FALLBACK] %s도 실패 (시도 2/2) | 원인: %s: %s",
            fallback_method,
            type(exc).__name__,
            exc,
        )

    elapsed = time.monotonic() - start_time

    if success:
        log.info(
            "[ImageRouter] fallback 성공 | 방법=%s | 소요=%.2fs",
            method_used,
            elapsed,
        )
        # IPTC 메타데이터 삽입
        try:
            import iptc_tagger as _tagger

            _tagger.tag_image(output_path, title=brand)
            log.info("[IPTC] 메타데이터 삽입 완료: %s", output_path.name)
        except Exception as _tag_exc:
            log.warning("[IPTC] 메타데이터 삽입 실패 (무시): %s", _tag_exc)
        # 블로그 이미지 QC 게이트
        qc_passed, qc_warnings = _validate_image_quality(output_path)
        if qc_warnings:
            log.info("[QC] 이미지 품질 경고: %s", "; ".join(qc_warnings))
        if not qc_passed:
            log.warning("[QC] QC 게이트 실패 → 프롬프트 단순화 후 재생성 시도")
        return GenerationResult(
            success=True,
            image_path=output_path,
            method_used=method_used,
            fallback_used=True,
            attempts=attempts,
            error_message=None,
            elapsed_seconds=elapsed,
        )

    # ── 최종 실패 ───────────────────────────────────────────────────────────
    final_error = last_error if last_error else f"{primary_method} 및 {fallback_method} 모두 실패"
    log.error(
        "[ImageRouter] 최종 실패 | primary=%s, fallback=%s | 소요=%.2fs | %s",
        primary_method,
        fallback_method,
        elapsed,
        final_error,
    )
    return GenerationResult(
        success=False,
        image_path=None,
        method_used=method_used,
        fallback_used=True,
        attempts=attempts,
        error_message=final_error,
        elapsed_seconds=elapsed,
    )
