"""Spatial validation rules — overlap, bounds, spacing, and text overflow checks."""

from __future__ import annotations

from typing import Any

from pptx_mcp.models.brand import BrandConfig
from pptx_mcp.models.review import ReviewCategory, ReviewIssue, Severity

# EMU-to-inches constant
_EMU_PER_INCH = 914400


def _shape_rect(shape: Any) -> tuple[float, float, float, float] | None:
    """Extract bounding rect (left, top, right, bottom) in inches.

    Returns None if the shape lacks position attributes.
    """
    try:
        left = shape.left
        top = shape.top
        width = shape.width
        height = shape.height
        if left is None or top is None or width is None or height is None:
            return None
        return (
            left / _EMU_PER_INCH,
            top / _EMU_PER_INCH,
            (left + width) / _EMU_PER_INCH,
            (top + height) / _EMU_PER_INCH,
        )
    except (AttributeError, TypeError):
        return None


def _shape_label(shape: Any, index: int) -> str:
    """Human-readable label for a shape."""
    name = getattr(shape, "name", None)
    if name:
        return f"'{name}' (index {index})"
    return f"shape index {index}"


def _rects_overlap(
    r1: tuple[float, float, float, float],
    r2: tuple[float, float, float, float],
    threshold: float = 0.05,
) -> bool:
    """Check if two rects overlap by more than threshold inches."""
    overlap_x = min(r1[2], r2[2]) - max(r1[0], r2[0])
    overlap_y = min(r1[3], r2[3]) - max(r1[1], r2[1])
    return overlap_x > threshold and overlap_y > threshold


_MAX_SHAPES_FOR_SPATIAL = 200


def check_element_overlap(
    slide: Any,
    slide_index: int,
    brand: BrandConfig,
    **kwargs: Any,
) -> list[ReviewIssue]:
    """Detect overlapping shapes on a slide."""
    issues: list[ReviewIssue] = []
    shapes = list(slide.shapes)
    if len(shapes) > _MAX_SHAPES_FOR_SPATIAL:
        return issues  # skip — too many shapes for pairwise checks
    rects: list[tuple[int, Any, tuple[float, float, float, float]]] = []

    for i, shape in enumerate(shapes):
        rect = _shape_rect(shape)
        if rect is not None:
            rects.append((i, shape, rect))

    for a_idx in range(len(rects)):
        for b_idx in range(a_idx + 1, len(rects)):
            i_a, shape_a, rect_a = rects[a_idx]
            i_b, shape_b, rect_b = rects[b_idx]
            if _rects_overlap(rect_a, rect_b):
                issues.append(ReviewIssue(
                    severity=Severity.WARNING,
                    category=ReviewCategory.LAYOUT,
                    message=(
                        f"Elements overlap: {_shape_label(shape_a, i_a)} "
                        f"and {_shape_label(shape_b, i_b)}."
                    ),
                    slide_index=slide_index,
                    suggestion="Move or resize elements so they don't overlap.",
                ))

    return issues


def check_element_bounds(
    slide: Any,
    slide_index: int,
    brand: BrandConfig,
    *,
    slide_width: float = 13.333,
    slide_height: float = 7.5,
    **kwargs: Any,
) -> list[ReviewIssue]:
    """Check that shapes stay within slide boundaries."""
    issues: list[ReviewIssue] = []
    tolerance = 0.05  # inches

    for i, shape in enumerate(slide.shapes):
        rect = _shape_rect(shape)
        if rect is None:
            continue

        left, top, right, bottom = rect
        out_parts: list[str] = []

        if left < -tolerance:
            out_parts.append(f"left edge at {left:.2f}\"")
        if top < -tolerance:
            out_parts.append(f"top edge at {top:.2f}\"")
        if right > slide_width + tolerance:
            out_parts.append(f"right edge at {right:.2f}\" (slide width {slide_width:.3f}\")")
        if bottom > slide_height + tolerance:
            out_parts.append(f"bottom edge at {bottom:.2f}\" (slide height {slide_height:.3f}\")")

        if out_parts:
            issues.append(ReviewIssue(
                severity=Severity.WARNING,
                category=ReviewCategory.LAYOUT,
                message=(
                    f"Element {_shape_label(shape, i)} extends beyond slide: "
                    f"{'; '.join(out_parts)}."
                ),
                slide_index=slide_index,
                suggestion="Reposition or resize the element to fit within the slide.",
            ))

    return issues


def check_element_spacing(
    slide: Any,
    slide_index: int,
    brand: BrandConfig,
    *,
    min_gap: float = 0.1,
    **kwargs: Any,
) -> list[ReviewIssue]:
    """Warn when non-overlapping shapes are too close together."""
    issues: list[ReviewIssue] = []
    shapes = list(slide.shapes)
    if len(shapes) > _MAX_SHAPES_FOR_SPATIAL:
        return issues  # skip — too many shapes for pairwise checks
    rects: list[tuple[int, Any, tuple[float, float, float, float]]] = []

    for i, shape in enumerate(shapes):
        rect = _shape_rect(shape)
        if rect is not None:
            rects.append((i, shape, rect))

    for a_idx in range(len(rects)):
        for b_idx in range(a_idx + 1, len(rects)):
            i_a, shape_a, rect_a = rects[a_idx]
            i_b, shape_b, rect_b = rects[b_idx]

            # Skip if overlapping (handled by overlap rule)
            if _rects_overlap(rect_a, rect_b, threshold=0.0):
                continue

            # Compute minimum gap between edges
            gap_x = max(0.0, max(rect_a[0], rect_b[0]) - min(rect_a[2], rect_b[2]))
            gap_y = max(0.0, max(rect_a[1], rect_b[1]) - min(rect_a[3], rect_b[3]))

            # Only flag shapes that are nearby (not on opposite sides)
            gap = max(gap_x, gap_y)
            if gap_x > 0 and gap_y > 0:
                # Diagonal — use Euclidean-ish distance
                gap = (gap_x ** 2 + gap_y ** 2) ** 0.5

            if 0 < gap < min_gap:
                issues.append(ReviewIssue(
                    severity=Severity.INFO,
                    category=ReviewCategory.WHITESPACE,
                    message=(
                        f"Tight spacing ({gap:.2f}\") between "
                        f"{_shape_label(shape_a, i_a)} and {_shape_label(shape_b, i_b)}."
                    ),
                    slide_index=slide_index,
                    suggestion=f"Increase spacing to at least {min_gap}\" for visual clarity.",
                ))

    return issues


def check_text_overflow(
    slide: Any,
    slide_index: int,
    brand: BrandConfig,
    **kwargs: Any,
) -> list[ReviewIssue]:
    """Heuristic check for text likely overflowing its container."""
    issues: list[ReviewIssue] = []

    for i, shape in enumerate(slide.shapes):
        if not shape.has_text_frame:
            continue

        rect = _shape_rect(shape)
        if rect is None:
            continue

        width_in = rect[2] - rect[0]
        height_in = rect[3] - rect[1]

        if width_in <= 0 or height_in <= 0:
            continue

        tf = shape.text_frame
        full_text = tf.text or ""
        if not full_text.strip():
            continue

        # Estimate font size from first run
        font_size_pt = 12.0  # default
        for para in tf.paragraphs:
            for run in para.runs:
                if run.font.size is not None:
                    font_size_pt = run.font.size / 12700  # EMU to pt
                    break
            else:
                continue
            break

        # Heuristic: average char width ≈ font_size * 0.6 / 72 inches
        avg_char_width = font_size_pt * 0.6 / 72.0
        chars_per_line = max(1, int(width_in / avg_char_width))

        # Line height ≈ font_size * 1.2 / 72 inches
        line_height = font_size_pt * 1.2 / 72.0
        max_lines = max(1, int(height_in / line_height))

        # Estimate lines needed
        lines_needed = 0
        for para in tf.paragraphs:
            para_text = para.text or ""
            para_lines = max(1, (len(para_text) + chars_per_line - 1) // chars_per_line)
            lines_needed += para_lines

        # Warn if estimated lines exceed capacity by >30%
        if lines_needed > max_lines * 1.3:
            issues.append(ReviewIssue(
                severity=Severity.WARNING,
                category=ReviewCategory.LAYOUT,
                message=(
                    f"Text likely overflows {_shape_label(shape, i)}: "
                    f"~{lines_needed} lines needed, ~{max_lines} fit."
                ),
                slide_index=slide_index,
                suggestion="Reduce text, decrease font size, or enlarge the text box.",
            ))

    return issues


# ---------------------------------------------------------------------------
# Extraction helpers for PPTX-level rules
# ---------------------------------------------------------------------------


def _extract_shape_fill_hex(shape: Any) -> str | None:
    """Safely get hex fill from PPTX shape (solid fills only)."""
    try:
        fill = shape.fill
        if fill.type is not None and fill.type == 1:  # MSO_FILL_TYPE.SOLID
            rgb = fill.fore_color.rgb
            if rgb is not None:
                return f"#{rgb}"
    except (AttributeError, TypeError, ValueError):
        pass
    return None


def _extract_text_color_hex(run: Any) -> str | None:
    """Safely get explicit text color from run (skip theme/inherited)."""
    try:
        font = run.font
        if font.color and font.color.type is not None:
            rgb = font.color.rgb
            if rgb is not None:
                return f"#{rgb}"
    except (AttributeError, TypeError, ValueError):
        pass
    return None


def _is_neutral_gray(hex_color: str, tolerance: int = 10) -> bool:
    """True if R approx G approx B within tolerance."""
    c = hex_color.lstrip("#")
    if len(c) != 6:
        return False
    r, g, b = int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16)
    return abs(r - g) <= tolerance and abs(g - b) <= tolerance and abs(r - b) <= tolerance


def _collect_brand_palette(brand: BrandConfig) -> set[str]:
    """Build full set of brand hex colors for compliance checks."""
    colors = brand.colors
    palette: set[str] = set()
    for attr in ("primary", "secondary", "accent", "background", "text_primary",
                 "text_secondary", "success", "danger", "surface", "border"):
        val = getattr(colors, attr, None)
        if val:
            palette.add(f"#{val.lstrip('#').upper()}")
    for c in colors.chart_colors:
        palette.add(f"#{c.lstrip('#').upper()}")
    for c in colors.custom_tokens.values():
        palette.add(f"#{c.lstrip('#').upper()}")
    return palette


# ---------------------------------------------------------------------------
# PPTX-level rules (C1-C7)
# ---------------------------------------------------------------------------


def check_min_font_size(
    slide: Any,
    slide_index: int,
    brand: BrandConfig,
    **kwargs: Any,
) -> list[ReviewIssue]:
    """Flag text runs below the brand minimum font size."""
    min_pt = brand.layout_rules.min_font_size
    violation_count = 0

    for shape in slide.shapes:
        if not shape.has_text_frame:
            continue
        for para in shape.text_frame.paragraphs:
            for run in para.runs:
                if run.font.size is not None:
                    size_pt = run.font.size / 12700  # EMU to pt
                    if size_pt < min_pt:
                        violation_count += 1

    if violation_count:
        return [ReviewIssue(
            severity=Severity.WARNING,
            category=ReviewCategory.ACCESSIBILITY,
            message=f"{violation_count} text run(s) below minimum font size ({min_pt}pt).",
            slide_index=slide_index,
            suggestion=f"Increase font size to at least {min_pt}pt for readability.",
        )]
    return []


def check_contrast_ratio(
    slide: Any,
    slide_index: int,
    brand: BrandConfig,
    **kwargs: Any,
) -> list[ReviewIssue]:
    """Check text-to-background contrast ratio (WCAG guidelines)."""
    from pptx_mcp.engine.shapes import contrast_ratio as _contrast_ratio

    issues: list[ReviewIssue] = []

    for i, shape in enumerate(slide.shapes):
        if not shape.has_text_frame:
            continue
        bg_hex = _extract_shape_fill_hex(shape)
        if bg_hex is None:
            continue

        flagged = False
        for para in shape.text_frame.paragraphs:
            if flagged:
                break
            for run in para.runs:
                text_hex = _extract_text_color_hex(run)
                if text_hex is None:
                    continue

                ratio = _contrast_ratio(text_hex, bg_hex)
                # Large text (>=18pt): threshold 3.0; body text: 4.5
                is_large = False
                if run.font.size is not None:
                    is_large = (run.font.size / 12700) >= 18.0
                threshold = 3.0 if is_large else 4.5

                if ratio < threshold:
                    issues.append(ReviewIssue(
                        severity=Severity.WARNING,
                        category=ReviewCategory.ACCESSIBILITY,
                        message=(
                            f"Low contrast ({ratio:.1f}:1) on {_shape_label(shape, i)} "
                            f"(threshold {threshold:.1f}:1)."
                        ),
                        slide_index=slide_index,
                        suggestion="Increase contrast between text and background colors.",
                    ))
                    flagged = True
                    break

    return issues


def check_font_compliance(
    slide: Any,
    slide_index: int,
    brand: BrandConfig,
    **kwargs: Any,
) -> list[ReviewIssue]:
    """Check that fonts used are from the brand font set."""
    allowed = {brand.fonts.heading_font, brand.fonts.body_font, brand.fonts.mono_font}
    if brand.fonts.display_font:
        allowed.add(brand.fonts.display_font)

    off_brand: set[str] = set()
    for shape in slide.shapes:
        if not shape.has_text_frame:
            continue
        for para in shape.text_frame.paragraphs:
            for run in para.runs:
                name = run.font.name
                if name and name not in allowed:
                    off_brand.add(name)

    if off_brand:
        return [ReviewIssue(
            severity=Severity.INFO,
            category=ReviewCategory.BRAND,
            message=f"Off-brand font(s): {', '.join(sorted(off_brand))}.",
            slide_index=slide_index,
            suggestion=f"Use brand fonts: {', '.join(sorted(allowed))}.",
        )]
    return []


def check_color_compliance(
    slide: Any,
    slide_index: int,
    brand: BrandConfig,
    **kwargs: Any,
) -> list[ReviewIssue]:
    """Check that colors used are close to the brand palette."""
    from pptx_mcp.engine.shapes import color_distance as _color_distance

    palette = _collect_brand_palette(brand)
    if not palette:
        return []

    off_brand: set[str] = set()
    for shape in slide.shapes:
        fill_hex = _extract_shape_fill_hex(shape)
        if fill_hex and not _is_neutral_gray(fill_hex):
            normalized = f"#{fill_hex.lstrip('#').upper()}"
            if all(_color_distance(normalized, pc) > 50 for pc in palette):
                off_brand.add(normalized)

        if shape.has_text_frame:
            for para in shape.text_frame.paragraphs:
                for run in para.runs:
                    text_hex = _extract_text_color_hex(run)
                    if text_hex and not _is_neutral_gray(text_hex):
                        normalized = f"#{text_hex.lstrip('#').upper()}"
                        if all(_color_distance(normalized, pc) > 50 for pc in palette):
                            off_brand.add(normalized)

    if off_brand:
        return [ReviewIssue(
            severity=Severity.INFO,
            category=ReviewCategory.BRAND,
            message=f"Off-brand color(s): {', '.join(sorted(off_brand))}.",
            slide_index=slide_index,
            suggestion="Use brand palette colors for visual consistency.",
        )]
    return []


def check_font_family_count(
    slide: Any,
    slide_index: int,
    brand: BrandConfig,
    **kwargs: Any,
) -> list[ReviewIssue]:
    """Flag slides using too many distinct font families."""
    families: set[str] = set()
    for shape in slide.shapes:
        if not shape.has_text_frame:
            continue
        for para in shape.text_frame.paragraphs:
            for run in para.runs:
                if run.font.name:
                    families.add(run.font.name)

    if len(families) > 3:
        return [ReviewIssue(
            severity=Severity.INFO,
            category=ReviewCategory.TYPOGRAPHY,
            message=f"Slide uses {len(families)} font families ({', '.join(sorted(families))}); limit to 3.",
            slide_index=slide_index,
            suggestion="Reduce font variety for a cleaner look.",
        )]
    return []


def check_distinct_color_count(
    slide: Any,
    slide_index: int,
    brand: BrandConfig,
    **kwargs: Any,
) -> list[ReviewIssue]:
    """Flag slides with too many distinct non-neutral colors."""
    colors: set[str] = set()
    bg = brand.colors.background.lstrip("#").upper()

    for shape in slide.shapes:
        fill_hex = _extract_shape_fill_hex(shape)
        if fill_hex:
            norm = fill_hex.lstrip("#").upper()
            if norm != bg and not _is_neutral_gray(fill_hex):
                colors.add(norm)

        if shape.has_text_frame:
            for para in shape.text_frame.paragraphs:
                for run in para.runs:
                    text_hex = _extract_text_color_hex(run)
                    if text_hex:
                        norm = text_hex.lstrip("#").upper()
                        if norm != bg and not _is_neutral_gray(text_hex):
                            colors.add(norm)

    if len(colors) > 5:
        return [ReviewIssue(
            severity=Severity.INFO,
            category=ReviewCategory.COLOR,
            message=f"Slide uses {len(colors)} distinct colors; limit to 5 for cohesion.",
            slide_index=slide_index,
            suggestion="Consolidate to a focused color palette.",
        )]
    return []


def check_whitespace_ratio(
    slide: Any,
    slide_index: int,
    brand: BrandConfig,
    *,
    slide_width: float = 13.333,
    slide_height: float = 7.5,
    **kwargs: Any,
) -> list[ReviewIssue]:
    """Check the ratio of shape coverage to slide area."""
    slide_area = slide_width * slide_height
    shape_area = 0.0
    shape_count = 0

    for shape in slide.shapes:
        rect = _shape_rect(shape)
        if rect is None:
            continue
        w = rect[2] - rect[0]
        h = rect[3] - rect[1]
        if w > 0 and h > 0:
            shape_area += w * h
            shape_count += 1

    if shape_count == 0:
        return []

    ratio = shape_area / slide_area
    issues: list[ReviewIssue] = []

    if ratio > 0.85:
        issues.append(ReviewIssue(
            severity=Severity.INFO,
            category=ReviewCategory.WHITESPACE,
            message=f"Slide is {ratio:.0%} covered — too crowded.",
            slide_index=slide_index,
            suggestion="Add whitespace by reducing element sizes or removing content.",
        ))
    elif ratio < 0.15:
        issues.append(ReviewIssue(
            severity=Severity.INFO,
            category=ReviewCategory.WHITESPACE,
            message=f"Slide is only {ratio:.0%} covered — may appear sparse.",
            slide_index=slide_index,
            suggestion="Add content or increase element sizes to fill the slide.",
        ))

    return issues


# Registry of spatial validation rules
SPATIAL_RULES = [
    check_element_overlap,
    check_element_bounds,
    check_element_spacing,
    check_text_overflow,
    check_min_font_size,
    check_contrast_ratio,
    check_font_compliance,
    check_color_compliance,
    check_font_family_count,
    check_distinct_color_count,
    check_whitespace_ratio,
]
