from __future__ import annotations

import base64
import io
from collections.abc import Callable

from PIL import Image
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
from pptx.enum.text import PP_ALIGN
from pptx.util import Inches

from .deck_models import ComparisonSide, DesignSpec, ImageTextSpec, KpiMetric, SlideSpec
from .models import PptxImageAsset
from .rendering_blocks import (
    _add_card,
    _add_panel,
    _add_table_block,
    _add_textbox,
    _apply_background,
    _center_block_vertically,
    _panel_content_left,
    _render_image_placeholder,
    _set_fill,
    _set_line,
    _slide_dimensions,
    _soften_round_rect,
)
from .rendering_styles import (
    ResolvedSpacing,
    ResolvedTheme,
    ResolvedTypeScale,
    TextBoxStyle,
    _is_compact_four_card_column,
    _is_compact_kpi_metric,
    _resolve_spacing,
    _resolve_text_insets,
    _resolve_type_scale,
    _split_text_lines,
)


def _render_title_slide(prs, slide, spec: SlideSpec, theme: ResolvedTheme, design: DesignSpec):
    slide_width, _ = _slide_dimensions(prs)
    spacing = _resolve_spacing(design)
    text_insets = _resolve_text_insets(spacing)
    type_scale = _resolve_type_scale(design)
    _apply_background(prs, slide, theme, design)

    eyebrow = slide.shapes.add_shape(
        MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE,
        Inches(spacing.outer_x),
        Inches(1.04),
        Inches(0.92),
        Inches(0.09),
    )
    _soften_round_rect(eyebrow, 0.03)
    _set_fill(eyebrow, theme.accent_color)
    _set_line(eyebrow, None)
    _add_textbox(
        slide,
        spacing.outer_x,
        1.18,
        1.9,
        0.22,
        "PRESENTATION",
        theme,
        TextBoxStyle(type_scale.caption, color=theme.accent_strong_color, bold=True, fit_text=False),
    )

    _add_textbox(
        slide,
        spacing.outer_x,
        1.62,
        slide_width - (2 * spacing.outer_x) - 3.15,
        1.7,
        spec.title or "",
        theme,
        TextBoxStyle(type_scale.hero + 1, bold=True, fit_text=False),
    )
    if spec.subtitle:
        _add_textbox(
            slide,
            spacing.outer_x,
            3.36,
            5.8,
            0.8,
            spec.subtitle,
            theme,
            TextBoxStyle(type_scale.subtitle, color=theme.muted_text_color, fit_text=False),
        )
    if spec.body:
        _add_textbox(
            slide,
            spacing.outer_x,
            4.18,
            5.9,
            1.0,
            spec.body,
            theme,
            TextBoxStyle(type_scale.body, color=theme.muted_text_color),
        )


def _render_section_slide(prs, slide, spec: SlideSpec, theme: ResolvedTheme, design: DesignSpec):
    slide_width, slide_height = _slide_dimensions(prs)
    spacing = _resolve_spacing(design)
    text_insets = _resolve_text_insets(spacing)
    type_scale = _resolve_type_scale(design)
    _apply_background(prs, slide, theme, design)

    rail = slide.shapes.add_shape(
        MSO_AUTO_SHAPE_TYPE.RECTANGLE,
        Inches(spacing.outer_x),
        Inches(1.1),
        Inches(0.22),
        Inches(slide_height - 2.2),
    )
    _set_fill(rail, theme.accent_color)
    _set_line(rail, None)

    badge = slide.shapes.add_shape(
        MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE,
        Inches(spacing.outer_x + 0.42),
        Inches(1.18),
        Inches(1.25),
        Inches(0.38),
    )
    _soften_round_rect(badge, 0.04)
    _set_fill(badge, theme.accent_soft_color)
    _set_line(badge, None)
    _add_textbox(
        slide,
        spacing.outer_x + 0.56,
        1.22,
        0.95,
        0.22,
        "SECTION",
        theme,
        TextBoxStyle(type_scale.caption, color=theme.accent_strong_color, bold=True, fit_text=False),
    )

    _add_textbox(
        slide,
        spacing.outer_x + 0.48,
        2.05,
        slide_width - (2 * spacing.outer_x) - 3.55,
        1.52,
        spec.title or "",
        theme,
        TextBoxStyle(type_scale.section, bold=True, fit_text=False),
    )
    if spec.subtitle:
        _add_textbox(
            slide,
            spacing.outer_x + 0.52,
            3.55,
            5.95,
            0.92,
            spec.subtitle,
            theme,
            TextBoxStyle(type_scale.subtitle, color=theme.muted_text_color, fit_text=False),
        )
    panel_copy = spec.body or spec.subtitle
    if spec.body:
        _add_textbox(
            slide,
            spacing.outer_x + 0.52,
            4.45,
            5.55,
            1.48,
            spec.body,
            theme,
            TextBoxStyle(type_scale.body, color=theme.muted_text_color, insets=text_insets.default),
        )
    if panel_copy:
        panel_left = slide_width - 3.55
        panel_top = 2.15
        panel_width = 2.75
        panel_height = 2.95
        _add_panel(slide, panel_left, panel_top, panel_width, panel_height, theme, design, spacing)
        _add_textbox(
            slide,
            panel_left,
            panel_top + 0.18,
            panel_width,
            0.42,
            "SUMMARY",
            theme,
            TextBoxStyle(
                type_scale.caption,
                color=theme.accent_strong_color,
                bold=True,
                fit_text=False,
                insets=text_insets.section_summary,
            ),
        )
        _add_textbox(
            slide,
            panel_left,
            panel_top + 0.58,
            panel_width,
            panel_height - 0.72,
            "\n".join(_split_text_lines(panel_copy)[:3]),
            theme,
            TextBoxStyle(
                type_scale.body,
                color=theme.text_color,
                insets=text_insets.section_summary,
                paragraph_space_after=7,
            ),
        )


def _render_columns_slide(prs, slide, spec: SlideSpec, theme: ResolvedTheme, design: DesignSpec):
    slide_width, slide_height = _slide_dimensions(prs)
    spacing = _resolve_spacing(design)
    type_scale = _resolve_type_scale(design)
    _apply_background(prs, slide, theme, design)

    top = spacing.outer_y + 0.05
    if spec.title:
        _add_textbox(
            slide,
            spacing.outer_x,
            top,
            slide_width - (2 * spacing.outer_x),
            0.72,
            spec.title,
            theme,
            TextBoxStyle(type_scale.title, bold=True, fit_text=False),
        )
        top += 0.9
    columns_count = spec.layout.columns or len(spec.columns)
    gap = spec.layout.gap if spec.layout.gap is not None else max(spacing.panel_gap, 0.32)
    available_width = slide_width - (2 * spacing.outer_x)
    use_single_row = columns_count != 4 or all(_is_compact_four_card_column(column) for column in spec.columns)
    has_table_column = any(column.table is not None for column in spec.columns)

    if columns_count == 4 and not use_single_row:
        _render_columns_slide_as_two_row_grid(
            slide, spec, theme, design, spacing, type_scale, slide_height, top, gap, available_width
        )
        return

    if has_table_column:
        _render_columns_slide_with_table_blocks(
            slide, spec, theme, design, spacing, type_scale, slide_height, top, gap, available_width, columns_count
        )
        return

    _render_columns_slide_as_single_row_cards(
        slide, spec, theme, design, spacing, type_scale, slide_height, top, gap, available_width, columns_count
    )


def _render_columns_slide_as_two_row_grid(
    slide,
    spec: SlideSpec,
    theme: ResolvedTheme,
    design: DesignSpec,
    spacing: ResolvedSpacing,
    type_scale: ResolvedTypeScale,
    slide_height: float,
    top: float,
    gap: float,
    available_width: float,
):
    grid_columns = 2
    card_width = (available_width - gap) / grid_columns
    available_height = slide_height - top - 0.95
    card_height = min(2.45, max(1.9, (available_height - gap) / 2))
    block_height = 2 * card_height + gap
    card_top = _center_block_vertically(
        content_top=top,
        block_height=block_height,
        slide_height=slide_height,
        bottom_margin=0.95,
    )
    for index, column in enumerate(spec.columns):
        row = index // grid_columns
        col = index % grid_columns
        card_left = spacing.outer_x + col * (card_width + gap)
        row_top = card_top + row * (card_height + gap)
        _add_card(
            slide,
            card_left,
            row_top,
            card_width,
            card_height,
            column.title,
            column.body,
            column.table,
            theme,
            design,
            spacing,
            type_scale,
            fit_title_text=True,
        )


def _render_columns_slide_with_table_blocks(
    slide,
    spec: SlideSpec,
    theme: ResolvedTheme,
    design: DesignSpec,
    spacing: ResolvedSpacing,
    type_scale: ResolvedTypeScale,
    slide_height: float,
    top: float,
    gap: float,
    available_width: float,
    columns_count: int,
):
    block_height = slide_height - top - 0.55
    block_top = top + 0.1
    card_width = (available_width - gap * (columns_count - 1)) / columns_count
    for index, column in enumerate(spec.columns):
        card_left = spacing.outer_x + index * (card_width + gap)
        if column.table is not None:
            _add_table_block(
                slide,
                card_left,
                block_top,
                card_width,
                block_height,
                column.title,
                column.body,
                column.table,
                theme,
                spacing,
                type_scale,
            )
            continue
        _add_card(
            slide,
            card_left,
            block_top,
            card_width,
            block_height,
            column.title,
            column.body,
            column.table,
            theme,
            design,
            spacing,
            type_scale,
        )


def _render_columns_slide_as_single_row_cards(
    slide,
    spec: SlideSpec,
    theme: ResolvedTheme,
    design: DesignSpec,
    spacing: ResolvedSpacing,
    type_scale: ResolvedTypeScale,
    slide_height: float,
    top: float,
    gap: float,
    available_width: float,
    columns_count: int,
):
    card_width = (available_width - gap * (columns_count - 1)) / columns_count
    card_height = min(4.05, slide_height - top - 1.05)
    if columns_count == 2:
        card_height = min(4.15, slide_height - top - 1.0)
    elif columns_count >= 4:
        card_height = min(3.55, slide_height - top - 1.1)
    card_top = _center_block_vertically(
        content_top=top, block_height=card_height, slide_height=slide_height, bottom_margin=0.9
    )
    for index, column in enumerate(spec.columns):
        card_left = spacing.outer_x + index * (card_width + gap)
        _add_card(
            slide,
            card_left,
            card_top,
            card_width,
            card_height,
            column.title,
            column.body,
            column.table,
            theme,
            design,
            spacing,
            type_scale,
        )


def _render_comparison_side(
    slide,
    left: float,
    top: float,
    width: float,
    height: float,
    side: ComparisonSide,
    theme: ResolvedTheme,
    design: DesignSpec,
    spacing: ResolvedSpacing,
    type_scale: ResolvedTypeScale,
):
    if side.table is not None:
        _add_table_block(slide, left, top, width, height, side.title, side.body, side.table, theme, spacing, type_scale)
        return
    _add_card(slide, left, top, width, height, side.title, side.body, side.table, theme, design, spacing, type_scale)


def _render_comparison_slide(prs, slide, spec: SlideSpec, theme: ResolvedTheme, design: DesignSpec):
    slide_width, slide_height = _slide_dimensions(prs)
    spacing = _resolve_spacing(design)
    type_scale = _resolve_type_scale(design)
    _apply_background(prs, slide, theme, design)

    top = spacing.outer_y
    if spec.title:
        _add_textbox(
            slide,
            spacing.outer_x,
            top,
            slide_width - (2 * spacing.outer_x),
            0.72,
            spec.title,
            theme,
            TextBoxStyle(type_scale.title, bold=True, fit_text=False),
        )
        top += 0.95

    card_height = 4.55
    card_width = (slide_width - (2 * spacing.outer_x) - 0.9) / 2
    card_top = _center_block_vertically(
        content_top=top, block_height=card_height, slide_height=slide_height, bottom_margin=0.9
    )
    _render_comparison_side(
        slide, spacing.outer_x, card_top, card_width, card_height, spec.left, theme, design, spacing, type_scale
    )
    _render_comparison_side(
        slide,
        spacing.outer_x + card_width + 0.9,
        card_top,
        card_width,
        card_height,
        spec.right,
        theme,
        design,
        spacing,
        type_scale,
    )

    divider = slide.shapes.add_shape(
        MSO_AUTO_SHAPE_TYPE.RECTANGLE,
        Inches((slide_width / 2) - 0.03),
        Inches(card_top + 0.35),
        Inches(0.06),
        Inches(card_height - 0.7),
    )
    _set_fill(divider, theme.accent_soft_color)
    _set_line(divider, None)


def _render_timeline_slide(prs, slide, spec: SlideSpec, theme: ResolvedTheme, design: DesignSpec):
    slide_width, _ = _slide_dimensions(prs)
    spacing = _resolve_spacing(design)
    text_insets = _resolve_text_insets(spacing)
    type_scale = _resolve_type_scale(design)
    _apply_background(prs, slide, theme, design)

    top = spacing.outer_y
    if spec.title:
        _add_textbox(
            slide,
            spacing.outer_x,
            top,
            slide_width - (2 * spacing.outer_x),
            0.72,
            spec.title,
            theme,
            TextBoxStyle(type_scale.title, bold=True, fit_text=False),
        )
        top += 0.9

    axis_y = top + 0.95
    start_x = spacing.outer_x + 0.45
    end_x = slide_width - spacing.outer_x - 0.45
    line = slide.shapes.add_shape(
        MSO_AUTO_SHAPE_TYPE.RECTANGLE,
        Inches(start_x),
        Inches(axis_y + 0.01),
        Inches(end_x - start_x),
        Inches(0.08),
    )
    _set_fill(line, theme.border_color, 0.0)
    _set_line(line, None)

    item_count = len(spec.items)
    step = (end_x - start_x) / max(1, item_count - 1)

    for index, item in enumerate(spec.items):
        x = start_x + index * step
        marker = slide.shapes.add_shape(
            MSO_AUTO_SHAPE_TYPE.OVAL,
            Inches(x - 0.18),
            Inches(axis_y - 0.17),
            Inches(0.4),
            Inches(0.4),
        )
        _set_fill(marker, theme.accent_color)
        _set_line(marker, None)
        connector = slide.shapes.add_shape(
            MSO_AUTO_SHAPE_TYPE.RECTANGLE,
            Inches(x - 0.01),
            Inches(axis_y + 0.2),
            Inches(0.05),
            Inches(0.5),
        )
        _set_fill(connector, theme.accent_soft_color)
        _set_line(connector, None)
        card_width = min(2.55, max(2.05, step - 0.28))
        card_left = max(spacing.outer_x, min(x - (card_width / 2), slide_width - spacing.outer_x - card_width))
        card_top = axis_y + 0.72 + (0.16 if item_count >= 5 and index % 2 else 0.0)
        card_height = 1.72
        _add_panel(slide, card_left, card_top, card_width, card_height, theme, design, spacing)
        _add_textbox(
            slide,
            card_left,
            card_top + 0.14,
            card_width,
            0.32,
            str(index + 1),
            theme,
            TextBoxStyle(
                type_scale.caption,
                color=theme.accent_strong_color,
                bold=True,
                align=PP_ALIGN.CENTER,
                fit_text=False,
                insets=text_insets.timeline_card,
            ),
        )
        _add_textbox(
            slide,
            card_left,
            card_top + 0.42,
            card_width,
            0.5,
            item.title,
            theme,
            TextBoxStyle(
                type_scale.body,
                bold=True,
                align=PP_ALIGN.CENTER,
                fit_text=False,
                insets=text_insets.timeline_card,
            ),
        )
        _add_textbox(
            slide,
            card_left,
            card_top + 0.9,
            card_width,
            0.62,
            item.body,
            theme,
            TextBoxStyle(
                type_scale.detail,
                color=theme.muted_text_color,
                align=PP_ALIGN.CENTER,
                insets=text_insets.timeline_card,
            ),
        )


def _render_metric_card(
    slide,
    left: float,
    top: float,
    width: float,
    height: float,
    metric: KpiMetric,
    theme: ResolvedTheme,
    design: DesignSpec,
    spacing: ResolvedSpacing,
    type_scale: ResolvedTypeScale,
):
    if not _is_compact_kpi_metric(metric):
        body_lines = [metric.value.strip()]
        if metric.detail and metric.detail.strip():
            body_lines.append(metric.detail.strip())
        _add_card(
            slide,
            left,
            top,
            width,
            height,
            metric.label,
            "\n".join(line for line in body_lines if line),
            None,
            theme,
            design,
            spacing,
            type_scale,
            fit_title_text=True,
        )
        return

    text_insets = _resolve_text_insets(spacing)
    _add_panel(slide, left, top, width, height, theme, design, spacing)
    content_left = _panel_content_left(left, design, spacing)
    content_width = width - (content_left - left)
    _add_textbox(
        slide,
        content_left,
        top + 0.28,
        content_width,
        0.74,
        metric.value,
        theme,
        TextBoxStyle(
            type_scale.kpi_value,
            color=theme.accent_strong_color,
            bold=True,
            fit_text=False,
            insets=text_insets.card_title,
        ),
    )
    _add_textbox(
        slide,
        content_left,
        top + 0.94,
        content_width,
        0.46,
        metric.label,
        theme,
        TextBoxStyle(
            type_scale.body,
            color=theme.muted_text_color,
            fit_text=False,
            insets=text_insets.card_body,
        ),
    )
    if metric.detail:
        _add_textbox(
            slide,
            content_left,
            top + 1.3,
            content_width,
            0.44,
            metric.detail,
            theme,
            TextBoxStyle(type_scale.detail, color=theme.muted_text_color, insets=text_insets.card_body),
        )


def _render_kpi_grid_slide(prs, slide, spec: SlideSpec, theme: ResolvedTheme, design: DesignSpec):
    slide_width, slide_height = _slide_dimensions(prs)
    spacing = _resolve_spacing(design)
    type_scale = _resolve_type_scale(design)
    _apply_background(prs, slide, theme, design)

    top = spacing.outer_y
    if spec.title:
        _add_textbox(
            slide,
            spacing.outer_x,
            top,
            slide_width - (2 * spacing.outer_x),
            0.72,
            spec.title,
            theme,
            TextBoxStyle(type_scale.title, bold=True, fit_text=False),
        )
        top += 0.92

    metrics = spec.metrics
    if len(metrics) <= 2:
        columns = len(metrics)
    elif len(metrics) == 4:
        columns = 2
    else:
        columns = 3
    gap = spacing.panel_gap
    card_width = (slide_width - (2 * spacing.outer_x) - (columns - 1) * gap) / columns
    has_verbose_metric = any(not _is_compact_kpi_metric(metric) for metric in metrics)
    if columns == 3:
        card_height = 1.65
    elif has_verbose_metric:
        available_height = slide_height - top - 0.95
        rows = (len(metrics) + columns - 1) // columns
        card_height = min(2.45, max(1.95, (available_height - max(0, rows - 1) * gap) / rows))
    else:
        card_height = 1.95
    rows = (len(metrics) + columns - 1) // columns
    grid_height = rows * card_height + max(0, rows - 1) * gap
    grid_top = _center_block_vertically(
        content_top=top, block_height=grid_height, slide_height=slide_height, bottom_margin=0.95
    )
    for index, metric in enumerate(metrics):
        row = index // columns
        col = index % columns
        left = spacing.outer_x + col * (card_width + gap)
        metric_top = grid_top + row * (card_height + gap)
        _render_metric_card(
            slide, left, metric_top, card_width, card_height, metric, theme, design, spacing, type_scale
        )


def _render_quote_slide(prs, slide, spec: SlideSpec, theme: ResolvedTheme, design: DesignSpec):
    slide_width, _ = _slide_dimensions(prs)
    spacing = _resolve_spacing(design)
    type_scale = _resolve_type_scale(design)
    _apply_background(prs, slide, theme, design)

    _add_textbox(
        slide,
        spacing.outer_x + 0.05,
        0.9,
        1.1,
        1.0,
        "“",
        theme,
        TextBoxStyle(
            type_scale.section + 10,
            color=theme.accent_soft_color,
            bold=True,
            align=PP_ALIGN.CENTER,
            fit_text=False,
        ),
    )
    _add_textbox(
        slide,
        1.55,
        1.75,
        slide_width - 3.1,
        2.3,
        spec.quote or "",
        theme,
        TextBoxStyle(
            type_scale.quote,
            bold=True,
            color=theme.text_color,
            align=PP_ALIGN.CENTER,
            fit_text=False,
        ),
    )
    if spec.attribution:
        _add_textbox(
            slide,
            3.0,
            4.55,
            slide_width - 6.0,
            0.45,
            spec.attribution,
            theme,
            TextBoxStyle(
                type_scale.subtitle,
                color=theme.muted_text_color,
                align=PP_ALIGN.CENTER,
                fit_text=False,
            ),
        )


def _render_image_text_slide(
    prs,
    slide,
    spec: SlideSpec,
    theme: ResolvedTheme,
    design: DesignSpec,
    image_assets: dict[str, PptxImageAsset],
):
    """Render an editorial image-plus-text slide from a previously resolved image asset.

    This renderer is where the YAML-level `image_text.asset_id` becomes a real embedded picture. The layout rules
    intentionally stay deterministic so revisions can preserve visuals without reinterpreting the original prompt.
    """
    slide_width, slide_height = _slide_dimensions(prs)
    spacing = _resolve_spacing(design)
    type_scale = _resolve_type_scale(design)
    _apply_background(prs, slide, theme, design)
    content: ImageTextSpec = spec.image_text
    if content.asset_id is None:
        raise ValueError("Image text slides require image_text.asset_id when image assets are provided")
    image_asset = _require_image_asset(image_assets, content.asset_id)

    top = spacing.outer_y
    if spec.title:
        _add_textbox(
            slide,
            spacing.outer_x,
            top,
            slide_width - (2 * spacing.outer_x),
            0.72,
            spec.title,
            theme,
            TextBoxStyle(type_scale.title, bold=True, fit_text=False),
        )
        top += 0.95

    if content.position == "bottom":
        image_height = 3.55
        image_top = top + 0.1
        body_top = image_top + image_height + 0.3
        body_height = max(1.0, slide_height - body_top - spacing.outer_y)

        _add_picture_contain(
            slide,
            image_asset,
            spacing.outer_x,
            image_top,
            slide_width - (2 * spacing.outer_x),
            image_height,
        )
        if content.title:
            _add_textbox(
                slide,
                spacing.outer_x,
                body_top,
                slide_width - (2 * spacing.outer_x),
                0.45,
                content.title,
                theme,
                TextBoxStyle(type_scale.card_title, bold=True, fit_text=False),
            )
            body_top += 0.48
            body_height = max(0.72, body_height - 0.48)
        _add_textbox(
            slide,
            spacing.outer_x,
            body_top,
            slide_width - (2 * spacing.outer_x),
            body_height,
            content.body,
            theme,
            TextBoxStyle(type_scale.body, color=theme.muted_text_color),
        )
        return

    image_width = 4.7
    image_left = spacing.outer_x if content.position == "left" else slide_width - spacing.outer_x - image_width
    text_left = spacing.outer_x + image_width + 0.55 if content.position == "left" else spacing.outer_x
    text_width = (
        slide_width - text_left - spacing.outer_x
        if content.position == "left"
        else slide_width - spacing.outer_x - image_width - 0.55 - spacing.outer_x
    )
    content_block_top = _center_block_vertically(
        content_top=top, block_height=4.17, slide_height=slide_height, bottom_margin=0.9
    )

    _add_picture_contain(
        slide,
        image_asset,
        image_left,
        content_block_top + 0.12,
        image_width,
        4.05,
    )
    if content.title:
        _add_textbox(
            slide,
            text_left,
            content_block_top + 0.34,
            text_width,
            0.62,
            content.title,
            theme,
            TextBoxStyle(type_scale.card_title, bold=True, fit_text=False),
        )
    _add_textbox(
        slide,
        text_left,
        content_block_top + 1.02,
        text_width,
        3.0,
        content.body,
        theme,
        TextBoxStyle(type_scale.body, color=theme.muted_text_color),
    )


def _render_image_slide(
    prs,
    slide,
    spec: SlideSpec,
    theme: ResolvedTheme,
    design: DesignSpec,
    image_assets: dict[str, PptxImageAsset],
):
    """Render a mostly-visual slide that contains one source image at the largest contained size."""

    slide_width, slide_height = _slide_dimensions(prs)
    spacing = _resolve_spacing(design)
    type_scale = _resolve_type_scale(design)
    _apply_background(prs, slide, theme, design)
    if spec.image is None:
        raise ValueError("Image slides require image content")
    image_asset = _require_image_asset(image_assets, spec.image.asset_id)

    top = spacing.outer_y
    if spec.title:
        _add_textbox(
            slide,
            spacing.outer_x,
            top,
            slide_width - (2 * spacing.outer_x),
            0.72,
            spec.title,
            theme,
            TextBoxStyle(type_scale.title, bold=True, fit_text=False),
        )
        top += 0.95

    image_height = slide_height - top - spacing.outer_y
    _add_picture_contain(
        slide,
        image_asset,
        spacing.outer_x,
        top,
        slide_width - (2 * spacing.outer_x),
        image_height,
    )


def _render_custom_slide(prs, slide, spec: SlideSpec, theme: ResolvedTheme, design: DesignSpec):
    _apply_background(prs, slide, theme, design)
    for element in spec.elements:
        if element.type == "text":
            text_color = element.line_color or theme.text_color
            _add_textbox(
                slide,
                element.x,
                element.y,
                element.w,
                element.h,
                element.text or "",
                theme,
                TextBoxStyle(
                    element.font_size or _resolve_type_scale(design).body,
                    color=text_color,
                    bold=(element.font_size or 18) >= 24,
                    fit_text=(element.font_size or 18) < 24,
                ),
            )
            continue
        shape_type = {
            "rect": MSO_AUTO_SHAPE_TYPE.RECTANGLE,
            "rounded_rect": MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE,
            "line": MSO_AUTO_SHAPE_TYPE.RECTANGLE,
        }.get(element.shape or "rect", MSO_AUTO_SHAPE_TYPE.RECTANGLE)
        shape = slide.shapes.add_shape(
            shape_type, Inches(element.x), Inches(element.y), Inches(element.w), Inches(element.h)
        )
        _set_fill(shape, element.fill_color or theme.surface_color)
        _set_line(shape, element.line_color or theme.border_color, width=0.012)


RENDERERS: dict[str, Callable] = {
    "title": _render_title_slide,
    "section": _render_section_slide,
    "columns": _render_columns_slide,
    "comparison": _render_comparison_slide,
    "timeline": _render_timeline_slide,
    "kpi_grid": _render_kpi_grid_slide,
    "quote": _render_quote_slide,
    "image_text": _render_image_text_slide,
    "image": _render_image_slide,
    "custom": _render_custom_slide,
}


def _require_image_asset(image_assets: dict[str, PptxImageAsset], asset_id: str) -> PptxImageAsset:
    """Resolve one deck-local image asset id or fail with an explicit rendering error."""

    image_asset = image_assets.get(asset_id)
    if image_asset is None:
        raise ValueError(f"Unknown image asset '{asset_id}'")
    return image_asset


def _add_picture_contain(
    slide,
    image_asset: PptxImageAsset,
    left: float,
    top: float,
    width: float,
    height: float,
) -> None:
    """Embed an image asset into a bounding box without cropping and with aspect ratio preserved."""

    try:
        image_bytes = base64.b64decode(image_asset.data_base64)
        image_buffer, image_width_px, image_height_px = _prepare_powerpoint_picture(image_bytes)
    except Exception as exc:
        raise ValueError(f"Image asset '{image_asset.asset_id}' could not be decoded as a valid image") from exc
    if image_width_px <= 0 or image_height_px <= 0:
        raise ValueError(f"Image asset '{image_asset.asset_id}' has invalid dimensions")

    scale = min(width / image_width_px, height / image_height_px)
    rendered_width = image_width_px * scale
    rendered_height = image_height_px * scale
    rendered_left = left + ((width - rendered_width) / 2)
    rendered_top = top + ((height - rendered_height) / 2)
    slide.shapes.add_picture(
        image_buffer,
        Inches(rendered_left),
        Inches(rendered_top),
        width=Inches(rendered_width),
        height=Inches(rendered_height),
    )


def _prepare_powerpoint_picture(image_bytes: bytes) -> tuple[io.BytesIO, int, int]:
    """Normalize raw image bytes into a stream that `python-pptx` can safely embed.

    PowerPoint accepts only a narrow set of raster formats. Unsupported but readable inputs such as WEBP are
    transcoded to PNG here so the rest of the renderer can stay format-agnostic.
    """
    with Image.open(io.BytesIO(image_bytes)) as image:
        image_width_px, image_height_px = image.size
        image_format = (image.format or "").upper()
        if image_format not in {"BMP", "GIF", "JPEG", "PNG", "TIFF", "WMF"}:
            converted_buffer = io.BytesIO()
            converted_image = image.convert("RGBA" if image.mode in {"RGBA", "LA", "P"} else "RGB")
            converted_image.save(converted_buffer, format="PNG")
            converted_buffer.seek(0)
            return converted_buffer, image_width_px, image_height_px

    passthrough_buffer = io.BytesIO(image_bytes)
    passthrough_buffer.seek(0)
    return passthrough_buffer, image_width_px, image_height_px
