"""Element-level editing functions for python-pptx slides."""

from __future__ import annotations

from typing import Any

from pptx.dml.color import RGBColor
from pptx.enum.text import PP_ALIGN
from pptx.util import Inches, Pt

from pptx_mcp.engine.shapes import ALIGN_MAP, SHAPE_TYPE_MAP, parse_hex_color
from pptx_mcp.models.editor import AlignmentType, BatchModification, ElementInfo, ElementLocator, ElementModification, FindReplaceResult
from pptx_mcp.models.slides import ShapeType

# EMU-to-inches constant
_EMU_PER_INCH = 914400


def _emu_to_inches(emu: int | None) -> float:
    """Convert EMU to inches, defaulting to 0."""
    if emu is None:
        return 0.0
    return round(emu / _EMU_PER_INCH, 4)


def _extract_element_info(shape: Any, index: int) -> ElementInfo:
    """Build an ElementInfo from a python-pptx shape."""
    text_preview = None
    has_text = False
    font_name = None
    font_size = None
    fill_color = None
    border_color = None
    child_count = None

    # Text
    if shape.has_text_frame:
        full_text = shape.text_frame.text or ""
        if full_text.strip():
            has_text = True
            text_preview = full_text[:100]
        # Font from first run
        for para in shape.text_frame.paragraphs:
            for run in para.runs:
                if run.font.name:
                    font_name = run.font.name
                if run.font.size is not None:
                    font_size = round(run.font.size / 12700, 1)  # EMU to pt
                break
            if font_name or font_size:
                break

    # Fill color
    try:
        fill = shape.fill
        if fill.type is not None and str(fill.type) != "BACKGROUND (6)":
            try:
                rgb = fill.fore_color.rgb
                fill_color = f"#{rgb}"
            except (AttributeError, TypeError):
                pass
    except (AttributeError, TypeError):
        pass

    # Border color
    try:
        line = shape.line
        if line.color and line.color.type is not None:
            try:
                rgb = line.color.rgb
                border_color = f"#{rgb}"
            except (AttributeError, TypeError):
                pass
    except (AttributeError, TypeError):
        pass

    # Group child count
    try:
        if shape.shape_type == 6:  # MSO_SHAPE_TYPE.GROUP
            child_count = len(shape.shapes)
    except (AttributeError, TypeError):
        pass

    return ElementInfo(
        index=index,
        shape_id=shape.shape_id,
        name=shape.name or f"Shape {index}",
        shape_type=str(shape.shape_type) if shape.shape_type is not None else "unknown",
        left=_emu_to_inches(shape.left),
        top=_emu_to_inches(shape.top),
        width=_emu_to_inches(shape.width),
        height=_emu_to_inches(shape.height),
        rotation=shape.rotation if shape.rotation else 0.0,
        text_preview=text_preview,
        has_text=has_text,
        font_name=font_name,
        font_size=font_size,
        fill_color=fill_color,
        border_color=border_color,
        child_count=child_count,
    )


def _resolve_shape(slide: Any, locator: ElementLocator) -> tuple[Any, int]:
    """Resolve a shape from a locator. Returns (shape, index).

    Raises ValueError if no match or ambiguous match.
    """
    shapes = list(slide.shapes)

    # Priority 1: index
    if locator.index is not None:
        if locator.index >= len(shapes):
            raise ValueError(
                f"Shape index {locator.index} out of range (0-{len(shapes) - 1})"
            )
        return shapes[locator.index], locator.index

    # Priority 2: name (exact match)
    if locator.name is not None:
        matches = [(i, s) for i, s in enumerate(shapes) if s.name == locator.name]
        if len(matches) == 0:
            raise ValueError(f"No shape with name '{locator.name}'")
        if len(matches) > 1:
            raise ValueError(
                f"Ambiguous: {len(matches)} shapes named '{locator.name}'"
            )
        return matches[0][1], matches[0][0]

    # Priority 3: text_match (substring)
    if locator.text_match is not None:
        matches = []
        for i, s in enumerate(shapes):
            if s.has_text_frame:
                text = s.text_frame.text or ""
                if locator.text_match in text:
                    matches.append((i, s))
        if len(matches) == 0:
            raise ValueError(f"No shape containing text '{locator.text_match}'")
        if len(matches) > 1:
            raise ValueError(
                f"Ambiguous: {len(matches)} shapes contain text '{locator.text_match}'"
            )
        return matches[0][1], matches[0][0]

    raise ValueError("ElementLocator requires at least one of: index, name, text_match")


def list_elements(slide: Any) -> list[ElementInfo]:
    """List all shapes on a slide with their properties."""
    return [_extract_element_info(shape, i) for i, shape in enumerate(slide.shapes)]


def modify_element(
    slide: Any,
    locator: ElementLocator,
    modification: ElementModification,
) -> ElementInfo:
    """Apply modifications to a single shape and return updated info."""
    shape, index = _resolve_shape(slide, locator)

    # Position / size
    if modification.left is not None:
        shape.left = Inches(modification.left)
    if modification.top is not None:
        shape.top = Inches(modification.top)
    if modification.width is not None:
        shape.width = Inches(modification.width)
    if modification.height is not None:
        shape.height = Inches(modification.height)
    if modification.rotation is not None:
        shape.rotation = modification.rotation

    # Text replacement
    if modification.text is not None and shape.has_text_frame:
        tf = shape.text_frame
        # Clear existing paragraphs and set new text
        for para in tf.paragraphs:
            para.clear()
        tf.paragraphs[0].text = modification.text

    # Font properties
    _apply_font_modifications(shape, modification)

    # Text alignment
    if modification.text_alignment is not None and shape.has_text_frame:
        alignment = ALIGN_MAP.get(modification.text_alignment.lower())
        if alignment is not None:
            for para in shape.text_frame.paragraphs:
                para.alignment = alignment

    # Fill color
    if modification.fill_color is not None:
        if modification.fill_color.lower() == "none":
            shape.fill.background()
        else:
            shape.fill.solid()
            shape.fill.fore_color.rgb = parse_hex_color(modification.fill_color)

    # Border
    if modification.border_color is not None:
        if modification.border_color.lower() == "none":
            shape.line.fill.background()
        else:
            shape.line.color.rgb = parse_hex_color(modification.border_color)
            if modification.border_width is None and shape.line.width is None:
                shape.line.width = Pt(1.0)
    if modification.border_width is not None:
        shape.line.width = Pt(modification.border_width)

    # Rename
    if modification.name is not None:
        shape.name = modification.name

    return _extract_element_info(shape, index)


def _apply_font_modifications(shape: Any, mod: ElementModification) -> None:
    """Apply font-level modifications across all runs in a shape."""
    has_font_change = any([
        mod.font_name is not None,
        mod.font_size is not None,
        mod.font_bold is not None,
        mod.font_italic is not None,
        mod.font_color is not None,
    ])
    if not has_font_change or not shape.has_text_frame:
        return

    for para in shape.text_frame.paragraphs:
        for run in para.runs:
            font = run.font
            if mod.font_name is not None:
                font.name = mod.font_name
            if mod.font_size is not None:
                font.size = Pt(mod.font_size)
            if mod.font_bold is not None:
                font.bold = mod.font_bold
            if mod.font_italic is not None:
                font.italic = mod.font_italic
            if mod.font_color is not None:
                font.color.rgb = parse_hex_color(mod.font_color)


def add_textbox(
    slide: Any,
    left: float,
    top: float,
    width: float,
    height: float,
    text: str = "",
    *,
    font_name: str | None = None,
    font_size: float | None = None,
    font_bold: bool | None = None,
    font_color: str | None = None,
    fill_color: str | None = None,
    text_alignment: str | None = None,
) -> ElementInfo:
    """Add a text box to the slide."""
    txBox = slide.shapes.add_textbox(
        Inches(left), Inches(top), Inches(width), Inches(height),
    )
    tf = txBox.text_frame
    tf.word_wrap = True
    if text:
        tf.paragraphs[0].text = text

    # Apply optional styling
    if font_name or font_size or font_bold or font_color:
        for para in tf.paragraphs:
            for run in para.runs:
                if font_name:
                    run.font.name = font_name
                if font_size:
                    run.font.size = Pt(font_size)
                if font_bold is not None:
                    run.font.bold = font_bold
                if font_color:
                    run.font.color.rgb = parse_hex_color(font_color)

    if text_alignment:
        alignment = ALIGN_MAP.get(text_alignment.lower())
        if alignment:
            for para in tf.paragraphs:
                para.alignment = alignment

    if fill_color:
        txBox.fill.solid()
        txBox.fill.fore_color.rgb = parse_hex_color(fill_color)

    index = len(list(slide.shapes)) - 1
    return _extract_element_info(txBox, index)


def add_shape(
    slide: Any,
    shape_type: str,
    left: float,
    top: float,
    width: float,
    height: float,
    *,
    text: str | None = None,
    fill_color: str | None = None,
    border_color: str | None = None,
    border_width: float | None = None,
) -> ElementInfo:
    """Add a geometric shape to the slide."""
    try:
        st = ShapeType(shape_type)
    except ValueError:
        valid = [s.value for s in ShapeType]
        raise ValueError(
            f"Invalid shape_type '{shape_type}'. Valid types: {', '.join(valid)}"
        )

    mso_shape = SHAPE_TYPE_MAP.get(st)
    if mso_shape is None:
        raise ValueError(f"Shape type '{shape_type}' not supported for add_shape")

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

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

    if border_color:
        shape.line.color.rgb = parse_hex_color(border_color)
        shape.line.width = Pt(border_width or 1.0)
    elif border_width:
        shape.line.width = Pt(border_width)
    else:
        shape.line.fill.background()

    if text:
        tf = shape.text_frame
        tf.word_wrap = True
        tf.paragraphs[0].text = text
        tf.paragraphs[0].alignment = PP_ALIGN.CENTER

    index = len(list(slide.shapes)) - 1
    return _extract_element_info(shape, index)


def add_image(
    slide: Any,
    image_path: str,
    left: float,
    top: float,
    width: float | None = None,
    height: float | None = None,
) -> ElementInfo:
    """Add an image to the slide."""
    from pptx_mcp.utils.paths import validate_image_path

    validated = validate_image_path(image_path)

    kwargs: dict[str, Any] = {
        "image_file": str(validated),
        "left": Inches(left),
        "top": Inches(top),
    }
    if width is not None:
        kwargs["width"] = Inches(width)
    if height is not None:
        kwargs["height"] = Inches(height)

    pic = slide.shapes.add_picture(**kwargs)
    index = len(list(slide.shapes)) - 1
    return _extract_element_info(pic, index)


def align_elements(
    slide: Any,
    locators: list[ElementLocator],
    alignment: AlignmentType,
) -> list[ElementInfo]:
    """Align or distribute multiple shapes."""
    if len(locators) < 2:
        raise ValueError("align_elements requires at least 2 elements")

    resolved = [_resolve_shape(slide, loc) for loc in locators]

    if alignment == AlignmentType.LEFT:
        min_left = min(s.left for s, _ in resolved)
        for shape, _ in resolved:
            shape.left = min_left

    elif alignment == AlignmentType.RIGHT:
        max_right = max(s.left + s.width for s, _ in resolved)
        for shape, _ in resolved:
            shape.left = max_right - shape.width

    elif alignment == AlignmentType.CENTER:
        centers = [(s.left + s.width // 2) for s, _ in resolved]
        avg_center = sum(centers) // len(centers)
        for shape, _ in resolved:
            shape.left = avg_center - shape.width // 2

    elif alignment == AlignmentType.TOP:
        min_top = min(s.top for s, _ in resolved)
        for shape, _ in resolved:
            shape.top = min_top

    elif alignment == AlignmentType.BOTTOM:
        max_bottom = max(s.top + s.height for s, _ in resolved)
        for shape, _ in resolved:
            shape.top = max_bottom - shape.height

    elif alignment == AlignmentType.MIDDLE:
        middles = [(s.top + s.height // 2) for s, _ in resolved]
        avg_middle = sum(middles) // len(middles)
        for shape, _ in resolved:
            shape.top = avg_middle - shape.height // 2

    elif alignment == AlignmentType.DISTRIBUTE_H:
        sorted_shapes = sorted(resolved, key=lambda x: x[0].left)
        if len(sorted_shapes) >= 2:
            first_left = sorted_shapes[0][0].left
            last_right = sorted_shapes[-1][0].left + sorted_shapes[-1][0].width
            total_width = sum(s.width for s, _ in sorted_shapes)
            total_gap = (last_right - first_left) - total_width
            gap = total_gap // (len(sorted_shapes) - 1) if len(sorted_shapes) > 1 else 0
            current_left = first_left
            for shape, _ in sorted_shapes:
                shape.left = current_left
                current_left += shape.width + gap

    elif alignment == AlignmentType.DISTRIBUTE_V:
        sorted_shapes = sorted(resolved, key=lambda x: x[0].top)
        if len(sorted_shapes) >= 2:
            first_top = sorted_shapes[0][0].top
            last_bottom = sorted_shapes[-1][0].top + sorted_shapes[-1][0].height
            total_height = sum(s.height for s, _ in sorted_shapes)
            total_gap = (last_bottom - first_top) - total_height
            gap = total_gap // (len(sorted_shapes) - 1) if len(sorted_shapes) > 1 else 0
            current_top = first_top
            for shape, _ in sorted_shapes:
                shape.top = current_top
                current_top += shape.height + gap

    return [_extract_element_info(shape, idx) for shape, idx in resolved]


def group_elements(
    slide: Any,
    locators: list[ElementLocator],
) -> ElementInfo:
    """Group multiple shapes into a group shape."""
    if len(locators) < 2:
        raise ValueError("group_elements requires at least 2 elements")

    resolved = [_resolve_shape(slide, loc) for loc in locators]
    shapes_to_group = [shape for shape, _ in resolved]

    group = slide.shapes.add_group_shape(shapes_to_group)
    index = len(list(slide.shapes)) - 1
    return _extract_element_info(group, index)


def delete_element(slide: Any, locator: ElementLocator) -> None:
    """Remove a shape from the slide.

    Args:
        slide: The python-pptx Slide object.
        locator: Identifies the shape to delete.

    Raises:
        ValueError: If shape not found.
    """
    shape, _index = _resolve_shape(slide, locator)
    slide.shapes._spTree.remove(shape._element)


def copy_element(
    prs: Any,
    src_slide_idx: int,
    locator: ElementLocator,
    dst_slide_idx: int,
    offset_x: float = 0.3,
    offset_y: float = 0.3,
) -> ElementInfo:
    """Copy a shape from one slide to another (or the same slide) with an offset.

    Args:
        prs: The python-pptx Presentation object.
        src_slide_idx: 0-based source slide index.
        locator: Identifies the shape to copy.
        dst_slide_idx: 0-based destination slide index.
        offset_x: Horizontal offset in inches for the copy.
        offset_y: Vertical offset in inches for the copy.

    Returns:
        ElementInfo for the copied shape.

    Raises:
        ValueError: If slide index out of range or shape not found.
    """
    import copy as _copy

    slides = list(prs.slides)
    if src_slide_idx < 0 or src_slide_idx >= len(slides):
        raise ValueError(f"src_slide_idx {src_slide_idx} out of range (0-{len(slides) - 1})")
    if dst_slide_idx < 0 or dst_slide_idx >= len(slides):
        raise ValueError(f"dst_slide_idx {dst_slide_idx} out of range (0-{len(slides) - 1})")

    src_slide = slides[src_slide_idx]
    dst_slide = slides[dst_slide_idx]

    shape, _index = _resolve_shape(src_slide, locator)
    new_el = _copy.deepcopy(shape._element)

    # Apply offset
    if new_el.get("x") is not None or shape.left is not None:
        new_el.set("x", str(shape.left + Inches(offset_x)))
    if new_el.get("y") is not None or shape.top is not None:
        new_el.set("y", str(shape.top + Inches(offset_y)))

    dst_slide.shapes._spTree.append(new_el)
    new_index = len(list(dst_slide.shapes)) - 1
    new_shape = list(dst_slide.shapes)[new_index]
    return _extract_element_info(new_shape, new_index)


def find_and_replace(
    prs: Any,
    find_text: str,
    replace_text: str,
    case_sensitive: bool = True,
    slide_indices: list[int] | None = None,
) -> list[FindReplaceResult]:
    """Find and replace text across all (or specified) slides.

    Args:
        prs: The python-pptx Presentation object.
        find_text: Text to search for.
        replace_text: Replacement text.
        case_sensitive: Whether the search is case-sensitive.
        slide_indices: Optional list of 0-based slide indices to limit search.

    Returns:
        List of FindReplaceResult for each shape that had replacements.
    """
    results: list[FindReplaceResult] = []
    slides = list(prs.slides)

    for slide_idx, slide in enumerate(slides):
        if slide_indices is not None and slide_idx not in slide_indices:
            continue
        for shape in slide.shapes:
            if not shape.has_text_frame:
                continue
            tf = shape.text_frame
            total_replacements = 0
            for para in tf.paragraphs:
                for run in para.runs:
                    old = run.text
                    if case_sensitive:
                        count = old.count(find_text)
                        if count > 0:
                            run.text = old.replace(find_text, replace_text)
                            total_replacements += count
                    else:
                        # Case-insensitive replace
                        import re
                        matches = re.findall(re.escape(find_text), old, re.IGNORECASE)
                        if matches:
                            run.text = re.sub(
                                re.escape(find_text), replace_text, old, flags=re.IGNORECASE
                            )
                            total_replacements += len(matches)
            if total_replacements > 0:
                old_text = tf.text[:100]
                results.append(FindReplaceResult(
                    slide_index=slide_idx,
                    shape_name=shape.name or f"Shape",
                    old_text=old_text,
                    new_text=tf.text[:100],
                    replacements=total_replacements,
                ))
    return results


def batch_modify(
    slide: Any,
    modifications: list[BatchModification],
) -> list[ElementInfo]:
    """Apply multiple modifications to shapes on a single slide.

    Args:
        slide: The python-pptx Slide object.
        modifications: List of (locator, modification) pairs.

    Returns:
        List of ElementInfo for each modified shape.
    """
    results: list[ElementInfo] = []
    for batch in modifications:
        info = modify_element(slide, batch.locator, batch.modification)
        results.append(info)
    return results


def ungroup_elements(slide: Any, locator: ElementLocator) -> list[ElementInfo]:
    """Dissolve a group shape, moving its children to the slide level.

    Args:
        slide: The python-pptx Slide object.
        locator: Identifies the group shape to dissolve.

    Returns:
        List of ElementInfo for the ungrouped child shapes.

    Raises:
        ValueError: If the located shape is not a group.
    """
    import copy as _copy

    shape, _index = _resolve_shape(slide, locator)

    # Check it's a group (shape_type == 6 is MSO_SHAPE_TYPE.GROUP)
    try:
        is_group = shape.shape_type == 6
    except (AttributeError, TypeError):
        is_group = False

    if not is_group:
        raise ValueError(f"Shape '{shape.name}' is not a group shape")

    # Collect child elements
    child_elements = []
    for child_shape in shape.shapes:
        child_el = _copy.deepcopy(child_shape._element)
        child_elements.append(child_el)

    # Remove the group shape
    slide.shapes._spTree.remove(shape._element)

    # Add children to slide
    for el in child_elements:
        slide.shapes._spTree.append(el)

    # Return info for the newly added shapes
    all_shapes = list(slide.shapes)
    results = []
    start_idx = len(all_shapes) - len(child_elements)
    for i, s in enumerate(all_shapes[start_idx:], start=start_idx):
        results.append(_extract_element_info(s, i))
    return results


def reorder_slides(prs: Any, new_order: list[int]) -> None:
    """Rearrange slides in the presentation according to the given order.

    Args:
        prs: The python-pptx Presentation object.
        new_order: List of 0-based slide indices representing the desired order.
            Must be a permutation of [0, 1, ..., n-1].

    Raises:
        ValueError: If new_order is not a valid permutation.
    """
    n = len(prs.slides)
    if sorted(new_order) != list(range(n)):
        raise ValueError(
            f"new_order must be a permutation of [0..{n - 1}], got {new_order}"
        )

    sld_id_lst = prs.slides._sldIdLst
    entries = list(sld_id_lst)

    # Clear and re-add in new order
    for entry in entries:
        sld_id_lst.remove(entry)
    for idx in new_order:
        sld_id_lst.append(entries[idx])
