"""Shape and diagram rendering for python-pptx."""

from __future__ import annotations

from typing import Optional, Sequence

from lxml import etree
from pptx.dml.color import RGBColor
from pptx.enum.dml import MSO_LINE_DASH_STYLE
from pptx.enum.shapes import MSO_SHAPE
from pptx.enum.text import PP_ALIGN
from pptx.oxml.ns import qn
from pptx.slide import Slide
from pptx.util import Inches, Pt

from pptx_mcp.models.brand import BrandConfig
from pptx_mcp.models.slides import (
    ConnectorElement,
    Position,
    ShapeElement,
    ShapeType,
    TextStyle,
)
from pptx_mcp.utils.logging import get_logger

logger = get_logger("engine.shapes")

SHAPE_TYPE_MAP: dict[ShapeType, MSO_SHAPE] = {
    ShapeType.RECTANGLE: MSO_SHAPE.RECTANGLE,
    ShapeType.ROUNDED_RECTANGLE: MSO_SHAPE.ROUNDED_RECTANGLE,
    ShapeType.CIRCLE: MSO_SHAPE.OVAL,
    ShapeType.OVAL: MSO_SHAPE.OVAL,
    ShapeType.ARROW_RIGHT: MSO_SHAPE.RIGHT_ARROW,
    ShapeType.ARROW_LEFT: MSO_SHAPE.LEFT_ARROW,
    ShapeType.ARROW_UP: MSO_SHAPE.UP_ARROW,
    ShapeType.ARROW_DOWN: MSO_SHAPE.DOWN_ARROW,
    ShapeType.CHEVRON: MSO_SHAPE.CHEVRON,
    ShapeType.DIAMOND: MSO_SHAPE.DIAMOND,
    ShapeType.TRIANGLE: MSO_SHAPE.ISOSCELES_TRIANGLE,
    ShapeType.PENTAGON: MSO_SHAPE.PENTAGON,
    ShapeType.HEXAGON: MSO_SHAPE.HEXAGON,
    ShapeType.CALLOUT: MSO_SHAPE.ROUNDED_RECTANGULAR_CALLOUT,
    ShapeType.OCTAGON: MSO_SHAPE.OCTAGON,
    ShapeType.STAR_5: MSO_SHAPE.STAR_5_POINT,
    ShapeType.STAR_6: MSO_SHAPE.STAR_6_POINT,
    ShapeType.HEART: MSO_SHAPE.HEART,
    ShapeType.LIGHTNING: MSO_SHAPE.LIGHTNING_BOLT,
    ShapeType.CLOUD: MSO_SHAPE.CLOUD,
    ShapeType.BLOCK_ARC: MSO_SHAPE.BLOCK_ARC,
    ShapeType.CROSS: MSO_SHAPE.CROSS,
    ShapeType.DONUT: MSO_SHAPE.DONUT,
    ShapeType.NO_SYMBOL: MSO_SHAPE.NO_SYMBOL,
}

ALIGN_MAP = {
    "left": PP_ALIGN.LEFT,
    "center": PP_ALIGN.CENTER,
    "right": PP_ALIGN.RIGHT,
    "justify": PP_ALIGN.JUSTIFY,
}


def parse_hex_color(hex_color: str) -> RGBColor:
    """Convert a hex color string to an RGBColor."""
    color = hex_color.lstrip("#")
    if len(color) != 6:
        raise ValueError(f"Invalid hex color: {hex_color}")
    r, g, b = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
    return RGBColor(r, g, b)


def default_position(index: int) -> Position:
    """Generate a default position for a shape based on its index."""
    col = index % 4
    row = index // 4
    return Position(
        left=1.0 + col * 3.0,
        top=2.0 + row * 2.0,
        width=2.5,
        height=1.5,
    )


def add_shape_to_slide(
    slide: Slide,
    element: ShapeElement,
    index: int = 0,
) -> None:
    """Add a geometric shape to a slide.

    Args:
        slide: The python-pptx Slide object.
        element: Shape definition.
        index: Element index for default positioning.
    """
    pos = element.position or default_position(index)
    mso_shape = SHAPE_TYPE_MAP.get(element.shape_type, MSO_SHAPE.RECTANGLE)

    if element.shape_type == ShapeType.LINE:
        shape = slide.shapes.add_connector(
            1,  # MSO_CONNECTOR_TYPE.STRAIGHT
            Inches(pos.left),
            Inches(pos.top),
            Inches(pos.left + pos.width),
            Inches(pos.top + pos.height),
        )
        if element.border_color:
            shape.line.color.rgb = parse_hex_color(element.border_color)
        if element.border_width:
            shape.line.width = Pt(element.border_width)
        return

    shape = slide.shapes.add_shape(
        mso_shape,
        Inches(pos.left),
        Inches(pos.top),
        Inches(pos.width),
        Inches(pos.height),
    )

    if element.fill_color:
        shape.fill.solid()
        shape.fill.fore_color.rgb = parse_hex_color(element.fill_color)
    else:
        shape.fill.background()

    if element.border_color:
        shape.line.color.rgb = parse_hex_color(element.border_color)
        if element.border_width:
            shape.line.width = Pt(element.border_width)
        else:
            # Default visible border when color is specified but no width
            shape.line.width = Pt(1.0)
    elif element.border_width:
        shape.line.width = Pt(element.border_width)
    else:
        shape.line.fill.background()

    if element.rotation is not None:
        shape.rotation = element.rotation

    if element.text:
        tf = shape.text_frame
        tf.word_wrap = True
        p = tf.paragraphs[0]
        p.text = element.text
        p.alignment = PP_ALIGN.CENTER
        _apply_text_style(p.runs[0], element.style)

    if element.hyperlink:
        shape.click_action.hyperlink.address = element.hyperlink


def _resolve_shape(
    element_attr_id: str | None,
    element_attr_index: int | None,
    shapes_on_slide: list,
    shape_id_map: dict[str, object] | None,
    label: str,
) -> object | None:
    """Resolve a shape reference by ID (priority) or index.

    Returns the shape object or None if not found.
    """
    if element_attr_id and shape_id_map and element_attr_id in shape_id_map:
        return shape_id_map[element_attr_id]
    if element_attr_index is not None:
        if 0 <= element_attr_index < len(shapes_on_slide):
            return shapes_on_slide[element_attr_index]
        logger.warning("Connector %s index %d out of range", label, element_attr_index)
        return None
    if element_attr_id:
        logger.warning("Connector %s shape ID '%s' not found in shape_id_map", label, element_attr_id)
    return None


_CONNECTOR_TYPE_MAP: dict[str, int] = {
    "straight": 1,
    "elbow": 2,
    "curved": 3,
}


def add_connector_to_slide(
    slide: Slide,
    element: ConnectorElement,
    shapes_on_slide: list,
    shape_id_map: dict[str, object] | None = None,
) -> None:
    """Add a connector line between two shapes.

    Args:
        slide: The python-pptx Slide object.
        element: Connector definition.
        shapes_on_slide: List of shape objects already on the slide.
        shape_id_map: Optional mapping of shape IDs to shape objects.
    """
    start = _resolve_shape(
        element.start_shape_id, element.start_shape_index,
        shapes_on_slide, shape_id_map, "start",
    )
    end = _resolve_shape(
        element.end_shape_id, element.end_shape_index,
        shapes_on_slide, shape_id_map, "end",
    )

    if start is None or end is None:
        return

    start_x = start.left + start.width // 2
    start_y = start.top + start.height // 2
    end_x = end.left + end.width // 2
    end_y = end.top + end.height // 2

    connector_type = _CONNECTOR_TYPE_MAP.get(element.connector_type, 1)

    connector = slide.shapes.add_connector(
        connector_type,
        start_x,
        start_y,
        end_x,
        end_y,
    )

    if element.color:
        connector.line.color.rgb = parse_hex_color(element.color)
    if element.width:
        connector.line.width = Pt(element.width)


def _apply_text_style(
    run: object,
    style: Optional[TextStyle],
) -> None:
    """Apply text styling to a run object."""
    if style is None:
        return
    font = run.font  # type: ignore[attr-defined]
    if style.font_name:
        font.name = style.font_name
    if style.font_size:
        font.size = Pt(style.font_size)
    if style.bold:
        font.bold = True
    if style.italic:
        font.italic = True
    if style.underline:
        font.underline = True
    if style.color:
        font.color.rgb = parse_hex_color(style.color)


def set_corner_radius(shape: object, radius: float) -> None:
    """Set the corner radius on a rounded rectangle shape.

    Args:
        shape: A python-pptx shape (must be ROUNDED_RECTANGLE).
        radius: Normalized radius 0.0 (sharp) to 0.5 (pill).
    """
    try:
        shape.adjustments[0] = radius  # type: ignore[index]
    except (IndexError, TypeError):
        pass  # shape doesn't support adjustments


def add_drop_shadow(
    shape: object,
    blur_pt: float = 4.0,
    dist_pt: float = 3.0,
    direction_deg: int = 315,
    color: str = "000000",
    alpha_pct: int = 25,
) -> None:
    """Add a drop shadow to a shape via XML.

    Args:
        shape: A python-pptx shape object.
        blur_pt: Shadow blur radius in points.
        dist_pt: Shadow offset distance in points.
        direction_deg: Shadow direction in degrees.
        color: Shadow color as hex (no #).
        alpha_pct: Shadow opacity 0-100.
    """
    spPr = shape._element.spPr  # type: ignore[attr-defined]
    # Remove existing effects
    for old in spPr.findall(qn("a:effectLst")):
        spPr.remove(old)

    effectLst = etree.SubElement(spPr, qn("a:effectLst"))
    outerShdw = etree.SubElement(effectLst, qn("a:outerShdw"))
    outerShdw.set("blurRad", str(int(blur_pt * 12700)))
    outerShdw.set("dist", str(int(dist_pt * 12700)))
    outerShdw.set("dir", str(direction_deg * 60000))
    outerShdw.set("algn", "bl")
    outerShdw.set("rotWithShape", "0")

    srgbClr = etree.SubElement(outerShdw, qn("a:srgbClr"))
    srgbClr.set("val", color)
    alpha = etree.SubElement(srgbClr, qn("a:alpha"))
    alpha.set("val", str(alpha_pct * 1000))


def apply_gradient_fill(
    shape: object,
    colors: Sequence[str],
    angle: float = 90.0,
    stops: Optional[Sequence[float]] = None,
) -> None:
    """Apply a linear gradient fill to a shape via direct XML manipulation.

    python-pptx doesn't expose gradient fills natively, so we build the
    ``a:gradFill`` XML element directly.

    Args:
        shape: A python-pptx shape object (must have ``._element``).
        colors: Hex color strings (e.g. ``["#1B2A4A", "#3A5A8A"]``).
            At least 2 colors required.
        angle: Gradient angle in degrees (0 = left→right, 90 = top→bottom).
        stops: Gradient stop positions as fractions 0.0–1.0.
            If omitted, colors are evenly spaced.
    """
    if len(colors) < 2:
        raise ValueError("Gradient requires at least 2 colors")

    if stops is None:
        stops = [i / (len(colors) - 1) for i in range(len(colors))]
    if len(stops) != len(colors):
        raise ValueError("stops must have the same length as colors")

    sp = shape._element  # type: ignore[attr-defined]

    # Remove existing fill properties
    spPr = sp.find(qn("a:spPr"))
    if spPr is None:
        spPr = sp.find(qn("p:spPr"))
    if spPr is None:
        return

    for tag in ("a:solidFill", "a:noFill", "a:gradFill", "a:pattFill"):
        for old in spPr.findall(qn(tag)):
            spPr.remove(old)

    # Build gradient fill element
    grad_fill = etree.SubElement(spPr, qn("a:gradFill"))

    gs_lst = etree.SubElement(grad_fill, qn("a:gsLst"))
    for color, stop in zip(colors, stops):
        pos = int(stop * 100000)  # thousandths of a percent
        gs = etree.SubElement(gs_lst, qn("a:gs"))
        gs.set("pos", str(pos))
        srgb = etree.SubElement(gs, qn("a:srgbClr"))
        srgb.set("val", color.lstrip("#"))

    # Linear gradient direction (EMUs: 60000 per degree)
    lin = etree.SubElement(grad_fill, qn("a:lin"))
    lin.set("ang", str(int(angle * 60000)))
    lin.set("scaled", "1")


# ---------------------------------------------------------------------------
# Shadow presets
# ---------------------------------------------------------------------------

_SHADOW_PRESETS: dict[str, tuple[float, float, int]] = {
    "subtle": (2.0, 1.5, 15),
    "medium": (4.0, 3.0, 25),
    "elevated": (6.0, 4.0, 35),
}


def resolve_shadow_params(brand: BrandConfig) -> tuple[float, float, int] | None:
    """Map brand shadow_preset to (blur_pt, dist_pt, alpha_pct).

    Returns None when no shadow should be applied.
    """
    ss = brand.shape_style
    preset = ss.shadow_preset
    if preset in _SHADOW_PRESETS:
        return _SHADOW_PRESETS[preset]
    # preset == "none": fallback to raw fields if card_shadow is True
    if ss.card_shadow:
        return (ss.shadow_blur_pt, ss.shadow_offset_pt, ss.shadow_opacity_pct)
    return None


# ---------------------------------------------------------------------------
# Line dash styles
# ---------------------------------------------------------------------------

_DASH_MAP: dict[str, MSO_LINE_DASH_STYLE] = {
    "solid": MSO_LINE_DASH_STYLE.SOLID,
    "dash": MSO_LINE_DASH_STYLE.DASH,
    "dot": MSO_LINE_DASH_STYLE.ROUND_DOT,
    "dash_dot": MSO_LINE_DASH_STYLE.DASH_DOT,
    "long_dash": MSO_LINE_DASH_STYLE.LONG_DASH,
}


def apply_line_dash(shape: object, style_name: str) -> None:
    """Set the line dash style on a shape.

    Args:
        shape: A python-pptx shape with a ``line`` attribute.
        style_name: Key into ``_DASH_MAP`` (solid, dash, dot, dash_dot, long_dash).
    """
    dash_style = _DASH_MAP.get(style_name)
    if dash_style is not None:
        shape.line.dash_style = dash_style  # type: ignore[attr-defined]


# ---------------------------------------------------------------------------
# Color contrast utilities (WCAG 2.1)
# ---------------------------------------------------------------------------


def relative_luminance(hex_color: str) -> float:
    """WCAG 2.1 relative luminance of an sRGB color (0.0=black, 1.0=white)."""
    color = hex_color.lstrip("#")
    r_raw, g_raw, b_raw = int(color[0:2], 16), int(color[2:4], 16), int(color[4:6], 16)
    components: list[float] = []
    for c in (r_raw, g_raw, b_raw):
        s = c / 255.0
        components.append(s / 12.92 if s <= 0.04045 else ((s + 0.055) / 1.055) ** 2.4)
    return 0.2126 * components[0] + 0.7152 * components[1] + 0.0722 * components[2]


def contrast_ratio(hex1: str, hex2: str) -> float:
    """WCAG contrast ratio between two hex colors (1.0–21.0)."""
    l1 = relative_luminance(hex1)
    l2 = relative_luminance(hex2)
    lighter = max(l1, l2)
    darker = min(l1, l2)
    return (lighter + 0.05) / (darker + 0.05)


def ensure_readable_text_color(
    bg_hex: str,
    preferred_hex: str,
    fallback_dark: str = "#1A1A1A",
    fallback_light: str = "#FFFFFF",
    min_ratio: float = 4.5,
) -> str:
    """Return *preferred_hex* if it has sufficient contrast on *bg_hex*.

    Otherwise return whichever of *fallback_dark* / *fallback_light* gives
    the best contrast against the background.
    """
    if contrast_ratio(bg_hex, preferred_hex) >= min_ratio:
        return preferred_hex
    dark_ratio = contrast_ratio(bg_hex, fallback_dark)
    light_ratio = contrast_ratio(bg_hex, fallback_light)
    return fallback_dark if dark_ratio >= light_ratio else fallback_light


# ---------------------------------------------------------------------------
# Color distance utilities
# ---------------------------------------------------------------------------


def color_distance(hex1: str, hex2: str) -> float:
    """Euclidean RGB distance between two hex colors. Range [0, 441.67]."""
    c1 = hex1.lstrip("#")
    c2 = hex2.lstrip("#")
    r1, g1, b1 = int(c1[0:2], 16), int(c1[2:4], 16), int(c1[4:6], 16)
    r2, g2, b2 = int(c2[0:2], 16), int(c2[2:4], 16), int(c2[4:6], 16)
    return ((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2) ** 0.5


def nearest_palette_color(hex_color: str, palette: list[str]) -> str:
    """Return the palette color closest to hex_color by RGB distance."""
    if not palette:
        return hex_color
    return min(palette, key=lambda c: color_distance(hex_color, c))
