"""Tests for v0.8.1 — Review Intelligence rules and auto-fix patterns."""

from __future__ import annotations

from unittest.mock import MagicMock

import pytest

from pptx_mcp.engine.shapes import color_distance, nearest_palette_color
from pptx_mcp.models.brand import (
    BrandConfig,
    ColorPalette,
    FontConfig,
    LayoutRules,
    LogoConfig,
    ShapeStyleConfig,
)
from pptx_mcp.models.review import ReviewCategory, ReviewResult, Severity
from pptx_mcp.models.slides import (
    ChartData,
    ChartDataSeries,
    ChartElement,
    ChartType,
    ImageElement,
    PresentationDefinition,
    PresentationMetadata,
    ShapeElement,
    ShapeType,
    SlideDefinition,
    SlideType,
    TextStyle,
)
from pptx_mcp.review.rules import (
    check_accent_overuse,
    check_chart_brand_colors,
    check_font_hierarchy,
    check_image_alt_text,
    check_logo_presence,
    check_shadow_border_conflict,
    check_visual_rhythm,
)
from pptx_mcp.review.spatial import (
    _collect_brand_palette,
    _is_neutral_gray,
    check_color_compliance,
    check_contrast_ratio,
    check_distinct_color_count,
    check_font_compliance,
    check_font_family_count,
    check_min_font_size,
    check_whitespace_ratio,
)
from pptx_mcp.agents.iterator import IterationAgent, IterationInput


# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------

def _brand(**overrides) -> BrandConfig:
    defaults: dict = dict(
        name="Test",
        colors=ColorPalette(primary="#1A1A2E", secondary="#16213E", accent="#E94560"),
    )
    defaults.update(overrides)
    return BrandConfig(**defaults)


def _slide(**kw) -> SlideDefinition:
    defaults: dict = dict(slide_type=SlideType.CONTENT, title="Test")
    defaults.update(kw)
    return SlideDefinition(**defaults)


def _defn(*slides: SlideDefinition) -> PresentationDefinition:
    return PresentationDefinition(
        metadata=PresentationMetadata(title="Test"),
        slides=list(slides),
    )


def _mock_run(*, size_pt=None, font_name=None, color_hex=None):
    """Build a mock python-pptx Run."""
    run = MagicMock()
    run.font.size = int(size_pt * 12700) if size_pt is not None else None
    run.font.name = font_name
    if color_hex:
        run.font.color.type = 1
        run.font.color.rgb = color_hex.lstrip("#").upper()
    else:
        run.font.color.type = None
        run.font.color.rgb = None
    return run


def _mock_para(*runs):
    para = MagicMock()
    para.runs = list(runs)
    para.text = "text"
    return para


def _mock_shape(
    *,
    left=1.0, top=1.0, width=3.0, height=2.0,
    has_text=False, paras=None, fill_hex=None, name="Shape",
):
    """Build a mock python-pptx Shape."""
    shape = MagicMock()
    shape.name = name
    shape.left = int(left * 914400)
    shape.top = int(top * 914400)
    shape.width = int(width * 914400)
    shape.height = int(height * 914400)
    shape.has_text_frame = has_text
    if fill_hex:
        shape.fill.type = 1
        shape.fill.fore_color.rgb = fill_hex.lstrip("#").upper()
    else:
        shape.fill.type = None
    if paras is not None:
        shape.has_text_frame = True
        shape.text_frame.paragraphs = paras
        shape.text_frame.text = "text"
    elif has_text:
        shape.text_frame.paragraphs = []
        shape.text_frame.text = ""
    return shape


def _pptx_slide(shapes):
    """Build a mock python-pptx Slide."""
    slide = MagicMock()
    slide.shapes = shapes
    return slide


# ===================================================================
# A1 — Color distance utilities
# ===================================================================

class TestColorDistance:
    def test_same_color_is_zero(self):
        assert color_distance("#FF0000", "#FF0000") == 0.0

    def test_black_white_is_max(self):
        d = color_distance("#000000", "#FFFFFF")
        assert abs(d - 441.67) < 0.1

    def test_close_colors(self):
        d = color_distance("#FF0000", "#FE0000")
        assert d < 2.0


class TestNearestPaletteColor:
    def test_exact_match(self):
        assert nearest_palette_color("#FF0000", ["#FF0000", "#00FF00"]) == "#FF0000"

    def test_nearest(self):
        result = nearest_palette_color("#FE0000", ["#FF0000", "#00FF00"])
        assert result == "#FF0000"

    def test_empty_palette_returns_input(self):
        assert nearest_palette_color("#ABC123", []) == "#ABC123"


# ===================================================================
# A2 — Spatial helpers
# ===================================================================

class TestIsNeutralGray:
    def test_pure_gray(self):
        assert _is_neutral_gray("#808080") is True

    def test_near_gray(self):
        assert _is_neutral_gray("#7F8081", tolerance=10) is True

    def test_not_gray(self):
        assert _is_neutral_gray("#FF0000") is False


# ===================================================================
# B1-B6 — Definition-level slide rules
# ===================================================================

class TestImageAltText:
    def test_missing_warns(self):
        slide = _slide(elements=[ImageElement(path="/tmp/img.png")])
        issues = check_image_alt_text(slide, 0, _brand())
        assert len(issues) == 1
        assert issues[0].category == ReviewCategory.ACCESSIBILITY

    def test_present_passes(self):
        slide = _slide(elements=[ImageElement(path="/tmp/img.png", alt_text="Logo")])
        assert check_image_alt_text(slide, 0, _brand()) == []

    def test_no_images_passes(self):
        slide = _slide(elements=[ShapeElement(shape_type=ShapeType.RECTANGLE)])
        assert check_image_alt_text(slide, 0, _brand()) == []


class TestFontHierarchy:
    def test_valid_passes(self):
        brand = _brand(fonts=FontConfig(
            title_size=36, heading_size=28, body_size=16, caption_size=12,
        ))
        assert check_font_hierarchy(_slide(), 0, brand) == []

    def test_inverted_flagged(self):
        brand = _brand(fonts=FontConfig(
            title_size=12, heading_size=28, body_size=16, caption_size=36,
        ))
        issues = check_font_hierarchy(_slide(), 0, brand)
        assert len(issues) == 1
        assert issues[0].category == ReviewCategory.TYPOGRAPHY

    def test_equal_flagged(self):
        brand = _brand(fonts=FontConfig(
            title_size=16, heading_size=16, body_size=16, caption_size=16,
        ))
        assert len(check_font_hierarchy(_slide(), 0, brand)) == 1


class TestAccentOveruse:
    def test_few_passes(self):
        slide = _slide(elements=[
            ShapeElement(shape_type=ShapeType.RECTANGLE, fill_color="#E94560"),
            ShapeElement(shape_type=ShapeType.RECTANGLE, fill_color="#E94560"),
        ])
        assert check_accent_overuse(slide, 0, _brand()) == []

    def test_overused_flagged(self):
        slide = _slide(elements=[
            ShapeElement(shape_type=ShapeType.RECTANGLE, fill_color="#E94560"),
            ShapeElement(shape_type=ShapeType.RECTANGLE, fill_color="#E94560"),
            ShapeElement(shape_type=ShapeType.RECTANGLE, fill_color="#E94560"),
        ])
        issues = check_accent_overuse(slide, 0, _brand())
        assert len(issues) == 1
        assert issues[0].category == ReviewCategory.COLOR

    def test_disabled_passes(self):
        brand = _brand(layout_rules=LayoutRules(use_accent_sparingly=False))
        slide = _slide(elements=[
            ShapeElement(shape_type=ShapeType.RECTANGLE, fill_color="#E94560"),
            ShapeElement(shape_type=ShapeType.RECTANGLE, fill_color="#E94560"),
            ShapeElement(shape_type=ShapeType.RECTANGLE, fill_color="#E94560"),
        ])
        assert check_accent_overuse(slide, 0, brand) == []


class TestChartBrandColors:
    def test_no_chart_colors_suggested(self):
        slide = _slide(elements=[ChartElement(chart_type=ChartType.BAR, data=ChartData(categories=["A"], series=[ChartDataSeries(name="S", values=[1])]))])
        issues = check_chart_brand_colors(slide, 0, _brand())
        assert len(issues) == 1
        assert issues[0].severity == Severity.SUGGESTION

    def test_chart_colors_passes(self):
        brand = _brand(colors=ColorPalette(
            primary="#1A1A2E", secondary="#16213E", accent="#E94560",
            chart_colors=["#FF0000", "#00FF00"],
        ))
        slide = _slide(elements=[ChartElement(chart_type=ChartType.BAR, data=ChartData(categories=["A"], series=[ChartDataSeries(name="S", values=[1])]))])
        assert check_chart_brand_colors(slide, 0, brand) == []


class TestLogoPresence:
    def test_missing_flagged(self):
        brand = _brand(logo=LogoConfig(path="/tmp/logo.png"))
        slide = _slide(elements=[ShapeElement(shape_type=ShapeType.RECTANGLE)])
        issues = check_logo_presence(slide, 0, brand)
        assert len(issues) == 1
        assert issues[0].category == ReviewCategory.BRAND

    def test_present_passes(self):
        brand = _brand(logo=LogoConfig(path="/tmp/logo.png"))
        slide = _slide(elements=[ImageElement(path="/tmp/logo.png", alt_text="Logo")])
        assert check_logo_presence(slide, 0, brand) == []

    def test_no_config_passes(self):
        assert check_logo_presence(_slide(), 0, _brand()) == []


class TestShadowBorderConflict:
    def test_thick_shadow_flagged(self):
        brand = _brand(shape_style=ShapeStyleConfig(shadow_preset="medium"))
        slide = _slide(elements=[
            ShapeElement(shape_type=ShapeType.RECTANGLE, border_width=3.0),
        ])
        issues = check_shadow_border_conflict(slide, 0, brand)
        assert len(issues) == 1
        assert issues[0].severity == Severity.SUGGESTION

    def test_thin_passes(self):
        brand = _brand(shape_style=ShapeStyleConfig(shadow_preset="medium"))
        slide = _slide(elements=[
            ShapeElement(shape_type=ShapeType.RECTANGLE, border_width=1.0),
        ])
        assert check_shadow_border_conflict(slide, 0, brand) == []

    def test_no_shadow_passes(self):
        brand = _brand(shape_style=ShapeStyleConfig(shadow_preset="none"))
        slide = _slide(elements=[
            ShapeElement(shape_type=ShapeType.RECTANGLE, border_width=3.0),
        ])
        assert check_shadow_border_conflict(slide, 0, brand) == []


# ===================================================================
# B7 — Deck-level rule
# ===================================================================

class TestVisualRhythm:
    def test_consecutive_flagged(self):
        slides = [
            _slide(slide_type=SlideType.TITLE),
            _slide(slide_type=SlideType.CONTENT),
            _slide(slide_type=SlideType.CONTENT),
            _slide(slide_type=SlideType.CONTENT),
        ]
        issues = check_visual_rhythm(slides, _brand())
        assert len(issues) == 1
        assert issues[0].category == ReviewCategory.NARRATIVE

    def test_varied_passes(self):
        slides = [
            _slide(slide_type=SlideType.TITLE),
            _slide(slide_type=SlideType.CONTENT),
            _slide(slide_type=SlideType.TWO_COLUMN),
            _slide(slide_type=SlideType.CONTENT),
        ]
        assert check_visual_rhythm(slides, _brand()) == []


# ===================================================================
# C1-C7 — PPTX-level spatial rules
# ===================================================================

class TestMinFontSize:
    def test_small_flagged(self):
        run = _mock_run(size_pt=8)
        shape = _mock_shape(paras=[_mock_para(run)])
        issues = check_min_font_size(_pptx_slide([shape]), 0, _brand())
        assert len(issues) == 1
        assert issues[0].category == ReviewCategory.ACCESSIBILITY

    def test_normal_passes(self):
        run = _mock_run(size_pt=16)
        shape = _mock_shape(paras=[_mock_para(run)])
        assert check_min_font_size(_pptx_slide([shape]), 0, _brand()) == []

    def test_no_text_passes(self):
        shape = _mock_shape(has_text=False)
        assert check_min_font_size(_pptx_slide([shape]), 0, _brand()) == []


class TestContrastRatio:
    def test_low_contrast_flagged(self):
        run = _mock_run(size_pt=14, color_hex="#333333")
        shape = _mock_shape(paras=[_mock_para(run)], fill_hex="#222222")
        issues = check_contrast_ratio(_pptx_slide([shape]), 0, _brand())
        assert len(issues) == 1
        assert issues[0].severity == Severity.WARNING

    def test_high_contrast_passes(self):
        run = _mock_run(size_pt=14, color_hex="#FFFFFF")
        shape = _mock_shape(paras=[_mock_para(run)], fill_hex="#000000")
        assert check_contrast_ratio(_pptx_slide([shape]), 0, _brand()) == []

    def test_large_text_lower_threshold(self):
        # ~3.5:1 contrast passes for large text (>=18pt, threshold 3.0)
        run = _mock_run(size_pt=20, color_hex="#888888")
        shape = _mock_shape(paras=[_mock_para(run)], fill_hex="#FFFFFF")
        assert check_contrast_ratio(_pptx_slide([shape]), 0, _brand()) == []

    def test_no_colors_skipped(self):
        run = _mock_run(size_pt=14)  # no color
        shape = _mock_shape(paras=[_mock_para(run)], fill_hex="#000000")
        assert check_contrast_ratio(_pptx_slide([shape]), 0, _brand()) == []


class TestFontCompliance:
    def test_off_brand_flagged(self):
        run = _mock_run(font_name="Comic Sans MS")
        shape = _mock_shape(paras=[_mock_para(run)])
        issues = check_font_compliance(_pptx_slide([shape]), 0, _brand())
        assert len(issues) == 1
        assert issues[0].category == ReviewCategory.BRAND

    def test_brand_passes(self):
        run = _mock_run(font_name="Calibri")
        shape = _mock_shape(paras=[_mock_para(run)])
        assert check_font_compliance(_pptx_slide([shape]), 0, _brand()) == []

    def test_mono_allowed(self):
        run = _mock_run(font_name="Consolas")
        shape = _mock_shape(paras=[_mock_para(run)])
        assert check_font_compliance(_pptx_slide([shape]), 0, _brand()) == []


class TestColorCompliance:
    def test_off_brand_flagged(self):
        shape = _mock_shape(fill_hex="#00FF00")
        issues = check_color_compliance(_pptx_slide([shape]), 0, _brand())
        assert len(issues) == 1
        assert issues[0].category == ReviewCategory.BRAND

    def test_brand_passes(self):
        shape = _mock_shape(fill_hex="#1A1A2E")
        assert check_color_compliance(_pptx_slide([shape]), 0, _brand()) == []

    def test_gray_ignored(self):
        shape = _mock_shape(fill_hex="#808080")
        assert check_color_compliance(_pptx_slide([shape]), 0, _brand()) == []

    def test_chart_colors_pass(self):
        brand = _brand(colors=ColorPalette(
            primary="#1A1A2E", secondary="#16213E", accent="#E94560",
            chart_colors=["#00FF00"],
        ))
        shape = _mock_shape(fill_hex="#00FF00")
        assert check_color_compliance(_pptx_slide([shape]), 0, brand) == []


class TestFontFamilyCount:
    def test_too_many_flagged(self):
        runs = [
            _mock_run(font_name="Arial"),
            _mock_run(font_name="Times"),
            _mock_run(font_name="Courier"),
            _mock_run(font_name="Georgia"),
        ]
        shape = _mock_shape(paras=[_mock_para(*runs)])
        issues = check_font_family_count(_pptx_slide([shape]), 0, _brand())
        assert len(issues) == 1
        assert issues[0].category == ReviewCategory.TYPOGRAPHY

    def test_two_passes(self):
        runs = [_mock_run(font_name="Arial"), _mock_run(font_name="Times")]
        shape = _mock_shape(paras=[_mock_para(*runs)])
        assert check_font_family_count(_pptx_slide([shape]), 0, _brand()) == []


class TestDistinctColorCount:
    def test_too_many_flagged(self):
        shapes = [
            _mock_shape(fill_hex="#FF0000"),
            _mock_shape(fill_hex="#00FF00"),
            _mock_shape(fill_hex="#0000FF"),
            _mock_shape(fill_hex="#FFFF00"),
            _mock_shape(fill_hex="#FF00FF"),
            _mock_shape(fill_hex="#00FFFF"),
        ]
        issues = check_distinct_color_count(_pptx_slide(shapes), 0, _brand())
        assert len(issues) == 1
        assert issues[0].category == ReviewCategory.COLOR

    def test_few_passes(self):
        shapes = [_mock_shape(fill_hex="#FF0000"), _mock_shape(fill_hex="#00FF00")]
        assert check_distinct_color_count(_pptx_slide(shapes), 0, _brand()) == []

    def test_grays_excluded(self):
        shapes = [_mock_shape(fill_hex=f"#{h:02X}{h:02X}{h:02X}") for h in range(128, 210, 14)]
        assert check_distinct_color_count(_pptx_slide(shapes), 0, _brand()) == []


class TestWhitespaceRatio:
    def test_crowded_flagged(self):
        shape = _mock_shape(left=0.5, top=0, width=12.0, height=7.5)
        issues = check_whitespace_ratio(
            _pptx_slide([shape]), 0, _brand(), slide_width=13.333, slide_height=7.5,
        )
        assert len(issues) == 1
        assert "crowded" in issues[0].message

    def test_sparse_flagged(self):
        shape = _mock_shape(left=5, top=3, width=0.5, height=0.5)
        issues = check_whitespace_ratio(
            _pptx_slide([shape]), 0, _brand(), slide_width=13.333, slide_height=7.5,
        )
        assert len(issues) == 1
        assert "sparse" in issues[0].message

    def test_normal_passes(self):
        shape = _mock_shape(left=1, top=1, width=8, height=5)
        assert check_whitespace_ratio(
            _pptx_slide([shape]), 0, _brand(), slide_width=13.333, slide_height=7.5,
        ) == []

    def test_blank_passes(self):
        assert check_whitespace_ratio(
            _pptx_slide([]), 0, _brand(), slide_width=13.333, slide_height=7.5,
        ) == []


# ===================================================================
# D — Auto-fix patterns
# ===================================================================

class TestFixTitleCasing:
    def test_mixed_normalized(self):
        agent = IterationAgent()
        agent._brand = None
        defn = _defn(
            _slide(title="hello world"),
            _slide(title="ANOTHER TITLE"),
        )
        result = agent._fix_title_casing(defn, MagicMock())
        assert result is not None
        assert defn.slides[0].title == "Hello World"
        assert defn.slides[1].title == "Another Title"

    def test_consistent_no_op(self):
        agent = IterationAgent()
        agent._brand = None
        defn = _defn(_slide(title="Already Title Case"))
        assert agent._fix_title_casing(defn, MagicMock()) is None


class TestFixFontSize:
    def test_below_bumped(self):
        brand = _brand(layout_rules=LayoutRules(min_font_size=14))
        agent = IterationAgent()
        agent._brand = brand
        defn = _defn(_slide(elements=[
            ShapeElement(shape_type=ShapeType.RECTANGLE, style=TextStyle(font_size=10)),
        ]))
        result = agent._fix_min_font_size(defn, MagicMock())
        assert result is not None
        assert defn.slides[0].elements[0].style.font_size == 14

    def test_above_unchanged(self):
        brand = _brand(layout_rules=LayoutRules(min_font_size=12))
        agent = IterationAgent()
        agent._brand = brand
        defn = _defn(_slide(elements=[
            ShapeElement(shape_type=ShapeType.RECTANGLE, style=TextStyle(font_size=16)),
        ]))
        assert agent._fix_min_font_size(defn, MagicMock()) is None

    def test_no_style_no_crash(self):
        brand = _brand(layout_rules=LayoutRules(min_font_size=12))
        agent = IterationAgent()
        agent._brand = brand
        defn = _defn(_slide(elements=[ShapeElement(shape_type=ShapeType.RECTANGLE)]))
        assert agent._fix_min_font_size(defn, MagicMock()) is None


class TestFixOffBrandColors:
    def test_snapped(self):
        agent = IterationAgent()
        agent._brand = _brand()
        defn = _defn(_slide(elements=[
            ShapeElement(shape_type=ShapeType.RECTANGLE, fill_color="#00FF00"),
        ]))
        result = agent._fix_off_brand_colors(defn, MagicMock())
        assert result is not None
        assert defn.slides[0].elements[0].fill_color != "#00FF00"

    def test_brand_unchanged(self):
        agent = IterationAgent()
        agent._brand = _brand()
        defn = _defn(_slide(elements=[
            ShapeElement(shape_type=ShapeType.RECTANGLE, fill_color="#1A1A2E"),
        ]))
        assert agent._fix_off_brand_colors(defn, MagicMock()) is None

    def test_no_fill_no_crash(self):
        agent = IterationAgent()
        agent._brand = _brand()
        defn = _defn(_slide(elements=[ShapeElement(shape_type=ShapeType.RECTANGLE)]))
        assert agent._fix_off_brand_colors(defn, MagicMock()) is None


class TestIterationBrand:
    def test_brand_passed_through(self):
        brand = _brand()
        agent = IterationAgent()
        inp = IterationInput(
            definition=_defn(_slide()),
            review=ReviewResult(
                overall_score=8.0, total_slides=1, total_issues=0,
                issues=[], slide_reviews=[], summary="OK",
            ),
            brand=brand,
        )
        agent.run(inp)
        assert agent._brand is brand

    def test_backward_compat(self):
        agent = IterationAgent()
        inp = IterationInput(
            definition=_defn(_slide()),
            review=ReviewResult(
                overall_score=8.0, total_slides=1, total_issues=0,
                issues=[], slide_reviews=[], summary="OK",
            ),
        )
        agent.run(inp)
        assert agent._brand is None
