"""PPTX generation engine — converts PresentationDefinition into a .pptx file."""

from __future__ import annotations

from pathlib import Path
from typing import Optional

from pptx import Presentation
from pptx.slide import Slide
from pptx.util import Inches, Pt

from pptx_mcp.engine.charts import add_chart_to_slide
from pptx_mcp.engine.layouts import (
    render_agenda_slide,
    render_big_number_slide,
    render_closing_slide,
    render_comparison_slide,
    render_content_slide,
    render_dashboard_slide,
    render_funnel_slide,
    render_icon_grid_slide,
    render_image_text_slide,
    render_process_slide,
    render_pros_cons_slide,
    render_quote_slide,
    render_section_slide,
    render_step_cards_slide,
    render_swot_slide,
    render_team_slide,
    render_timeline_slide,
    render_title_slide,
    render_two_column_slide,
    _add_slide_title,
    _apply_line_spacing,
    _apply_text_frame_padding,
)
from pptx_mcp.engine.shapes import (
    add_connector_to_slide,
    add_shape_to_slide,
    parse_hex_color,
)
from pptx_mcp.models.brand import BrandConfig
from pptx_mcp.models.slides import (
    BulletList,
    ChartElement,
    ConnectorElement,
    GroupElement,
    ImageElement,
    PresentationDefinition,
    ShapeElement,
    SlideDefinition,
    SlideType,
    TableElement,
    TextBox,
)
from pptx_mcp.utils.errors import GenerationError
from pptx_mcp.utils.logging import get_logger
from pptx_mcp.utils.paths import validate_image_path, validate_output_path, validate_template_path

logger = get_logger("engine.generator")


def _delete_slide(prs: Presentation, slide_index: int) -> None:
    """Delete a slide from a presentation by index.

    Args:
        prs: The python-pptx Presentation object.
        slide_index: 0-based index of the slide to remove.

    Raises:
        GenerationError: If the index is out of range.
    """
    if slide_index < 0 or slide_index >= len(prs.slides):
        raise GenerationError(
            f"Slide index {slide_index} out of range (0-{len(prs.slides) - 1})"
        )
    from pptx.oxml.ns import qn

    sld_id_lst = prs.slides._sldIdLst  # type: ignore[attr-defined]
    rId = sld_id_lst[slide_index].get(qn("r:id"))
    if rId is None:
        raise GenerationError(f"Could not resolve relationship ID for slide {slide_index}")
    prs.part.drop_rel(rId)
    del sld_id_lst[slide_index]


def _move_slide(prs: Presentation, from_index: int, to_index: int) -> None:
    """Move a slide from one position to another in the slide list."""
    n = len(prs.slides)
    if from_index < 0 or from_index >= n:
        raise GenerationError(f"from_index {from_index} out of range (0-{n - 1})")
    if to_index < 0 or to_index >= n:
        raise GenerationError(f"to_index {to_index} out of range (0-{n - 1})")
    sld_id_lst = prs.slides._sldIdLst  # type: ignore[attr-defined]
    el = sld_id_lst[from_index]
    sld_id_lst.remove(el)
    sld_id_lst.insert(to_index, el)


def _duplicate_slide(prs: Presentation, slide_index: int) -> int:
    """Duplicate a slide and append the copy after the original.

    Args:
        prs: The python-pptx Presentation object.
        slide_index: 0-based index of the slide to duplicate.

    Returns:
        The index of the new (duplicated) slide.

    Raises:
        GenerationError: If the index is out of range.
    """
    import copy
    from lxml import etree

    n = len(prs.slides)
    if slide_index < 0 or slide_index >= n:
        raise GenerationError(f"Slide index {slide_index} out of range (0-{n - 1})")

    source_slide = prs.slides[slide_index]
    slide_layout = source_slide.slide_layout

    new_slide = prs.slides.add_slide(slide_layout)

    # Copy all XML content from source to new slide
    for shape in list(new_slide.shapes):
        sp = shape._element
        sp.getparent().remove(sp)

    for shape in source_slide.shapes:
        el = copy.deepcopy(shape._element)
        new_slide.shapes._spTree.append(el)  # type: ignore[attr-defined]

    # Copy notes if present
    if source_slide.has_notes_slide:
        source_notes = source_slide.notes_slide.notes_text_frame.text
        new_slide.notes_slide.notes_text_frame.text = source_notes

    # Move the new slide to right after the original
    new_index = len(prs.slides) - 1
    target_index = slide_index + 1
    if new_index != target_index:
        _move_slide(prs, new_index, target_index)

    return target_index


class DeckGenerator:
    """Generates PPTX files from structured PresentationDefinition objects."""

    def __init__(self, brand: Optional[BrandConfig] = None) -> None:
        from pptx_mcp.brand.config import resolve_brand_config
        self.brand = brand or resolve_brand_config()

    def generate(
        self,
        definition: PresentationDefinition,
        output_path: str | Path,
    ) -> Path:
        """Generate a PPTX file from a PresentationDefinition.

        Args:
            definition: The presentation structure.
            output_path: Where to save the generated .pptx file.

        Returns:
            Path to the saved file.

        Raises:
            GenerationError: If generation fails.
        """
        output_path = validate_output_path(output_path)
        output_path.parent.mkdir(parents=True, exist_ok=True)

        brand = self.brand
        slide_width = definition.width_inches

        try:
            if brand.template_path:
                template = validate_template_path(brand.template_path)
                prs = Presentation(str(template))
            else:
                prs = Presentation()

            prs.slide_width = Inches(definition.width_inches)
            prs.slide_height = Inches(definition.height_inches)

            prs.core_properties.title = definition.metadata.title
            if definition.metadata.author:
                prs.core_properties.author = definition.metadata.author
            if definition.metadata.subject:
                prs.core_properties.subject = definition.metadata.subject
            if definition.metadata.category:
                prs.core_properties.category = definition.metadata.category

            for slide_def in definition.slides:
                self._render_slide(prs, slide_def, brand, slide_width)

            prs.save(str(output_path))
            logger.info(
                "Presentation saved to %s (%d slides)", output_path, len(definition.slides)
            )
            return output_path

        except GenerationError:
            raise
        except Exception as e:
            raise GenerationError("Failed to generate presentation") from e

    def add_slide(
        self,
        pptx_path: str | Path,
        slide_def: SlideDefinition,
        position: Optional[int] = None,
    ) -> Path:
        """Add a single slide to an existing presentation.

        Args:
            pptx_path: Path to the existing PPTX.
            slide_def: Slide definition to add.
            position: Optional 0-based position. Appends if None.

        Returns:
            Path to the saved file.
        """
        pptx_path = Path(pptx_path)
        prs = Presentation(str(pptx_path))
        slide_width = round(prs.slide_width / 914400, 3) if prs.slide_width else 13.333

        self._render_slide(prs, slide_def, self.brand, slide_width)

        if position is not None and position < len(prs.slides) - 1:
            _move_slide(prs, len(prs.slides) - 1, position)

        prs.save(str(pptx_path))
        logger.info("Added slide to %s at position %s", pptx_path, position or "end")
        return pptx_path

    def update_slide(
        self,
        pptx_path: str | Path,
        slide_index: int,
        slide_def: SlideDefinition,
    ) -> Path:
        """Replace a slide in an existing presentation.

        Args:
            pptx_path: Path to the existing PPTX.
            slide_index: 0-based index of the slide to replace.
            slide_def: New slide definition.

        Returns:
            Path to the saved file.
        """
        pptx_path = Path(pptx_path)
        prs = Presentation(str(pptx_path))
        slide_width = round(prs.slide_width / 914400, 3) if prs.slide_width else 13.333

        _delete_slide(prs, slide_index)

        self._render_slide(prs, slide_def, self.brand, slide_width)
        if slide_index < len(prs.slides) - 1:
            _move_slide(prs, len(prs.slides) - 1, slide_index)

        prs.save(str(pptx_path))
        logger.info("Updated slide %d in %s", slide_index, pptx_path)
        return pptx_path

    def _render_slide(
        self,
        prs: Presentation,
        definition: SlideDefinition,
        brand: BrandConfig,
        slide_width: float,
    ) -> None:
        """Render a single slide based on its type."""
        layout = prs.slide_layouts[6]  # Blank layout
        slide: Slide = prs.slides.add_slide(layout)

        # Background color
        bg_color = definition.background_color or brand.colors.background
        if bg_color and bg_color.upper() != "#FFFFFF":
            background = slide.background
            fill = background.fill
            fill.solid()
            fill.fore_color.rgb = parse_hex_color(bg_color)

        # Speaker notes
        if definition.speaker_notes:
            notes_slide = slide.notes_slide
            notes_slide.notes_text_frame.text = definition.speaker_notes

        # Render by slide type
        type_renderers = {
            SlideType.TITLE: render_title_slide,
            SlideType.SECTION: render_section_slide,
            SlideType.CONTENT: render_content_slide,
            SlideType.TWO_COLUMN: render_two_column_slide,
            SlideType.PROS_CONS: render_pros_cons_slide,
            SlideType.COMPARISON: render_comparison_slide,
            SlideType.QUOTE: render_quote_slide,
            SlideType.BIG_NUMBER: render_big_number_slide,
            SlideType.PROCESS: render_process_slide,
            SlideType.TIMELINE: render_timeline_slide,
            SlideType.STEP_CARDS: render_step_cards_slide,
            SlideType.DASHBOARD: render_dashboard_slide,
            SlideType.IMAGE_TEXT: render_image_text_slide,
            SlideType.ICON_GRID: render_icon_grid_slide,
            SlideType.TEAM: render_team_slide,
            SlideType.CLOSING: render_closing_slide,
            SlideType.SWOT: render_swot_slide,
            SlideType.FUNNEL: render_funnel_slide,
            SlideType.AGENDA: render_agenda_slide,
        }

        renderer = type_renderers.get(definition.slide_type)
        if renderer:
            renderer(slide, definition, brand, slide_width)
        elif definition.slide_type == SlideType.CHART:
            self._render_chart_slide(slide, definition, brand, slide_width)
        elif definition.slide_type == SlideType.DIAGRAM:
            self._render_diagram_slide(slide, definition, brand, slide_width)
        elif definition.slide_type == SlideType.TABLE:
            self._render_table_slide(slide, definition, brand, slide_width)
        elif definition.slide_type == SlideType.BLANK:
            pass  # blank slide, just render elements

        # Render any additional elements
        self._render_elements(slide, definition, brand, slide_width)

        # Footer
        if brand.footer_text:
            self._add_footer(slide, brand)

    def _render_chart_slide(
        self,
        slide: Slide,
        definition: SlideDefinition,
        brand: BrandConfig,
        slide_width: float,
    ) -> None:
        """Render a slide dedicated to a chart."""
        _add_slide_title(slide, definition.title, brand, slide_width)

        for elem in definition.elements:
            if isinstance(elem, ChartElement):
                add_chart_to_slide(
                    slide,
                    elem,
                    chart_colors=brand.colors.chart_colors or None,
                )

    def _render_diagram_slide(
        self,
        slide: Slide,
        definition: SlideDefinition,
        brand: BrandConfig,
        slide_width: float,
    ) -> None:
        """Render a slide with shapes and connectors."""
        _add_slide_title(slide, definition.title, brand, slide_width)

        shape_objects: list = []
        for i, elem in enumerate(definition.elements):
            if isinstance(elem, ShapeElement):
                add_shape_to_slide(slide, elem, i)
                shape_objects.append(slide.shapes[-1])

        for elem in definition.elements:
            if isinstance(elem, ConnectorElement):
                add_connector_to_slide(slide, elem, shape_objects)

    def _render_table_slide(
        self,
        slide: Slide,
        definition: SlideDefinition,
        brand: BrandConfig,
        slide_width: float,
    ) -> None:
        """Render a slide with a table."""
        _add_slide_title(slide, definition.title, brand, slide_width)

        for elem in definition.elements:
            if isinstance(elem, TableElement):
                self._add_table(slide, elem, brand, slide_width)

    def _render_elements(
        self,
        slide: Slide,
        definition: SlideDefinition,
        brand: BrandConfig,
        slide_width: float,
    ) -> None:
        """Render freeform elements on a slide."""
        shape_objects: list = []
        for i, elem in enumerate(definition.elements):
            if isinstance(elem, ChartElement):
                add_chart_to_slide(
                    slide,
                    elem,
                    chart_colors=brand.colors.chart_colors or None,
                )
            elif isinstance(elem, ShapeElement):
                add_shape_to_slide(slide, elem, i)
                shape_objects.append(slide.shapes[-1])
            elif isinstance(elem, ConnectorElement):
                add_connector_to_slide(slide, elem, shape_objects)
            elif isinstance(elem, ImageElement):
                self._add_image(slide, elem)
            elif isinstance(elem, TableElement):
                self._add_table(slide, elem, brand, slide_width)
            elif isinstance(elem, TextBox):
                self._add_textbox(slide, elem, brand)
            elif isinstance(elem, BulletList):
                self._add_bullet_list(slide, elem, brand)
            elif isinstance(elem, GroupElement):
                logger.warning(
                    "GroupElement rendering is not yet supported; "
                    "%d sub-elements skipped",
                    len(elem.elements),
                )

    def _add_image(self, slide: Slide, elem: ImageElement) -> None:
        """Add an image to the slide with optional crop, mask, and hyperlink."""
        try:
            image_path = validate_image_path(elem.path)
        except Exception:
            logger.warning("Skipping image: invalid or missing path '%s'", elem.path)
            return

        pos = elem.position
        left = Inches(pos.left) if pos else Inches(1.0)
        top = Inches(pos.top) if pos else Inches(2.0)
        width = Inches(pos.width) if pos else Inches(4.0)
        height = Inches(pos.height) if pos else Inches(3.0)

        pic = slide.shapes.add_picture(str(image_path), left, top, width, height)

        # Crop (offsets as fractions → EMU offsets on the blipFill)
        if elem.crop:
            from pptx.oxml.ns import qn

            blip_fill = pic._element.find(qn("p:blipFill"))
            if blip_fill is not None:
                src_rect = blip_fill.find(qn("a:srcRect"))
                if src_rect is None:
                    from lxml import etree
                    src_rect = etree.SubElement(blip_fill, qn("a:srcRect"))
                # Values in thousandths of a percent (0–100000)
                src_rect.set("l", str(int(elem.crop.left * 100000)))
                src_rect.set("t", str(int(elem.crop.top * 100000)))
                src_rect.set("r", str(int(elem.crop.right * 100000)))
                src_rect.set("b", str(int(elem.crop.bottom * 100000)))

        # Shape mask (clip to geometry)
        if elem.mask_shape:
            from pptx.oxml.ns import qn
            from pptx_mcp.engine.shapes import SHAPE_TYPE_MAP

            sp_pr = pic._element.find(qn("p:spPr"))
            if sp_pr is not None:
                # Map our ShapeType to OOXml preset geometry name
                _GEOM_MAP = {
                    "circle": "ellipse", "oval": "ellipse",
                    "rounded_rectangle": "roundRect", "rectangle": "rect",
                    "diamond": "diamond", "triangle": "triangle",
                    "hexagon": "hexagon", "pentagon": "pentagon",
                }
                geom_name = _GEOM_MAP.get(elem.mask_shape.value, "ellipse")
                # Remove existing geometry and add preset
                for old in sp_pr.findall(qn("a:prstGeom")):
                    sp_pr.remove(old)
                from lxml import etree
                prst = etree.SubElement(sp_pr, qn("a:prstGeom"))
                prst.set("prst", geom_name)
                etree.SubElement(prst, qn("a:avLst"))

        # Hyperlink
        if elem.hyperlink:
            pic.click_action.hyperlink.address = elem.hyperlink

    def _add_table(
        self,
        slide: Slide,
        elem: TableElement,
        brand: BrandConfig,
        slide_width: float,
    ) -> None:
        """Add a table to the slide."""
        if not elem.rows:
            return

        pos = elem.position
        n_rows = len(elem.rows)
        n_cols = max(len(row) for row in elem.rows) if elem.rows else 1

        left = Inches(pos.left) if pos else Inches(brand.spacing.margin_left)
        top = Inches(pos.top) if pos else Inches(brand.spacing.content_top + 0.5)
        width = Inches(pos.width) if pos else Inches(
            slide_width - brand.spacing.margin_left - brand.spacing.margin_right
        )
        height = Inches(pos.height) if pos else Inches(min(n_rows * 0.5, 4.5))

        table_shape = slide.shapes.add_table(n_rows, n_cols, left, top, width, height)
        table = table_shape.table

        if elem.col_widths:
            for col_idx, w in enumerate(elem.col_widths):
                if col_idx < n_cols:
                    table.columns[col_idx].width = Inches(w)

        for row_idx, row in enumerate(elem.rows):
            for col_idx, cell_def in enumerate(row):
                if col_idx < n_cols:
                    cell = table.cell(row_idx, col_idx)
                    cell.text = cell_def.text

                    for paragraph in cell.text_frame.paragraphs:
                        for run in paragraph.runs:
                            run.font.name = brand.fonts.body_font
                            run.font.size = Pt(brand.fonts.body_size - 2)

                    if cell_def.fill_color:
                        cell.fill.solid()
                        cell.fill.fore_color.rgb = parse_hex_color(cell_def.fill_color)
                    elif row_idx == 0 and elem.header_row:
                        cell.fill.solid()
                        cell.fill.fore_color.rgb = parse_hex_color(brand.colors.primary)
                        for paragraph in cell.text_frame.paragraphs:
                            for run in paragraph.runs:
                                run.font.color.rgb = parse_hex_color("#FFFFFF")
                                run.font.bold = True

    def _add_textbox(
        self,
        slide: Slide,
        elem: TextBox,
        brand: BrandConfig,
    ) -> None:
        """Add a textbox element to the slide."""
        pos = elem.position
        left = Inches(pos.left) if pos else Inches(brand.spacing.margin_left)
        top = Inches(pos.top) if pos else Inches(brand.spacing.content_top)
        width = Inches(pos.width) if pos else Inches(6.0)
        height = Inches(pos.height) if pos else Inches(2.0)

        txbox = slide.shapes.add_textbox(left, top, width, height)
        tf = txbox.text_frame
        tf.word_wrap = True
        _apply_text_frame_padding(tf, brand)

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

        for i, para_def in enumerate(elem.paragraphs):
            p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
            if para_def.alignment:
                from pptx.enum.text import PP_ALIGN
                align_map = {
                    "left": PP_ALIGN.LEFT,
                    "center": PP_ALIGN.CENTER,
                    "right": PP_ALIGN.RIGHT,
                    "justify": PP_ALIGN.JUSTIFY,
                }
                p.alignment = align_map.get(para_def.alignment)

            for run_def in para_def.runs:
                run = p.add_run()
                run.text = run_def.text
                style = run_def.style or elem.style
                if style:
                    if style.font_name:
                        run.font.name = style.font_name
                    if style.font_size:
                        run.font.size = Pt(style.font_size)
                    if style.bold:
                        run.font.bold = True
                    if style.italic:
                        run.font.italic = True
                    if style.color:
                        run.font.color.rgb = parse_hex_color(style.color)

    def _add_bullet_list(
        self,
        slide: Slide,
        elem: BulletList,
        brand: BrandConfig,
    ) -> None:
        """Add a bullet list element to the slide."""
        pos = elem.position
        left = Inches(pos.left) if pos else Inches(brand.spacing.margin_left)
        top = Inches(pos.top) if pos else Inches(brand.spacing.content_top)
        width = Inches(pos.width) if pos else Inches(10.0)
        height = Inches(pos.height) if pos else Inches(4.0)

        txbox = slide.shapes.add_textbox(left, top, width, height)
        tf = txbox.text_frame
        tf.word_wrap = True
        _apply_text_frame_padding(tf, brand)

        from pptx_mcp.engine.layouts import _set_bullet

        for i, item in enumerate(elem.items):
            p = tf.paragraphs[0] if i == 0 else tf.add_paragraph()
            p.text = item
            p.space_after = Pt(6)
            _apply_line_spacing(p, brand)
            _set_bullet(p)
            run = p.runs[0]
            run.font.name = brand.fonts.body_font
            run.font.size = Pt(brand.fonts.body_size)
            run.font.color.rgb = parse_hex_color(brand.colors.text_primary)

    def _add_footer(self, slide: Slide, brand: BrandConfig) -> None:
        """Add footer text to the bottom of a slide."""
        if not brand.footer_text:
            return
        footer = slide.shapes.add_textbox(
            Inches(brand.spacing.margin_left),
            Inches(7.0),
            Inches(6.0),
            Inches(0.3),
        )
        tf = footer.text_frame
        p = tf.paragraphs[0]
        p.text = brand.footer_text
        run = p.runs[0]
        run.font.name = brand.fonts.body_font
        run.font.size = Pt(brand.fonts.caption_size)
        run.font.color.rgb = parse_hex_color(brand.colors.text_secondary)
