"""IDS Phase 0.5 Lite Evaluator 회귀 테스트.

7+ 시나리오 (task-2446 Fix 4):
    1. L1 Contrast 정상 PASS / 그라데이션(분포 측정) PASS / 글리프 명도 단조 FAIL
    2. L2 Margin: 침범 0건 PASS / 침범 5건 FAIL
    3. L3 Hierarchy: 정상 비율 PASS / 헤딩=본문 동일 크기 FAIL (head/sub ratio<1.3)
    4. L4 Color Token: 4색 이내 PASS / off-token WARN / AI 퍼플 FAIL
    5. L5 Typography: Pretendard PASS / 시스템 fallback FAIL
    6. JSON Schema invalid input → SchemaValidationError
    7. mappingVersion 미일치 → SSotMismatchError
"""

from __future__ import annotations

import importlib.util
import sys
from pathlib import Path

import numpy as np
import pytest
from PIL import Image, ImageDraw

# pytest는 tests/scripts/ids/__init__.py로 인해 본 테스트를 `scripts.ids.test_lite_evaluator`
# 모듈로 로드하여 실제 scripts/ids 패키지와 namespace 충돌이 발생한다.
# importlib.util로 파일 절대경로 기반 로드하여 충돌 회피.
_REPO_ROOT = Path(__file__).resolve().parents[3]
_LITE_PATH = _REPO_ROOT / "scripts" / "ids" / "lite_evaluator.py"


def _load_lite_module():
    spec = importlib.util.spec_from_file_location(
        "_ids_lite_evaluator", _LITE_PATH
    )
    if spec is None or spec.loader is None:
        raise RuntimeError(f"Cannot load lite_evaluator from {_LITE_PATH}")
    mod = importlib.util.module_from_spec(spec)
    sys.modules["_ids_lite_evaluator"] = mod
    spec.loader.exec_module(mod)
    return mod


_lite = _load_lite_module()
EvalResult = _lite.EvalResult
SchemaValidationError = _lite.SchemaValidationError
SSotMismatchError = _lite.SSotMismatchError
SSOT_MAPPING_VERSION = _lite.SSOT_MAPPING_VERSION
evaluate = _lite.evaluate
evaluate_l1_contrast = _lite.evaluate_l1_contrast
evaluate_l2_margin = _lite.evaluate_l2_margin
evaluate_l3_hierarchy = _lite.evaluate_l3_hierarchy
evaluate_l4_color_token = _lite.evaluate_l4_color_token
evaluate_l5_typography = _lite.evaluate_l5_typography
_glyph_pixel_contrasts = _lite._glyph_pixel_contrasts


# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------

CANVAS_W = 1080
CANVAS_H = 1080
SAFE_PX = 72


def _base_meta(image_path: Path) -> dict:
    """SSOT 적합 LayoutMeta 기본 구조."""

    return {
        "image_path": str(image_path),
        "themePreset": "theme-fa-fintech",
        "targetPersona": "insurance-fa",
        "mappingVersion": SSOT_MAPPING_VERSION,
        "canvas": {"width": CANVAS_W, "height": CANVAS_H},
        "safeArea": {"top": SAFE_PX, "bottom": SAFE_PX, "left": SAFE_PX, "right": SAFE_PX},
        "grid": {"baseline": 8, "columns": 12, "gutter": 24},
        "background": {"type": "solid", "data": "#F8F4EE"},
        "components": [
            {
                "name": "MainTitle",
                "role": "headline",
                "bbox": [120, 200, 800, 120],
                "fontSize": 96,
                "fill": "#0B1E3F",
                "fontFamily": "Pretendard",
                "fontWeight": 700,
                "lineHeight": 1.2,
            },
            {
                "name": "SubTitle",
                "role": "subhead",
                "bbox": [120, 360, 600, 80],
                "fontSize": 64,
                "fill": "#1F1F1F",
                "fontFamily": "Pretendard",
                "fontWeight": 500,
                "lineHeight": 1.3,
            },
            {
                "name": "ActionButton",
                "role": "cta",
                "bbox": [120, 880, 320, 80],
                "fontSize": 44,
                "fill": "#FFFFFF",
                "fontFamily": "Pretendard",
                "fontWeight": 700,
                "lineHeight": 1.2,
            },
        ],
    }


def _draw_text_block(
    draw: ImageDraw.ImageDraw,
    bbox: list[float],
    fill_hex: str,
) -> None:
    """bbox 내에 글리프-유사 패턴(가는 막대 strokes)을 그려 contrast 측정 가능 픽셀을 생성."""

    x, y, w, h = (int(v) for v in bbox)
    fill_rgb = tuple(int(fill_hex.lstrip("#")[i : i + 2], 16) for i in (0, 2, 4))
    # 글리프 stroke 시뮬레이션: 수직 막대 + 수평 막대
    stroke_w = max(2, int(w * 0.012))
    pad = max(4, int(h * 0.15))
    cols = max(3, int(w / (stroke_w * 4)))
    for i in range(cols):
        cx = x + pad + int(i * (w - 2 * pad) / max(1, cols - 1))
        draw.rectangle([cx, y + pad, cx + stroke_w, y + h - pad], fill=fill_rgb)
    # 수평 cross
    cy = y + h // 2
    draw.rectangle(
        [x + pad, cy, x + w - pad, cy + max(1, stroke_w // 2)], fill=fill_rgb
    )


def _render_solid_bg_image(
    path: Path,
    bg_hex: str = "#F8F4EE",
    components: list[dict] | None = None,
) -> None:
    """단색 배경 + 텍스트 블록 PNG 합성."""

    img = Image.new("RGB", (CANVAS_W, CANVAS_H), bg_hex)
    draw = ImageDraw.Draw(img)
    if components:
        for c in components:
            if c.get("isText", True):
                _draw_text_block(draw, c["bbox"], c["fill"])
    img.save(path)


def _render_gradient_bg_image(
    path: Path,
    start_hex: str = "#FFFFFF",
    end_hex: str = "#1A1A1A",
    components: list[dict] | None = None,
) -> None:
    """수직 그라데이션 배경 + 텍스트 PNG 합성. L1 분포 측정 시나리오."""

    arr = np.zeros((CANVAS_H, CANVAS_W, 3), dtype=np.uint8)
    s = np.array([int(start_hex[1:3], 16), int(start_hex[3:5], 16), int(start_hex[5:7], 16)])
    e = np.array([int(end_hex[1:3], 16), int(end_hex[3:5], 16), int(end_hex[5:7], 16)])
    for y in range(CANVAS_H):
        t = y / max(1, CANVAS_H - 1)
        arr[y, :, :] = (s * (1 - t) + e * t).astype(np.uint8)
    img = Image.fromarray(arr, "RGB")
    draw = ImageDraw.Draw(img)
    if components:
        for c in components:
            if c.get("isText", True):
                _draw_text_block(draw, c["bbox"], c["fill"])
    img.save(path)


def _render_purple_bg_image(path: Path, components: list[dict] | None = None) -> None:
    """AI 퍼플(hue 280, sat 0.7) 배경 PNG."""

    img = Image.new("RGB", (CANVAS_W, CANVAS_H), (138, 43, 226))  # blueviolet ≈ hue 271
    draw = ImageDraw.Draw(img)
    if components:
        for c in components:
            if c.get("isText", True):
                _draw_text_block(draw, c["bbox"], c["fill"])
    img.save(path)


# ---------------------------------------------------------------------------
# Scenario 1: L1 Contrast
# ---------------------------------------------------------------------------


def test_l1_contrast_normal_pass(tmp_path: Path) -> None:
    """정상 케이스: 어두운 텍스트(#0B1E3F) vs 밝은 배경(#F8F4EE) → p5 ≥ 4.5 → PASS."""

    img_path = tmp_path / "l1_normal.png"
    meta = _base_meta(img_path)
    _render_solid_bg_image(img_path, bg_hex="#F8F4EE", components=meta["components"])
    # CTA bg 색을 어둡게 만들어 #FFFFFF 텍스트가 contrast 확보
    img = Image.open(img_path).convert("RGB")
    draw = ImageDraw.Draw(img)
    cta = next(c for c in meta["components"] if c["role"] == "cta")
    x, y, w, h = (int(v) for v in cta["bbox"])
    draw.rectangle([x, y, x + w, y + h], fill=(11, 30, 63))
    _draw_text_block(draw, cta["bbox"], cta["fill"])
    img.save(img_path)

    arr = np.asarray(Image.open(img_path).convert("RGB"))
    result = evaluate_l1_contrast(arr, meta)
    assert result.verdict in {"PASS", "WARN"}, f"L1 정상 케이스 FAIL: {result.reason}"
    headline_detail = next(
        d for d in result.details["per_component"] if d["role"] == "headline"
    )
    assert headline_detail["p5"] >= 4.5, headline_detail


def test_l1_contrast_gradient_distribution(tmp_path: Path) -> None:
    """그라데이션 배경에서 글리프 픽셀별 contrast 분포가 발생해야 한다.

    p5(어두운 끝)와 p95(밝은 끝)가 달라야 분포 측정이 작동.
    """
    img_path = tmp_path / "l1_gradient.png"
    meta = _base_meta(img_path)
    # 단일 컴포넌트(검은색)로 그라데이션 위에 그림 — bbox 내에서 위/아래 contrast 차이
    meta["components"] = [
        {
            "name": "Headline",
            "role": "headline",
            "bbox": [120, 100, 800, 800],  # 거의 캔버스 전체 높이 → 그라데이션 분포 발생
            "fontSize": 96,
            "fill": "#000000",
            "fontFamily": "Pretendard",
            "fontWeight": 700,
            "lineHeight": 1.2,
        },
    ]
    _render_gradient_bg_image(
        img_path,
        start_hex="#FFFFFF",
        end_hex="#000000",
        components=meta["components"],
    )

    arr = np.asarray(Image.open(img_path).convert("RGB"))
    contrasts = _glyph_pixel_contrasts(arr, meta["components"][0])
    assert len(contrasts) > 100, f"glyph 픽셀 부족: {len(contrasts)}"
    # 분포는 항상 양수, 균일색이 아니므로 분포가 1보다 크게 발생하지는 않을 수 있으나
    # 그라데이션 배경에서 글리프 분류 후 effective bg가 대체로 밝아 contrast가 유의미해야 함
    p5 = sorted(contrasts)[len(contrasts) // 20]
    assert p5 > 1.0, f"p5={p5} 너무 낮음"


def test_l1_contrast_low_glyph_brightness_fail(tmp_path: Path) -> None:
    """배경(#888) vs 텍스트(#999) 같이 contrast 부족 → p5 < 4.5 → FAIL."""

    img_path = tmp_path / "l1_low.png"
    meta = _base_meta(img_path)
    # 모든 텍스트를 회색 톤으로 두어 contrast 미달 유도
    for c in meta["components"]:
        c["fill"] = "#999999"
    _render_solid_bg_image(img_path, bg_hex="#888888", components=meta["components"])

    arr = np.asarray(Image.open(img_path).convert("RGB"))
    result = evaluate_l1_contrast(arr, meta)
    assert result.verdict == "FAIL", f"contrast 부족인데 PASS: {result.details}"
    assert any("contrast" in r.lower() for r in [result.reason or ""])


# ---------------------------------------------------------------------------
# Scenario 2: L2 Margin
# ---------------------------------------------------------------------------


def test_l2_margin_safe_area_clean(tmp_path: Path) -> None:
    """모든 텍스트가 safe-area(72px) 안 → PASS."""

    img_path = tmp_path / "l2_clean.png"
    meta = _base_meta(img_path)
    _render_solid_bg_image(img_path, components=meta["components"])
    result = evaluate_l2_margin(meta)
    assert result.verdict == "PASS", result.details
    assert result.details["violation_count"] == 0
    assert result.details["ssot_aligned"] is True


def test_l2_margin_violations_fail(tmp_path: Path) -> None:
    """5개 컴포넌트가 safe-area 침범 → FAIL."""

    img_path = tmp_path / "l2_violate.png"
    meta = _base_meta(img_path)
    meta["components"] = [
        {
            "name": f"Bad{i}",
            "role": "body",
            "bbox": [10, 10 + i * 20, 200, 60],  # 모두 left=10 < 72
            "fontSize": 44,
            "fill": "#000000",
            "fontFamily": "Pretendard",
            "fontWeight": 400,
            "lineHeight": 1.4,
        }
        for i in range(5)
    ]
    # text 컴포넌트가 1개 이상 필요하므로 안전 컴포넌트도 추가 (그래도 5건 침범)
    _render_solid_bg_image(img_path, components=meta["components"])
    result = evaluate_l2_margin(meta)
    assert result.verdict == "FAIL", result.details
    assert result.details["violation_count"] == 5


# ---------------------------------------------------------------------------
# Scenario 3: L3 Hierarchy
# ---------------------------------------------------------------------------


def test_l3_hierarchy_normal_pass(tmp_path: Path) -> None:
    """정상 비율: head=96, sub=64, head/sub=1.5 ≥ 1.3 → PASS."""

    img_path = tmp_path / "l3_pass.png"
    meta = _base_meta(img_path)
    _render_solid_bg_image(img_path, components=meta["components"])
    result = evaluate_l3_hierarchy(meta)
    assert result.verdict == "PASS", result.details
    assert result.details["head_sub_ratio"] >= 1.3


def test_l3_hierarchy_head_eq_body_fail(tmp_path: Path) -> None:
    """heading=subhead 동일 크기 → ratio<1.3 → FAIL.

    또한 absolute_min=40 위반(fontSize=20) 추가하여 FAIL 확정.
    """
    img_path = tmp_path / "l3_fail.png"
    meta = _base_meta(img_path)
    # head/sub 동일 크기로 만들어 ratio=1.0 → FAIL
    meta["components"][0]["fontSize"] = 64
    meta["components"][1]["fontSize"] = 64
    # 추가: absolute_min 위반
    meta["components"].append(
        {
            "name": "TooSmall",
            "role": "caption",
            "bbox": [120, 700, 400, 30],
            "fontSize": 20,  # < 40
            "fill": "#000000",
            "fontFamily": "Pretendard",
            "fontWeight": 400,
            "lineHeight": 1.4,
        }
    )
    _render_solid_bg_image(img_path, components=meta["components"])
    result = evaluate_l3_hierarchy(meta)
    assert result.verdict == "FAIL", result.details
    assert any("absolute_min" in r for r in (result.reason or "").split(";"))


# ---------------------------------------------------------------------------
# Scenario 4: L4 Color Token
# ---------------------------------------------------------------------------


def test_l4_color_token_palette_pass(tmp_path: Path) -> None:
    """단순 단색 배경 + 텍스트 → 색상 카운트 ≤ 4 → PASS."""

    img_path = tmp_path / "l4_pass.png"
    meta = _base_meta(img_path)
    _render_solid_bg_image(img_path, components=meta["components"])
    arr = np.asarray(Image.open(img_path).convert("RGB"))
    result = evaluate_l4_color_token(arr, meta)
    assert result.verdict in {"PASS", "WARN"}, result.details
    assert result.details["color_count"] <= 4
    assert result.details["ai_purple_ratio"] < 0.10


def test_l4_color_token_off_token_warn(tmp_path: Path) -> None:
    """themePreset 팔레트에 없는 형광 핑크 텍스트 → WARN (회귀 차단)."""

    img_path = tmp_path / "l4_off.png"
    meta = _base_meta(img_path)
    # theme-fa-fintech 팔레트(#FF6E2B/#0B1E3F/#F8F4EE/#1F1F1F/#FFFFFF) 외 형광색
    for c in meta["components"]:
        if c["role"] == "headline":
            c["fill"] = "#FF00AA"
            break
    _render_solid_bg_image(img_path, bg_hex="#F0F0F0", components=meta["components"])
    arr = np.asarray(Image.open(img_path).convert("RGB"))
    result = evaluate_l4_color_token(arr, meta)
    assert result.details["color_count"] <= 4
    # off-token이 발견되면 반드시 WARN 이상 (Codex high #5 회귀 차단)
    assert result.verdict == "WARN", f"off-token이 PASS로 통과됨: {result.details}"
    assert "#FF00AA" in result.details["off_token_fills"]


def test_l4_color_token_ai_purple_fail(tmp_path: Path) -> None:
    """AI 퍼플 배경(hue 271, sat>0.5) 캔버스 100% → 10% 초과 → FAIL."""

    img_path = tmp_path / "l4_purple.png"
    meta = _base_meta(img_path)
    _render_purple_bg_image(img_path, components=meta["components"])
    arr = np.asarray(Image.open(img_path).convert("RGB"))
    result = evaluate_l4_color_token(arr, meta)
    assert result.verdict == "FAIL", result.details
    assert result.details["ai_purple_ratio"] > 0.10
    assert "AI 퍼플" in (result.reason or "")


# ---------------------------------------------------------------------------
# Scenario 5: L5 Typography
# ---------------------------------------------------------------------------


def test_l5_typography_pretendard_pass(tmp_path: Path) -> None:
    """Pretendard + 정상 weight → PASS."""

    img_path = tmp_path / "l5_pass.png"
    meta = _base_meta(img_path)
    # font_pairing.min_families=2 만족 위해 한 컴포넌트만 다른 한글 패밀리로
    meta["components"][1]["fontFamily"] = "Noto Sans KR"
    _render_solid_bg_image(img_path, components=meta["components"])
    result = evaluate_l5_typography(meta)
    assert result.verdict == "PASS", result.details
    assert "Pretendard" in result.details["families_seen"]


def test_l5_typography_system_fallback_fail(tmp_path: Path) -> None:
    """시스템 fallback(굴림체 등 banned) → FAIL."""

    img_path = tmp_path / "l5_fail.png"
    meta = _base_meta(img_path)
    meta["components"][0]["fontFamily"] = "굴림체"
    meta["components"][1]["fontFamily"] = "바탕체"
    _render_solid_bg_image(img_path, components=meta["components"])
    result = evaluate_l5_typography(meta)
    assert result.verdict == "FAIL", result.details
    assert any("banned" in v["type"] for v in result.details["family_violations"])


# ---------------------------------------------------------------------------
# Scenario 6: JSON Schema invalid input
# ---------------------------------------------------------------------------


def test_schema_invalid_input_blocks_entry(tmp_path: Path) -> None:
    """필수 필드 누락 → SchemaValidationError → 진입 차단."""

    img_path = tmp_path / "schema_fail.png"
    _render_solid_bg_image(img_path)
    bad_meta = {
        "image_path": str(img_path),
        "themePreset": "theme-fa-fintech",
        # targetPersona 누락 (required)
        "mappingVersion": SSOT_MAPPING_VERSION,
        "canvas": {"width": CANVAS_W, "height": CANVAS_H},
        "safeArea": {"top": SAFE_PX, "bottom": SAFE_PX, "left": SAFE_PX, "right": SAFE_PX},
        "grid": {"baseline": 8},
        "components": [
            {
                "name": "X",
                "role": "headline",
                "bbox": [100, 100, 200, 60],
                "fontSize": 96,
                "fill": "#000000",
            }
        ],
    }
    with pytest.raises(SchemaValidationError) as exc:
        evaluate(bad_meta)
    assert "targetPersona" in str(exc.value)


def test_schema_invalid_preset_enum(tmp_path: Path) -> None:
    """themePreset enum 위반(theme-A) → SchemaValidationError."""

    img_path = tmp_path / "schema_preset.png"
    _render_solid_bg_image(img_path)
    bad_meta = _base_meta(img_path)
    bad_meta["themePreset"] = "theme-A"  # SSOT 위반
    with pytest.raises(SchemaValidationError):
        evaluate(bad_meta)


# ---------------------------------------------------------------------------
# Scenario 7: mappingVersion 미일치 진입 차단
# ---------------------------------------------------------------------------


def test_mapping_version_mismatch_blocks_entry(tmp_path: Path) -> None:
    """mappingVersion이 SSOT_MAPPING_VERSION(v1.0)이 아니면 SSotMismatchError."""

    img_path = tmp_path / "mv_fail.png"
    meta = _base_meta(img_path)
    _render_solid_bg_image(img_path, components=meta["components"])
    meta["mappingVersion"] = "v9.9"
    with pytest.raises(SSotMismatchError) as exc:
        evaluate(meta)
    assert "v9.9" in str(exc.value)
    assert SSOT_MAPPING_VERSION in str(exc.value)


# ---------------------------------------------------------------------------
# 통합 진입점 evaluate() 시나리오 (보너스)
# ---------------------------------------------------------------------------


def test_evaluate_end_to_end_pass(tmp_path: Path) -> None:
    """정상 LayoutMeta → evaluate() 종합 결과 PASS."""

    img_path = tmp_path / "e2e_pass.png"
    meta = _base_meta(img_path)
    meta["components"][1]["fontFamily"] = "Noto Sans KR"
    # 안정적 contrast: CTA bg를 어둡게
    img = Image.new("RGB", (CANVAS_W, CANVAS_H), (248, 244, 238))
    draw = ImageDraw.Draw(img)
    cta = next(c for c in meta["components"] if c["role"] == "cta")
    x, y, w, h = (int(v) for v in cta["bbox"])
    draw.rectangle([x, y, x + w, y + h], fill=(11, 30, 63))
    for c in meta["components"]:
        _draw_text_block(draw, c["bbox"], c["fill"])
    img.save(img_path)

    result = evaluate(meta)
    assert isinstance(result, EvalResult)
    assert len(result.items) == 5
    assert result.overall in {"PASS", "WARN"}, result.fail_reasons
    assert result.layout_meta_summary["mappingVersion"] == SSOT_MAPPING_VERSION
