"""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.diagrams import (
    render_org_chart_slide,
    render_matrix_slide,
    render_pyramid_slide,
    render_venn_slide,
    render_waterfall_slide,
    render_hub_spoke_slide,
    render_cycle_slide,
    render_gantt_slide,
)
from pptx_mcp.engine.layouts import (
    render_agenda_slide,
    render_big_number_slide,
    render_callout_box,
    render_card_grid_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_edge_bar,
    _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
            if definition.metadata.keywords:
                prs.core_properties.keywords = definition.metadata.keywords
            if definition.metadata.comments:
                prs.core_properties.comments = definition.metadata.comments

            for slide_idx, slide_def in enumerate(definition.slides):
                self._render_slide(prs, slide_def, brand, slide_width, slide_number=slide_idx + 1)

            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.

        Renders the new slide onto a temporary Presentation, then replaces the
        target slide's XML in-place.  This avoids the delete+add cycle that
        triggers a python-pptx partname collision bug where all relationships
        end up pointing to the same slide file.

        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.
        """
        from copy import deepcopy
        from lxml import etree
        from pptx.oxml.ns import qn

        pptx_path = Path(pptx_path)
        prs = Presentation(str(pptx_path))
        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})"
            )
        slide_width = round(prs.slide_width / 914400, 3) if prs.slide_width else 13.333

        # Render the new slide onto a disposable Presentation
        tmp_prs = Presentation()
        tmp_prs.slide_width = prs.slide_width
        tmp_prs.slide_height = prs.slide_height
        self._render_slide(tmp_prs, slide_def, self.brand, slide_width)
        new_slide = tmp_prs.slides[0]

        # Replace the target slide's shape tree XML with the new content
        target_slide = prs.slides[slide_index]
        target_cSld = target_slide._element.find(qn("p:cSld"))
        new_cSld = new_slide._element.find(qn("p:cSld"))

        if target_cSld is not None and new_cSld is not None:
            # Replace the entire cSld element (contains spTree with all shapes)
            parent = target_cSld.getparent()
            idx = list(parent).index(target_cSld)
            parent.remove(target_cSld)
            parent.insert(idx, deepcopy(new_cSld))

        # Copy background if the new slide has one
        new_bg = new_slide._element.find(qn("p:cSld")).find(qn("p:bg")) if new_cSld is not None else None
        target_new_cSld = target_slide._element.find(qn("p:cSld"))
        if target_new_cSld is not None:
            old_bg = target_new_cSld.find(qn("p:bg"))
            # Background is already included in the copied cSld, no extra work needed

        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,
        slide_number: int | None = None,
    ) -> None:
        """Render a single slide based on its type."""
        layout = prs.slide_layouts[6]  # Blank layout
        slide: Slide = prs.slides.add_slide(layout)

        # Background: gradient takes priority over solid color
        if definition.background_gradient and len(definition.background_gradient) >= 2:
            self._apply_gradient_background(slide, definition.background_gradient)
        else:
            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)

        # Edge bar (rendered early so content draws on top)
        if brand.shape_style.edge_bar != "none":
            _add_edge_bar(slide, brand, slide_width)

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

        # Slide transition
        if definition.transition:
            self._apply_transition(slide, definition.transition)

        # 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.CARD_GRID: render_card_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,
            # Diagram types (v0.6.0)
            SlideType.ORG_CHART: render_org_chart_slide,
            SlideType.MATRIX: render_matrix_slide,
            SlideType.PYRAMID: render_pyramid_slide,
            SlideType.VENN: render_venn_slide,
            SlideType.WATERFALL: render_waterfall_slide,
            SlideType.HUB_SPOKE: render_hub_spoke_slide,
            SlideType.CYCLE: render_cycle_slide,
            SlideType.GANTT: render_gantt_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

        # Callout box (can appear on any slide type)
        if definition.callout:
            render_callout_box(slide, definition.callout, brand, slide_width)

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

        # Logo placement
        self._add_logo(slide, brand, definition, slide_width)

        # Footer (skip on title/closing if configured)
        hf = brand.header_footer
        skip_types = {SlideType.TITLE, SlideType.CLOSING} if hf.skip_title_slide else set()
        has_footer_content = (
            brand.footer_text
            or hf.show_slide_numbers
            or hf.show_date
            or hf.show_company_name
            or hf.confidential_marking
            or (definition.footnotes)
        )
        if has_footer_content and definition.slide_type not in skip_types:
            self._add_footer(slide, brand, slide_number=slide_number, definition=definition)

    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 = []
        shape_id_map: dict[str, object] = {}
        for i, elem in enumerate(definition.elements):
            if isinstance(elem, ShapeElement):
                add_shape_to_slide(slide, elem, i)
                shape_obj = slide.shapes[-1]
                shape_objects.append(shape_obj)
                if elem.id:
                    shape_id_map[elem.id] = shape_obj

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

    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.

        Charts and tables are skipped when the slide type already has a
        dedicated renderer (CHART, TABLE) to avoid duplicate rendering.
        """
        skip_charts = definition.slide_type == SlideType.CHART
        skip_tables = definition.slide_type == SlideType.TABLE

        shape_objects: list = []
        shape_id_map: dict[str, object] = {}
        for i, elem in enumerate(definition.elements):
            if isinstance(elem, ChartElement):
                if not skip_charts:
                    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_obj = slide.shapes[-1]
                shape_objects.append(shape_obj)
                if elem.id:
                    shape_id_map[elem.id] = shape_obj
            elif isinstance(elem, ConnectorElement):
                add_connector_to_slide(slide, elem, shape_objects, shape_id_map)
            elif isinstance(elem, ImageElement):
                self._add_image(slide, elem)
            elif isinstance(elem, TableElement):
                if not skip_tables:
                    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):
                self._render_group(slide, elem, brand, slide_width)

    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

        # Table-level styling
        ts = elem.table_style
        if ts:
            table.horz_banding = ts.banded_rows
            table.vert_banding = ts.banded_cols
            table.first_col = ts.first_col_bold
            table.last_col = ts.last_col_bold

        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
                    elif elem.alternate_row_shading and row_idx > 0 and row_idx % 2 == 0:
                        cell.fill.solid()
                        cell.fill.fore_color.rgb = parse_hex_color(brand.colors.surface)

                    # Vertical alignment
                    if cell_def.vertical_align:
                        from pptx.enum.text import MSO_ANCHOR
                        valign_map = {
                            "top": MSO_ANCHOR.TOP,
                            "middle": MSO_ANCHOR.MIDDLE,
                            "bottom": MSO_ANCHOR.BOTTOM,
                        }
                        anchor = valign_map.get(cell_def.vertical_align)
                        if anchor is not None:
                            cell.vertical_anchor = anchor

                    # Cell margins
                    if ts and ts.cell_margin_pt is not None:
                        margin = Pt(ts.cell_margin_pt)
                        cell.margin_left = margin
                        cell.margin_right = margin
                        cell.margin_top = margin
                        cell.margin_bottom = margin

                    # Cell merging
                    if cell_def.col_span > 1 or cell_def.row_span > 1:
                        end_row = min(row_idx + cell_def.row_span - 1, n_rows - 1)
                        end_col = min(col_idx + cell_def.col_span - 1, n_cols - 1)
                        if end_row > row_idx or end_col > col_idx:
                            cell.merge(table.cell(end_row, end_col))

    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)
                # External hyperlink
                if run_def.hyperlink:
                    run.hyperlink.address = run_def.hyperlink
                # Internal slide hyperlink
                elif run_def.slide_link is not None:
                    self._apply_slide_link(run, run_def.slide_link)

    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]
            elem_style = elem.style
            run.font.name = (elem_style.font_name if elem_style and elem_style.font_name
                             else brand.fonts.body_font)
            run.font.size = Pt(elem_style.font_size if elem_style and elem_style.font_size
                               else brand.fonts.body_size)
            run.font.color.rgb = parse_hex_color(
                elem_style.color if elem_style and elem_style.color
                else brand.colors.text_primary)

    def _render_group(
        self,
        slide: Slide,
        group: GroupElement,
        brand: BrandConfig,
        slide_width: float,
        depth: int = 0,
    ) -> None:
        """Render a GroupElement by rendering each sub-element with position offset.

        Recursion is capped at depth 5 to prevent infinite nesting.
        """
        if depth > 5:
            logger.warning("GroupElement nesting depth exceeded 5; skipping nested group")
            return

        if not group.elements:
            return

        offset_left = group.position.left if group.position else 0.0
        offset_top = group.position.top if group.position else 0.0

        for i, elem in enumerate(group.elements):
            if isinstance(elem, ConnectorElement):
                logger.warning("ConnectorElement inside group skipped (indices invalid in group context)")
                continue
            if isinstance(elem, GroupElement):
                # Nest: apply cumulative offset
                nested = self._apply_group_offset(elem, offset_left, offset_top)
                self._render_group(slide, nested, brand, slide_width, depth=depth + 1)
                continue
            # Apply the group's position offset to the sub-element
            offset_elem = self._apply_group_offset(elem, offset_left, offset_top)
            # Re-dispatch to the element renderers
            if isinstance(offset_elem, ChartElement):
                add_chart_to_slide(slide, offset_elem, chart_colors=brand.colors.chart_colors or None)
            elif isinstance(offset_elem, ShapeElement):
                add_shape_to_slide(slide, offset_elem, i)
            elif isinstance(offset_elem, ImageElement):
                self._add_image(slide, offset_elem)
            elif isinstance(offset_elem, TableElement):
                self._add_table(slide, offset_elem, brand, slide_width)
            elif isinstance(offset_elem, TextBox):
                self._add_textbox(slide, offset_elem, brand)
            elif isinstance(offset_elem, BulletList):
                self._add_bullet_list(slide, offset_elem, brand)

    @staticmethod
    def _apply_group_offset(elem, offset_left: float, offset_top: float):
        """Return a copy of *elem* with position shifted by the group offset."""
        from pptx_mcp.models.slides import Position

        pos = getattr(elem, "position", None)
        if pos is None:
            new_pos = Position(left=offset_left, top=offset_top, width=4.0, height=2.0)
        else:
            new_pos = Position(
                left=pos.left + offset_left,
                top=pos.top + offset_top,
                width=pos.width,
                height=pos.height,
            )
        if hasattr(elem, "model_copy"):
            return elem.model_copy(update={"position": new_pos})
        # Fallback for non-Pydantic objects
        import copy
        new_elem = copy.deepcopy(elem)
        new_elem.position = new_pos
        return new_elem

    def _add_logo(
        self,
        slide: Slide,
        brand: BrandConfig,
        definition: SlideDefinition,
        slide_width: float,
    ) -> None:
        """Place logo image on the slide based on brand.logo config.

        Skips if no logo path configured or slide type matches skip_on.
        """
        logo_cfg = brand.logo
        logo_path = logo_cfg.path or brand.logo_path
        if not logo_path:
            return

        # Check skip_on list
        slide_type_val = definition.slide_type.value if definition.slide_type else ""
        if slide_type_val in logo_cfg.skip_on:
            return

        try:
            validated = validate_image_path(logo_path)
        except Exception:
            logger.warning("Skipping logo: invalid or missing path '%s'", logo_path)
            return

        max_h = logo_cfg.max_height
        # Aspect ratio preserved by python-pptx when only height is specified
        margin = 0.2
        placement = logo_cfg.placement

        if placement == "top_left":
            left, top = margin, margin
        elif placement == "top_right":
            left, top = slide_width - 1.5 - margin, margin
        elif placement == "bottom_left":
            left, top = margin, 7.5 - max_h - margin
        else:  # bottom_right
            left, top = slide_width - 1.5 - margin, 7.5 - max_h - margin

        slide.shapes.add_picture(
            str(validated), Inches(left), Inches(top), height=Inches(max_h),
        )

    def _add_footer(
        self,
        slide: Slide,
        brand: BrandConfig,
        slide_number: int | None = None,
        definition: SlideDefinition | None = None,
    ) -> None:
        """Add footer with slide numbers, confidential marking, and footnotes."""
        from pptx.enum.text import PP_ALIGN

        sp = brand.spacing
        hf = brand.header_footer
        cap_size = brand.fonts.caption_size
        sec_color = parse_hex_color(brand.colors.text_secondary)
        total_width = 13.333  # default widescreen
        footer_h = 0.22
        footer_y = 7.5 - sp.margin_bottom + 0.05  # dynamic: just inside bottom margin

        # Footnotes (above footer line)
        if definition and definition.footnotes:
            fn_y = footer_y - 0.15 * len(definition.footnotes) - 0.1
            for i, fn in enumerate(definition.footnotes):
                fn_box = slide.shapes.add_textbox(
                    Inches(sp.margin_left), Inches(fn_y + i * 0.15),
                    Inches(total_width - sp.margin_left - sp.margin_right), Inches(0.15),
                )
                tf = fn_box.text_frame
                p = tf.paragraphs[0]
                p.text = f"{i + 1}. {fn}"
                run = p.runs[0]
                run.font.name = brand.fonts.body_font
                run.font.size = Pt(max(cap_size - 1, 10))
                run.font.color.rgb = sec_color

        # Determine which footer elements are present
        left_parts: list[str] = []
        if brand.footer_text:
            left_parts.append(brand.footer_text)
        if hf.show_company_name and brand.name:
            left_parts.append(brand.name)
        if hf.show_date:
            import datetime
            left_parts.append(hf.date_text or datetime.date.today().strftime("%B %Y"))

        has_left = bool(left_parts)
        has_center = bool(hf.confidential_marking)
        has_right = hf.show_slide_numbers and slide_number is not None

        # Compute non-overlapping widths with 0.2" gaps
        usable = total_width - sp.margin_left - sp.margin_right
        gap = 0.2
        right_w = 1.2 if has_right else 0.0
        center_w = 3.0 if has_center else 0.0
        n_gaps = sum([has_left, has_center, has_right]) - 1
        left_w = usable - right_w - center_w - max(n_gaps, 0) * gap if has_left else 0.0

        cursor_x = sp.margin_left

        # Left: footer text / company name
        if has_left:
            left_text = "  |  ".join(left_parts)
            footer_box = slide.shapes.add_textbox(
                Inches(cursor_x), Inches(footer_y),
                Inches(left_w), Inches(footer_h),
            )
            tf = footer_box.text_frame
            p = tf.paragraphs[0]
            p.text = left_text
            p.alignment = PP_ALIGN.LEFT
            run = p.runs[0]
            run.font.name = brand.fonts.body_font
            run.font.size = Pt(cap_size)
            run.font.color.rgb = sec_color
            cursor_x += left_w + gap

        # Center: confidential marking
        if has_center:
            conf_box = slide.shapes.add_textbox(
                Inches(cursor_x), Inches(footer_y),
                Inches(center_w), Inches(footer_h),
            )
            tf = conf_box.text_frame
            p = tf.paragraphs[0]
            p.text = hf.confidential_marking
            p.alignment = PP_ALIGN.CENTER
            run = p.runs[0]
            run.font.name = brand.fonts.body_font
            run.font.size = Pt(cap_size)
            run.font.bold = True
            run.font.color.rgb = sec_color
            cursor_x += center_w + gap

        # Right: slide number
        if has_right:
            num_x = total_width - sp.margin_right - right_w
            num_box = slide.shapes.add_textbox(
                Inches(num_x), Inches(footer_y),
                Inches(right_w), Inches(footer_h),
            )
            tf = num_box.text_frame
            p = tf.paragraphs[0]
            p.text = str(slide_number)
            p.alignment = PP_ALIGN.RIGHT
            run = p.runs[0]
            run.font.name = brand.fonts.body_font
            run.font.size = Pt(cap_size)
            run.font.color.rgb = sec_color

    @staticmethod
    def _apply_slide_link(run: object, slide_index: int) -> None:
        """Apply an internal slide hyperlink to a text run via XML."""
        from lxml import etree
        from pptx.oxml import OxmlElement
        from pptx.oxml.ns import qn

        r_element = run._r  # type: ignore[attr-defined]
        rPr = r_element.get_or_add_rPr()
        hlinkClick = OxmlElement("a:hlinkClick")
        hlinkClick.set(qn("r:id"), "")
        hlinkClick.set("action", f"ppaction://hlinksldjump")
        # Store slide index for resolution (1-based in PPTX)
        hlinkClick.set("tooltip", f"Slide {slide_index + 1}")
        rPr.append(hlinkClick)

    @staticmethod
    def _apply_transition(slide: Slide, transition_type: str) -> None:
        """Apply a slide transition via XML."""
        from lxml import etree
        from pptx.oxml.ns import qn as _qn

        sld = slide._element
        for old in sld.findall(_qn("p:transition")):
            sld.remove(old)

        transition = etree.SubElement(sld, _qn("p:transition"))
        transition.set("spd", "med")
        transition.set("advClick", "1")

        transition_map = {
            "fade": "p:fade",
            "push": "p:push",
            "wipe": "p:wipe",
            "split": "p:split",
            "dissolve": "p:dissolve",
            "cover": "p:cover",
            "random": "p:random",
        }
        tag = transition_map.get(transition_type, "p:fade")
        etree.SubElement(transition, _qn(tag))

    @staticmethod
    def _apply_gradient_background(slide: Slide, colors: list[str]) -> None:
        """Apply a linear gradient background (top to bottom) with 2-5 color stops."""
        from lxml import etree
        from pptx.oxml.ns import qn as _qn

        background = slide.background
        fill = background.fill
        fill.gradient()

        # Access the underlying XML element (not the wrapper)
        grad_fill_xml = fill._fill._element  # type: ignore[attr-defined]

        # Remove default gradient stop list
        for old_stop in grad_fill_xml.findall(_qn("a:gsLst")):
            grad_fill_xml.remove(old_stop)

        gs_lst = etree.SubElement(grad_fill_xml, _qn("a:gsLst"))
        n = len(colors)
        for i, hex_color in enumerate(colors):
            position = int((i / max(n - 1, 1)) * 100000)
            gs = etree.SubElement(gs_lst, _qn("a:gs"))
            gs.set("pos", str(position))
            srgb = etree.SubElement(gs, _qn("a:srgbClr"))
            srgb.set("val", hex_color.lstrip("#")[:6].upper())

        # Linear fill angle: top-to-bottom = 5400000 (90 degrees in 60000ths)
        for old_lin in grad_fill_xml.findall(_qn("a:lin")):
            grad_fill_xml.remove(old_lin)
        lin = etree.SubElement(grad_fill_xml, _qn("a:lin"))
        lin.set("ang", "5400000")
        lin.set("scaled", "1")
