"""quality_evaluator.py — IDS Phase 1 이미지 품질 평가 모듈.

목적:
  task-2389(한글 깨짐), task-2401(silent corruption: 5장 단조 그라데이션+박스+텍스트 1종 패턴)
  재발 영구 차단. 시각 다양성·브랜드 색상·hybrid 패턴·폰트 크기·OCR 신뢰도를
  코드 수치로 검증하여 인간 평가 없이 PASS/FAIL 판정.

회장 3원칙:
  - 더 가볍게: 외부 API 호출 0, 로컬 분석만
  - 더 정확하게: 코드 수치 기반 판정
  - 더 퀄리티 높게: retry-until-pass 연동 (retry_loop.py)
"""

from __future__ import annotations

import json
import math
import random
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any

import numpy as np
from PIL import Image

# pytesseract는 선택적 의존성 (시스템 tesseract 설치 필요)
try:
    import pytesseract
    from pytesseract import Output as TesseractOutput
    _TESSERACT_AVAILABLE = True
except ImportError:
    _TESSERACT_AVAILABLE = False

# dq-rules.json 단일 소스 경로 (수정 금지, 참조만)
_DQ_RULES_PATH = Path("/home/jay/workspace/memory/specs/dq-rules.json")

# ─── 평가 임계값 모듈 상수 (Codex 사전 검증 high #4 해소: 정량화) ───────
# 시각 다양성
VISUAL_STD_THRESHOLD: float = 25.0          # RGB 채널 평균 std 임계 (silent corruption 차단)
VISUAL_UNIQUE_COLOR_THRESHOLD: int = 1000   # 샘플링 unique color 최소 (단조 박스 차단)
# 공간 일관성 (로키 G2 적대적 평가 CRITICAL #C-NEW 해소)
# TV-static 노이즈는 인접 픽셀 차이 평균이 매우 높음 (구조 없는 random noise).
# 자연 사진/일러스트/그라디언트는 인접 픽셀 차이 평균이 임계 이하로 spatial coherence 보유.
SPATIAL_COHERENCE_DIFF_MAX: float = 25.0    # 인접 픽셀 차이 평균 임계 — 초과 시 unstructured noise 의심
# 브랜드 색
BRAND_DELTA_E_THRESHOLD: float = 30.0       # CIE76 ΔE 임계 (인지적 동일색 영역 한계)
BRAND_AREA_RATIO_MIN: float = 0.10          # 브랜드 색 영역 비율 최소 (로키 MEDIUM #4-EDGE 해소)
# OCR
OCR_CONFIDENCE_THRESHOLD: float = 70.0      # 한글 OCR confidence 평균 (task-2389 회귀 차단)
OCR_KOREAN_RATIO_MIN: float = 0.5           # 추출 텍스트 한글 유니코드 비율 (로키 LOW #4-OCR 해소)
# 5 hybrid 패턴별 정량 임계 (Codex 사전 검증 high #4 — N 명시)
PATTERN_THRESHOLDS: dict[str, dict[str, float]] = {
    "h1_photo_card":        {"edge_density_min": 0.05},
    "h2_illustration_card": {"unique_colors_min": 5000.0, "saturation_min": 0.40},
    "h3_gpt_style_card":    {"edge_density_min": 0.03, "edge_density_max": 0.08, "saturation_std_min": 0.10},
    "h4_gradient_card":     {"smoothness_max": 5.0},
    "h5_user_photo_card":   {"noise_ratio_min": 0.15},
}

# dq-rules.json 최소 스키마 (Codex 사전 검증 medium #5 해소)
_DQ_RULES_REQUIRED_KEYS: dict[str, list[str]] = {
    "font_sizes": ["absolute_min"],
}


def _validate_dq_rules_schema(rules: dict[str, Any]) -> None:
    """dq-rules.json이 evaluator가 기대하는 최소 스키마를 만족하는지 검증한다.

    누락 시 즉시 RuntimeError 발생 (silent pass 영구 차단).
    """
    for top_key, required_subkeys in _DQ_RULES_REQUIRED_KEYS.items():
        if top_key not in rules:
            raise RuntimeError(
                f"dq-rules.json schema violation: missing top-level key '{top_key}'"
            )
        for sub in required_subkeys:
            if sub not in rules[top_key]:
                raise RuntimeError(
                    f"dq-rules.json schema violation: missing key '{top_key}.{sub}'"
                )


# ---------------------------------------------------------------------------
# Dataclass
# ---------------------------------------------------------------------------

@dataclass
class EvalResult:
    """evaluate_image 반환 결과."""

    passed: bool
    score: int  # 0~100
    fail_reasons: list[str] = field(default_factory=list)
    retry_hints: dict[str, Any] = field(default_factory=dict)
    details: dict[str, Any] = field(default_factory=dict)  # 각 검증 결과 상세


# ---------------------------------------------------------------------------
# 내부 유틸: RGB → Lab 변환 (D65 광원, sRGB 가정)
# colormath 미설치 환경을 위한 순수 Python 구현
# ---------------------------------------------------------------------------

def _srgb_to_linear(c: float) -> float:
    """sRGB 감마 보정 채널(0~255)을 선형 값으로 변환한다."""
    cv = c / 255.0
    if cv <= 0.04045:
        return cv / 12.92
    return ((cv + 0.055) / 1.055) ** 2.4


def _linear_to_xyz(r: float, g: float, b: float) -> tuple[float, float, float]:
    """선형 sRGB를 CIE XYZ (D65)로 변환한다."""
    x = r * 0.4124564 + g * 0.3575761 + b * 0.1804375
    y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750
    z = r * 0.0193339 + g * 0.1191920 + b * 0.9503041
    return x, y, z


def _xyz_to_lab(x: float, y: float, z: float) -> tuple[float, float, float]:
    """CIE XYZ를 CIE Lab으로 변환한다 (D65 기준 백색점)."""
    xn, yn, zn = 0.95047, 1.00000, 1.08883

    def f(t: float) -> float:
        if t > 0.008856:
            return t ** (1.0 / 3.0)
        return 7.787 * t + 16.0 / 116.0

    fx = f(x / xn)
    fy = f(y / yn)
    fz = f(z / zn)

    L = 116.0 * fy - 16.0
    a = 500.0 * (fx - fy)
    b_val = 200.0 * (fy - fz)
    return L, a, b_val


def _hex_to_lab(hex_color: str) -> tuple[float, float, float]:
    """HEX 색상 문자열을 CIE Lab으로 변환한다."""
    hx = hex_color.lstrip("#")
    r = int(hx[0:2], 16)
    g = int(hx[2:4], 16)
    b = int(hx[4:6], 16)
    lr = _srgb_to_linear(r)
    lg = _srgb_to_linear(g)
    lb = _srgb_to_linear(b)
    x, y, z = _linear_to_xyz(lr, lg, lb)
    return _xyz_to_lab(x, y, z)


def _rgb_to_lab(r: int, g: int, b: int) -> tuple[float, float, float]:
    """RGB 정수값을 CIE Lab으로 변환한다."""
    lr = _srgb_to_linear(r)
    lg = _srgb_to_linear(g)
    lb = _srgb_to_linear(b)
    x, y, z = _linear_to_xyz(lr, lg, lb)
    return _xyz_to_lab(x, y, z)


def _delta_e(lab1: tuple[float, float, float], lab2: tuple[float, float, float]) -> float:
    """두 Lab 색상 사이의 CIE76 ΔE 거리를 계산한다."""
    return math.sqrt(
        (lab1[0] - lab2[0]) ** 2
        + (lab1[1] - lab2[1]) ** 2
        + (lab1[2] - lab2[2]) ** 2
    )


# ---------------------------------------------------------------------------
# 검증 함수 1: 시각 다양성 — silent corruption 영구 차단 핵심
# ---------------------------------------------------------------------------

def check_visual_diversity(img: Image.Image) -> dict[str, Any]:
    """RGB 히스토그램 + 공간 일관성으로 단조/노이즈 corruption 1종 패턴을 검출한다.

    검출 대상:
      - 단조 그라데이션/박스 (std/unique colors 부족)
      - TV-static 같은 unstructured noise (spatial coherence 부재)
        — 로키 G2 적대적 평가 CRITICAL #C-NEW 해소
    """
    arr = np.array(img.convert("RGB"), dtype=np.float32)

    # 채널별 std 계산
    std_r = float(np.std(arr[:, :, 0]))
    std_g = float(np.std(arr[:, :, 1]))
    std_b = float(np.std(arr[:, :, 2]))
    std_mean = (std_r + std_g + std_b) / 3.0

    # unique color count (샘플링으로 속도 확보, 최대 5만 픽셀)
    h, w, _ = arr.shape
    step = max(1, (h * w) // 50000)
    flat = arr[::step, ::step, :].reshape(-1, 3).astype(np.uint8)
    unique_colors = int(len(set(map(tuple, flat.tolist()))))

    # 공간 일관성: 인접 픽셀 차이 평균. unstructured noise는 매우 높음.
    # 자연 이미지/그라디언트/일러스트는 spatial autocorrelation으로 임계 이하.
    diff_h = float(np.abs(np.diff(arr, axis=1)).mean())
    diff_v = float(np.abs(np.diff(arr, axis=0)).mean())
    spatial_diff = (diff_h + diff_v) / 2.0

    reasons: list[str] = []
    if std_mean < VISUAL_STD_THRESHOLD:
        reasons.append(
            f"단조 그라데이션 의심: RGB std 평균 {std_mean:.2f} < {VISUAL_STD_THRESHOLD}"
        )
    if unique_colors < VISUAL_UNIQUE_COLOR_THRESHOLD:
        reasons.append(
            f"색상 다양성 부족: unique colors {unique_colors} < {VISUAL_UNIQUE_COLOR_THRESHOLD}"
        )
    if spatial_diff > SPATIAL_COHERENCE_DIFF_MAX:
        reasons.append(
            f"공간 일관성 부족 (TV-static 의심): "
            f"인접 픽셀 차이 평균 {spatial_diff:.2f} > {SPATIAL_COHERENCE_DIFF_MAX}"
        )

    passed = len(reasons) == 0
    score = 20 if passed else max(
        0,
        int(20 * min(std_mean / VISUAL_STD_THRESHOLD, 1.0) * 0.4
            + 20 * min(unique_colors / VISUAL_UNIQUE_COLOR_THRESHOLD, 1.0) * 0.3
            + 20 * min(SPATIAL_COHERENCE_DIFF_MAX / max(spatial_diff, 1.0), 1.0) * 0.3),
    )

    return {
        "passed": passed,
        "score": score,
        "std_mean": round(std_mean, 4),
        "unique_colors": unique_colors,
        "spatial_diff": round(spatial_diff, 4),
        "reason": "; ".join(reasons) if reasons else None,
        "retry_hint": {
            "force_pattern_diversity": True,
            "force_spatial_coherence": True,
            "suggested_seed": random.randint(0, 2**31),
        },
    }


# ---------------------------------------------------------------------------
# 검증 함수 2: 브랜드 색상 일치 — 132 design-md 브랜드 적용 강제
# ---------------------------------------------------------------------------

def check_brand_color_match(img: Image.Image, design_md: dict[str, Any]) -> dict[str, Any]:
    """design_md primary 색과 이미지 dominant colors의 Lab ΔE + 면적 비율을 검증한다.

    면적 비율 검증 (로키 G2 적대적 평가 MEDIUM #4-EDGE 해소):
      brand 색이 1% 영역만 차지해도 PASS되는 우회를 차단.
      매칭 색상 영역 ≥ 10% (BRAND_AREA_RATIO_MIN) 요구.
    """
    primary_hex: str = design_md.get("primary", "#000000")

    # dominant colors + 픽셀 카운트: PIL quantize 사용
    dominant_colors: list[tuple[int, int, int]] = []
    color_counts: list[int] = []
    total_pixels = 1  # 0 division 방지
    try:
        rgb_img = img.convert("RGB")
        quantized = rgb_img.quantize(colors=5, method=Image.Quantize.FASTOCTREE)
        palette = quantized.getpalette() or []
        for i in range(min(5, len(palette) // 3)):
            dominant_colors.append((palette[i * 3], palette[i * 3 + 1], palette[i * 3 + 2]))
        # 각 색상 인덱스 픽셀 카운트
        idx_arr = np.array(quantized)
        total_pixels = int(idx_arr.size)
        for i in range(len(dominant_colors)):
            color_counts.append(int(np.sum(idx_arr == i)))
    except Exception:
        # fallback: numpy 평균 색상 (면적 검증 무력화 — 정상 경로 아님)
        arr_fb = np.array(img.convert("RGB"))
        h_fb, w_fb, _ = arr_fb.shape
        step_fb = max(1, (h_fb * w_fb) // 10000)
        flat = arr_fb.reshape(-1, 3)[::step_fb]
        mean_color = flat.mean(axis=0).astype(int)
        dominant_colors = [(int(mean_color[0]), int(mean_color[1]), int(mean_color[2]))]
        color_counts = [h_fb * w_fb]
        total_pixels = h_fb * w_fb

    # primary HEX → Lab
    try:
        primary_lab = _hex_to_lab(primary_hex)
    except Exception:
        primary_lab = (0.0, 0.0, 0.0)

    # ΔE 계산 + 면적 비율 동시 추적
    delta_es: list[float] = []
    for rgb in dominant_colors:
        try:
            color_lab = _rgb_to_lab(rgb[0], rgb[1], rgb[2])
            delta_es.append(_delta_e(primary_lab, color_lab))
        except Exception:
            delta_es.append(999.0)

    # 매칭(ΔE < 임계)된 색상의 누적 면적 비율
    matching_area = 0
    for de, cnt in zip(delta_es, color_counts):
        if de < BRAND_DELTA_E_THRESHOLD:
            matching_area += cnt
    matching_area_ratio = matching_area / total_pixels if total_pixels > 0 else 0.0

    min_delta_e = float(min(delta_es)) if delta_es else 999.0

    delta_e_ok = min_delta_e < BRAND_DELTA_E_THRESHOLD
    area_ok = matching_area_ratio >= BRAND_AREA_RATIO_MIN
    passed = delta_e_ok and area_ok
    score = 25 if passed else max(0, int(25 * max(0.0, 1.0 - min_delta_e / 60.0)
                                         * min(matching_area_ratio / BRAND_AREA_RATIO_MIN, 1.0)))

    reasons: list[str] = []
    if not delta_e_ok:
        reasons.append(
            f"브랜드 색상 불일치: min ΔE {min_delta_e:.2f} ≥ {BRAND_DELTA_E_THRESHOLD}"
            f" (primary={primary_hex})"
        )
    if not area_ok:
        reasons.append(
            f"브랜드 색 영역 부족: 매칭 영역 비율 {matching_area_ratio:.4f}"
            f" < {BRAND_AREA_RATIO_MIN} (1% 영역만으로 PASS 우회 차단)"
        )
    reason = "; ".join(reasons) if reasons else None

    return {
        "passed": passed,
        "score": score,
        "min_delta_e": round(min_delta_e, 4),
        "matching_area_ratio": round(matching_area_ratio, 4),
        "primary_hex": primary_hex,
        "reason": reason,
        "retry_hint": {
            "force_brand_color": primary_hex,
            "min_color_area_ratio": BRAND_AREA_RATIO_MIN,
        },
    }


# ---------------------------------------------------------------------------
# 검증 함수 3: hybrid 패턴 특징 — 5 패턴 분화 강제
# ---------------------------------------------------------------------------

def _sobel_edge_density(arr_gray: np.ndarray) -> float:
    """Sobel 엣지 밀도를 계산한다 (흑백 float32 numpy 배열)."""
    from numpy.lib.stride_tricks import sliding_window_view

    sobel_x = np.array([[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]], dtype=np.float32)
    sobel_y = sobel_x.T

    windows = sliding_window_view(arr_gray, (3, 3))
    gx = (windows * sobel_x).sum(axis=(-2, -1))
    gy = (windows * sobel_y).sum(axis=(-2, -1))
    magnitude = np.sqrt(gx ** 2 + gy ** 2)
    return float(np.mean(magnitude > 30))


def _mean_saturation(img: Image.Image) -> float:
    """이미지 평균 채도를 HSV 기준으로 계산한다 (0~1)."""
    arr = np.array(img.convert("RGB"), dtype=np.float32) / 255.0
    max_c = arr.max(axis=2)
    min_c = arr.min(axis=2)
    diff = max_c - min_c
    sat = np.where(max_c > 0, diff / max_c, 0.0)
    return float(sat.mean())


def _unique_color_count_sampled(arr: np.ndarray) -> int:
    """이미지 배열의 unique color 수를 샘플링으로 반환한다."""
    h, w, _ = arr.shape
    step = max(1, (h * w) // 100000)
    flat = arr.reshape(-1, 3)[::step].astype(np.uint8)
    return int(len(set(map(tuple, flat.tolist()))))


def _check_h1_photo_card(arr_gray: np.ndarray) -> tuple[bool, float, str | None]:
    """H1 포토 카드: Sobel edge density > 0.05 → 사진 영역 인정."""
    density = _sobel_edge_density(arr_gray)
    passed = density > 0.05
    reason = None if passed else f"H1 사진 영역 부족: edge_density {density:.4f} ≤ 0.05"
    return passed, density, reason


def _check_h2_illustration_card(
    arr: np.ndarray, img: Image.Image
) -> tuple[bool, float, str | None]:
    """H2 일러스트 카드: unique colors > 5000 AND 평균 채도 > 0.4."""
    unique = _unique_color_count_sampled(arr)
    sat = _mean_saturation(img)
    passed = unique > 5000 and sat > 0.4
    reasons: list[str] = []
    if unique <= 5000:
        reasons.append(f"unique colors {unique} ≤ 5000")
    if sat <= 0.4:
        reasons.append(f"평균 채도 {sat:.4f} ≤ 0.4")
    return passed, float(unique), ("; ".join(reasons) if reasons else None)


def _check_h3_gpt_style_card(
    arr_gray: np.ndarray, img: Image.Image
) -> tuple[bool, float, str | None]:
    """H3 GPT 스타일 카드: edge density 0.03~0.08 AND 채도 다양성 std > 0.1."""
    density = _sobel_edge_density(arr_gray)
    arr = np.array(img.convert("RGB"), dtype=np.float32) / 255.0
    max_c = arr.max(axis=2)
    min_c = arr.min(axis=2)
    diff = max_c - min_c
    sat = np.where(max_c > 0, diff / max_c, 0.0)
    sat_std = float(sat.std())

    passed = (0.03 <= density <= 0.08) and sat_std > 0.1
    reasons: list[str] = []
    if not (0.03 <= density <= 0.08):
        reasons.append(f"edge_density {density:.4f} 중간 범위(0.03~0.08) 벗어남")
    if sat_std <= 0.1:
        reasons.append(f"채도 다양성 부족: std {sat_std:.4f} ≤ 0.1")
    return passed, density, ("; ".join(reasons) if reasons else None)


def _check_h4_gradient_card(arr: np.ndarray) -> tuple[bool, float, str | None]:
    """H4 그래디언트 카드: 인접 픽셀 차이 평균 < 5 (부드러운 그라디언트)."""
    arr_f = arr.astype(np.float32)
    diff_h = float(np.abs(np.diff(arr_f, axis=1)).mean())
    diff_v = float(np.abs(np.diff(arr_f, axis=0)).mean())
    smoothness = (diff_h + diff_v) / 2.0
    passed = smoothness < 5.0
    reason = None if passed else f"그래디언트 부드러움 부족: smoothness {smoothness:.4f} ≥ 5.0"
    return passed, smoothness, reason


def _check_h5_user_photo_card(arr: np.ndarray) -> tuple[bool, float, str | None]:
    """H5 사용자 사진 카드: 자연 사진 JPEG-like noise 비율 > 0.15."""
    arr_f = arr.astype(np.float32)
    diff_h = np.abs(np.diff(arr_f, axis=1))
    diff_v = np.abs(np.diff(arr_f, axis=0))
    # 1~15 범위의 작은 high-frequency 변화가 자연 사진 특징
    noise_h = float(np.mean((diff_h > 1) & (diff_h < 15)))
    noise_v = float(np.mean((diff_v > 1) & (diff_v < 15)))
    noise_ratio = (noise_h + noise_v) / 2.0
    passed = noise_ratio > 0.15
    reason = None if passed else f"자연 사진 노이즈 패턴 부족: noise_ratio {noise_ratio:.4f} ≤ 0.15"
    return passed, noise_ratio, reason


def check_hybrid_pattern(img: Image.Image, hybrid_pattern: str) -> dict[str, Any]:
    """hybrid_pattern 별 시각 특징이 기준을 충족하는지 검증한다."""
    arr = np.array(img.convert("RGB"), dtype=np.uint8)
    arr_gray = np.array(img.convert("L"), dtype=np.float32)

    dispatch: dict[str, Any] = {
        "h1_photo_card": lambda: _check_h1_photo_card(arr_gray),
        "h2_illustration_card": lambda: _check_h2_illustration_card(arr, img),
        "h3_gpt_style_card": lambda: _check_h3_gpt_style_card(arr_gray, img),
        "h4_gradient_card": lambda: _check_h4_gradient_card(arr),
        "h5_user_photo_card": lambda: _check_h5_user_photo_card(arr),
    }

    if hybrid_pattern not in dispatch:
        return {
            "passed": False,
            "score": 0,
            "pattern": hybrid_pattern,
            "feature_value": 0.0,
            "reason": f"알 수 없는 hybrid_pattern: {hybrid_pattern}",
            "retry_hint": {},
        }

    passed, feature_value, reason = dispatch[hybrid_pattern]()
    score = 25 if passed else 0

    return {
        "passed": passed,
        "score": score,
        "pattern": hybrid_pattern,
        "feature_value": round(float(feature_value), 6),
        "reason": reason,
        "retry_hint": {
            "force_pattern_signature": hybrid_pattern,
            "pattern_specific_seed": random.randint(0, 2**31),
        },
    }


# ---------------------------------------------------------------------------
# 검증 함수 4: 폰트 크기 — dq-rules.json 임계값 적용
# ---------------------------------------------------------------------------

def check_font_size(img: Image.Image, target_size: tuple[int, int]) -> dict[str, Any]:
    """dq-rules.json absolute_min 기준으로 텍스트 최소 높이를 검증한다.

    pytesseract 미설치 시 BLOCKED 상태 반환 (silent pass 차단).
    """
    if not _TESSERACT_AVAILABLE:
        # BLOCKED: passed=True지만 score=0 + blocked=True로 silent pass 차단
        # 통합 evaluate_image가 blocked_count를 별도 카운트
        return {
            "passed": True,
            "blocked": True,
            "score": 0,
            "min_text_height": -1,
            "threshold": -1,
            "reason": "BLOCKED: pytesseract 미설치 — 시스템에 tesseract + kor.traineddata 필요",
            "retry_hint": {},
        }

    # dq-rules.json 로드 + 스키마 검증 (Codex medium #5 해소)
    try:
        rules = json.loads(_DQ_RULES_PATH.read_text(encoding="utf-8"))
        _validate_dq_rules_schema(rules)
        absolute_min: int = int(rules["font_sizes"]["absolute_min"])
    except Exception as exc:
        # 스키마 위반은 명시 ERROR (silent fallback 금지)
        raise RuntimeError(
            f"dq-rules.json 로드/스키마 검증 실패: {exc}. "
            f"단일 소스 파일 무결성을 확인하세요."
        ) from exc

    scale = target_size[0] / 1080.0
    threshold = int(absolute_min * scale)

    # OCR bounding box 추출
    try:
        data = pytesseract.image_to_data(img, lang="kor", output_type=TesseractOutput.DICT)
        heights = [
            int(h)
            for text, h in zip(data["text"], data["height"])
            if isinstance(text, str) and text.strip() and int(h) > 0
        ]
    except Exception as exc:
        # 로키 G2 적대적 평가 HIGH #3-EDGE 해소: BLOCKED 통일 (silent pass 차단)
        return {
            "passed": True,
            "blocked": True,
            "score": 0,
            "min_text_height": -1,
            "threshold": threshold,
            "reason": f"BLOCKED: OCR 실행 오류 (운영팀 알림): {exc}",
            "retry_hint": {},
        }

    if not heights:
        return {
            "passed": False,
            "score": 0,
            "min_text_height": 0,
            "threshold": threshold,
            "reason": "텍스트 미감지 (OCR 결과 없음)",
            "retry_hint": {"min_font_px": threshold},
        }

    min_text_height = min(heights)
    passed = min_text_height >= threshold
    score = 15 if passed else 0

    reason = (
        None
        if passed
        else f"폰트 크기 미달: 최소 텍스트 높이 {min_text_height}px < {threshold}px"
    )

    return {
        "passed": passed,
        "score": score,
        "min_text_height": min_text_height,
        "threshold": threshold,
        "reason": reason,
        "retry_hint": {"min_font_px": threshold},
    }


# ---------------------------------------------------------------------------
# 검증 함수 5: OCR 신뢰도 — task-2389 한글 깨짐 회귀 차단
# ---------------------------------------------------------------------------

def check_ocr_confidence(img: Image.Image) -> dict[str, Any]:
    """pytesseract 한글 OCR 신뢰도 평균 ≥ 70% 를 검증한다.

    pytesseract 미설치 시 BLOCKED 상태 반환 (silent pass 차단).
    """
    if not _TESSERACT_AVAILABLE:
        # BLOCKED: passed=True지만 score=0 + blocked=True로 silent pass 차단
        return {
            "passed": True,
            "blocked": True,
            "score": 0,
            "avg_confidence": -1.0,
            "extracted_text": "",
            "reason": "BLOCKED: pytesseract 미설치 — 시스템에 tesseract + kor.traineddata 필요",
            "retry_hint": {},
        }

    try:
        data = pytesseract.image_to_data(img, lang="kor", output_type=TesseractOutput.DICT)
        confidences = [
            int(conf)
            for text, conf in zip(data["text"], data["conf"])
            if isinstance(text, str) and text.strip() and int(conf) >= 0
        ]
        extracted_text = " ".join(
            t for t in data["text"] if isinstance(t, str) and t.strip()
        )
    except Exception as exc:
        return {
            "passed": False,
            "score": 0,
            "avg_confidence": 0.0,
            "extracted_text": "",
            "reason": f"OCR 실행 오류: {exc}",
            "retry_hint": {
                "force_korean_font": "Pretendard,NotoSansCJK",
                "fallback_check": True,
            },
        }

    if not confidences:
        return {
            "passed": False,
            "score": 0,
            "avg_confidence": 0.0,
            "extracted_text": extracted_text,
            "reason": "한글 텍스트 미감지: OCR 신뢰도 0",
            "retry_hint": {
                "force_korean_font": "Pretendard,NotoSansCJK",
                "fallback_check": True,
            },
        }

    avg_confidence = float(sum(confidences) / len(confidences))

    # 한글 유니코드 비율 (로키 G2 적대적 평가 LOW #4-OCR 해소):
    # confidence만 검사하면 영문/숫자 텍스트로도 PASS 가능.
    # task-2389 한글 깨짐 회귀 차단 목적상 한글 비율 검증 필수.
    korean_chars = sum(1 for c in extracted_text if "가" <= c <= "힣")
    total_alnum = sum(1 for c in extracted_text if c.isalnum() or "가" <= c <= "힣")
    korean_ratio = (korean_chars / total_alnum) if total_alnum > 0 else 0.0

    confidence_ok = avg_confidence >= OCR_CONFIDENCE_THRESHOLD
    korean_ok = korean_ratio >= OCR_KOREAN_RATIO_MIN
    passed = confidence_ok and korean_ok
    score = 15 if passed else max(
        0,
        int(15 * (avg_confidence / OCR_CONFIDENCE_THRESHOLD)
            * min(korean_ratio / OCR_KOREAN_RATIO_MIN, 1.0)),
    )

    reasons: list[str] = []
    if not confidence_ok:
        reasons.append(
            f"한글 OCR 신뢰도 미달: 평균 {avg_confidence:.1f}% < {OCR_CONFIDENCE_THRESHOLD}%"
        )
    if not korean_ok:
        reasons.append(
            f"한글 텍스트 비율 부족: {korean_ratio:.2%} < {OCR_KOREAN_RATIO_MIN:.0%}"
            f" (한글 깨짐 회귀 차단)"
        )
    reason = "; ".join(reasons) if reasons else None

    return {
        "passed": passed,
        "score": score,
        "avg_confidence": round(avg_confidence, 2),
        "korean_ratio": round(korean_ratio, 4),
        "extracted_text": extracted_text[:200],
        "reason": reason,
        "retry_hint": {
            "force_korean_font": "Pretendard,NotoSansCJK",
            "fallback_check": True,
        },
    }


# ---------------------------------------------------------------------------
# 통합 평가 함수
# ---------------------------------------------------------------------------

def evaluate_image(
    png_path: Path,
    design_md: dict[str, Any],
    hybrid_pattern: str,
    target_size: tuple[int, int],
) -> EvalResult:
    """PNG 이미지를 5가지 품질 기준으로 평가하고 EvalResult를 반환한다."""
    img = Image.open(png_path).convert("RGB")

    results: dict[str, dict[str, Any]] = {}

    # 검증 1: 시각 다양성 (20점) — silent corruption 차단 핵심
    results["visual_diversity"] = check_visual_diversity(img)

    # 검증 2: 브랜드 색상 일치 (25점)
    results["brand_color_match"] = check_brand_color_match(img, design_md)

    # 검증 3: hybrid 패턴 특징 (25점) — 5 패턴 분화 강제
    results["hybrid_pattern"] = check_hybrid_pattern(img, hybrid_pattern)

    # 검증 4: 폰트 크기 (15점) — dq-rules.json absolute_min 준수
    results["font_size"] = check_font_size(img, target_size)

    # 검증 5: OCR 신뢰도 (15점) — task-2389 한글 깨짐 회귀 차단
    results["ocr_confidence"] = check_ocr_confidence(img)

    # 점수 합산 및 PASS/FAIL 판정
    total_score = sum(r["score"] for r in results.values())
    fail_reasons: list[str] = []
    blocked_reasons: list[str] = []
    merged_hints: dict[str, Any] = {}

    for key, r in results.items():
        # BLOCKED는 silent pass 차단 — passed=True지만 별도 카운트
        if r.get("blocked"):
            blocked_reasons.append(f"[{key}] {r.get('reason', 'BLOCKED')}")
            continue
        if not r["passed"]:
            if r.get("reason"):
                fail_reasons.append(f"[{key}] {r['reason']}")
            hint = r.get("retry_hint", {})
            merged_hints.update(hint)

    # passed = FAIL 0 AND BLOCKED 0 (silent pass 영구 차단)
    passed = len(fail_reasons) == 0 and len(blocked_reasons) == 0

    # BLOCKED 발생 시 fail_reasons에 명시 추가 (운영자 알림 의무)
    if blocked_reasons:
        fail_reasons.extend(blocked_reasons)

    return EvalResult(
        passed=passed,
        score=min(100, total_score),
        fail_reasons=fail_reasons,
        retry_hints=merged_hints,
        details=results,
    )
