"""Brand extraction engine — analyzes an existing PPTX and produces a BrandConfig."""

from __future__ import annotations

from collections import Counter
from pathlib import Path

from pptx import Presentation

from pptx_mcp.models.brand import (
    BrandConfig,
    ColorPalette,
    FontConfig,
    SpacingConfig,
    ToneStyle,
)
from pptx_mcp.utils.errors import ParseError
from pptx_mcp.utils.logging import get_logger

logger = get_logger("engine.brand_extractor")

# Font sizes at or above this threshold are considered "heading" text
_HEADING_SIZE_THRESHOLD_PT = 20
# Default slide dimensions (widescreen 16:9)
_DEFAULT_SLIDE_WIDTH_IN = 13.333
_DEFAULT_SLIDE_HEIGHT_IN = 7.5


def _emu_to_inches(emu: int | None) -> float:
    if emu is None:
        return 0.0
    return round(emu / 914400, 3)


def _emu_to_pt(emu: int | None) -> float:
    if emu is None:
        return 0.0
    return round(emu / 12700, 1)


def _rgb_to_hex(rgb: object) -> str | None:
    """Convert an RGBColor to a hex string like '#1A2B3C'."""
    try:
        if rgb is not None:
            return f"#{rgb}"
    except (TypeError, ValueError):
        pass
    return None


def _extract_fill_hex(obj: object) -> str | None:
    """Extract solid fill color as hex from a shape or cell."""
    try:
        fill = obj.fill  # type: ignore[attr-defined]
        if fill.type is not None:
            return _rgb_to_hex(fill.fore_color.rgb)
    except (AttributeError, TypeError, ValueError):
        pass
    return None


def _extract_line_hex(obj: object) -> str | None:
    """Extract border/line color as hex from a shape."""
    try:
        line = obj.line  # type: ignore[attr-defined]
        if line.color and line.color.rgb is not None:
            return _rgb_to_hex(line.color.rgb)
    except (AttributeError, TypeError, ValueError):
        pass
    return None


def _extract_slide_bg_hex(slide: object) -> str | None:
    """Extract slide background fill color."""
    try:
        bg = slide.background  # type: ignore[attr-defined]
        fill = bg.fill
        if fill.type is not None:
            return _rgb_to_hex(fill.fore_color.rgb)
    except (AttributeError, TypeError, ValueError):
        pass
    return None


def _is_near_white(hex_color: str) -> bool:
    """Check if a color is near-white (all channels >= 0xF0)."""
    hex_color = hex_color.lstrip("#")
    if len(hex_color) != 6:
        return False
    try:
        r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
        return r >= 0xF0 and g >= 0xF0 and b >= 0xF0
    except ValueError:
        return False


def _is_near_black(hex_color: str) -> bool:
    """Check if a color is near-black (all channels <= 0x30)."""
    hex_color = hex_color.lstrip("#")
    if len(hex_color) != 6:
        return False
    try:
        r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
        return r <= 0x30 and g <= 0x30 and b <= 0x30
    except ValueError:
        return False


def _is_chromatic(hex_color: str) -> bool:
    """Check if a color has meaningful saturation (not gray/white/black)."""
    hex_color = hex_color.lstrip("#")
    if len(hex_color) != 6:
        return False
    try:
        r, g, b = int(hex_color[0:2], 16), int(hex_color[2:4], 16), int(hex_color[4:6], 16)
        max_c = max(r, g, b)
        min_c = min(r, g, b)
        # Chromatic if there's meaningful spread between channels
        return (max_c - min_c) > 30
    except ValueError:
        return False


def extract_brand(file_path: str | Path) -> BrandConfig:
    """Analyze a PPTX file and extract a BrandConfig from its visual properties.

    Scans all slides and shapes to collect font families, sizes, fill colors,
    text colors, and border colors. Aggregates by frequency to determine the
    brand's primary/secondary/accent colors and heading/body fonts.

    Args:
        file_path: Path to the .pptx file.

    Returns:
        Extracted BrandConfig.

    Raises:
        ParseError: If the file cannot be read.
    """
    file_path = Path(file_path)
    if not file_path.exists():
        raise ParseError(f"File not found: {file_path.name}")

    try:
        prs = Presentation(str(file_path))
    except Exception as e:
        raise ParseError(f"Failed to open PPTX file '{file_path.name}'") from e

    # Counters for aggregation
    fill_colors: Counter[str] = Counter()
    text_colors: Counter[str] = Counter()
    border_colors: Counter[str] = Counter()
    heading_fonts: Counter[str] = Counter()
    body_fonts: Counter[str] = Counter()
    heading_sizes: Counter[int] = Counter()
    body_sizes: Counter[int] = Counter()
    bg_colors: Counter[str] = Counter()

    # Spacing analysis: collect left margins and top positions
    left_positions: list[float] = []
    top_positions: list[float] = []

    for slide in prs.slides:
        # Slide background
        bg_hex = _extract_slide_bg_hex(slide)
        if bg_hex:
            bg_colors[bg_hex.upper()] += 1

        for shape in slide.shapes:
            # Fill colors (non-text shapes like cards, boxes)
            fill_hex = _extract_fill_hex(shape)
            if fill_hex:
                fill_colors[fill_hex.upper()] += 1

            # Border colors
            line_hex = _extract_line_hex(shape)
            if line_hex:
                border_colors[line_hex.upper()] += 1

            # Position data
            left_in = _emu_to_inches(getattr(shape, "left", None))
            top_in = _emu_to_inches(getattr(shape, "top", None))
            if left_in > 0:
                left_positions.append(left_in)
            if top_in > 0:
                top_positions.append(top_in)

            # Text analysis
            if not hasattr(shape, "text_frame"):
                continue
            try:
                tf = shape.text_frame
            except Exception:
                continue

            for para in tf.paragraphs:
                for run in para.runs:
                    font = run.font
                    if not font:
                        continue

                    # Font size
                    size_pt = 0
                    if font.size is not None:
                        size_pt = int(font.size.pt)

                    # Font name
                    font_name = font.name
                    if font_name:
                        if size_pt >= _HEADING_SIZE_THRESHOLD_PT:
                            heading_fonts[font_name] += 1
                            if size_pt > 0:
                                heading_sizes[size_pt] += 1
                        elif size_pt > 0:
                            body_fonts[font_name] += 1
                            body_sizes[size_pt] += 1
                        else:
                            # No size info — count in body as default
                            body_fonts[font_name] += 1

                    # Text color
                    try:
                        rgb = font.color.rgb
                        if rgb is not None:
                            text_colors[f"#{rgb}".upper()] += 1
                    except (AttributeError, TypeError):
                        pass

    # Determine presentation title from metadata
    title = prs.core_properties.title or file_path.stem

    # Build color palette
    colors = _build_color_palette(fill_colors, text_colors, border_colors, bg_colors)

    # Build font config
    fonts = _build_font_config(heading_fonts, body_fonts, heading_sizes, body_sizes)

    # Build spacing config
    spacing = _build_spacing_config(left_positions, top_positions)

    return BrandConfig(
        name=title,
        colors=colors,
        fonts=fonts,
        spacing=spacing,
        tone=ToneStyle.EXECUTIVE,
    )


def _build_color_palette(
    fill_colors: Counter[str],
    text_colors: Counter[str],
    border_colors: Counter[str],
    bg_colors: Counter[str],
) -> ColorPalette:
    """Map collected colors by frequency to a ColorPalette."""
    # Background: most common bg color, default white
    background = "#FFFFFF"
    if bg_colors:
        top_bg = bg_colors.most_common(1)[0][0]
        background = top_bg

    # Chromatic fill colors (exclude near-white, near-black, transparent fills)
    chromatic_fills = Counter({
        c: n for c, n in fill_colors.items()
        if _is_chromatic(c)
    })

    # Chromatic text colors (exclude near-black which is body text)
    chromatic_text = Counter({
        c: n for c, n in text_colors.items()
        if _is_chromatic(c)
    })

    # Merge fill + text colors for overall brand color detection
    all_chromatic: Counter[str] = Counter()
    all_chromatic.update(chromatic_fills)
    all_chromatic.update(chromatic_text)

    # Primary: most common chromatic color overall
    primary = "#1B2A4A"  # fallback executive default
    secondary = "#2D5F8A"
    accent = "#E8913A"

    ranked = all_chromatic.most_common()
    if len(ranked) >= 1:
        primary = ranked[0][0]
    if len(ranked) >= 2:
        secondary = ranked[1][0]
    if len(ranked) >= 3:
        accent = ranked[2][0]

    # Text colors: most common dark text
    text_primary = "#1A1A2E"
    text_secondary = "#4A4A5A"
    dark_texts = [
        (c, n) for c, n in text_colors.most_common()
        if _is_near_black(c) or not _is_chromatic(c)
    ]
    if dark_texts:
        text_primary = dark_texts[0][0]
        if len(dark_texts) >= 2:
            text_secondary = dark_texts[1][0]

    # Chart colors: top chromatic colors
    chart_colors = [c for c, _ in ranked[:6]]

    # Border color
    border = "#E0E0E0"
    if border_colors:
        border = border_colors.most_common(1)[0][0]

    # Surface: look for light fill colors
    surface = "#F8F9FA"
    light_fills = [
        (c, n) for c, n in fill_colors.most_common()
        if _is_near_white(c) and c.upper() != background.upper()
    ]
    if light_fills:
        surface = light_fills[0][0]

    return ColorPalette(
        primary=primary,
        secondary=secondary,
        accent=accent,
        background=background,
        text_primary=text_primary,
        text_secondary=text_secondary,
        chart_colors=chart_colors,
        border=border,
        surface=surface,
    )


def _build_font_config(
    heading_fonts: Counter[str],
    body_fonts: Counter[str],
    heading_sizes: Counter[int],
    body_sizes: Counter[int],
) -> FontConfig:
    """Map collected font data to a FontConfig."""
    heading_font = "Calibri"
    body_font = "Calibri"

    if heading_fonts:
        heading_font = heading_fonts.most_common(1)[0][0]
    if body_fonts:
        body_font = body_fonts.most_common(1)[0][0]

    # Size clusters
    title_size = 36
    heading_size = 28
    body_size = 16

    if heading_sizes:
        sorted_heading = sorted(heading_sizes.keys(), reverse=True)
        title_size = sorted_heading[0]
        if len(sorted_heading) >= 2:
            heading_size = sorted_heading[1]
        else:
            heading_size = max(title_size - 8, 18)

    if body_sizes:
        # Most common body size
        body_size = body_sizes.most_common(1)[0][0]

    subheading_size = max(body_size + 2, heading_size - 6)

    return FontConfig(
        heading_font=heading_font,
        body_font=body_font,
        title_size=title_size,
        heading_size=heading_size,
        subheading_size=subheading_size,
        body_size=body_size,
    )


def _build_spacing_config(
    left_positions: list[float],
    top_positions: list[float],
) -> SpacingConfig:
    """Estimate spacing from shape positions."""
    margin_left = 0.7
    margin_top = 0.8

    if left_positions:
        # The minimum left position (excluding near-zero) approximates the margin
        meaningful = [p for p in left_positions if p >= 0.2]
        if meaningful:
            margin_left = round(min(meaningful), 2)

    if top_positions:
        meaningful = [p for p in top_positions if p >= 0.2]
        if meaningful:
            margin_top = round(min(meaningful), 2)

    return SpacingConfig(
        margin_left=min(margin_left, 1.5),
        margin_right=min(margin_left, 1.5),  # assume symmetric
        margin_top=min(margin_top, 1.5),
    )
