"""SuperTheme Package Emitter.

Generates Microsoft PowerPoint SuperThemes with multiple design variants across
different aspect ratios. SuperThemes allow seamless theme switching in PowerPoint
with different color schemes, fonts, and slide dimensions.

A SuperTheme is an OPC (Open Packaging Conventions) archive containing:
- [Content_Types].xml - Content type definitions
- _rels/.rels - Root relationships
- themeVariants/themeVariantManager.xml - Microsoft SuperTheme variant manager
- themeVariants/_rels/themeVariantManager.xml.rels - Variant relationships
- themeVariants/variant{n}/ - Individual variant packages
- theme/ - Default theme (first variant)

XML templates are the Single Source of Truth (SSOT) for document structure.
This emitter loads templates and modifies attribute values via template
substitution - it never constructs new XML elements programmatically.

Based on BrandWares SuperTheme research and archive-stylestack-v1 implementation.
"""

from __future__ import annotations

import hashlib
import io
import zipfile
from dataclasses import dataclass
from pathlib import Path
from string import Template
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple

from lxml import etree

from tokenmoulds.emitters import TemplateResolver
from tokenmoulds.emitters.xml_helpers import (
    A_NS,
    THEMEML_NS,
    create_effect_style,
    find_element,
    find_elements,
    normalize_hex_color,
    parse_xml,
    serialize_tree,
    set_color_slot,
    set_color_value,
    set_font_typeface,
    set_scheme_name,
    set_theme_name,
)
from tokenmoulds.design.page_geometry import SlideGrid, create_slide_grid
from tokenmoulds.design.units import pt_to_emu
from tokenmoulds.themes.aspect_ratios import ASPECT_RATIOS, AspectRatio
from tokenmoulds.themes.thumbnail_generator import (
    generate_thumbnails_from_pptx,
    is_soffice_available,
)


def _create_placeholder_jpeg(
    width: int, height: int, color: Tuple[int, int, int]
) -> bytes:
    """Create a minimal placeholder JPEG image with a solid color.

    This creates a valid JPEG without requiring PIL/Pillow by using a
    simple baseline JPEG structure with a solid color fill.

    Args:
        width: Image width in pixels
        height: Image height in pixels
        color: RGB tuple (r, g, b) where each value is 0-255

    Returns:
        JPEG image data as bytes
    """
    # For simplicity, use PIL if available, otherwise create a minimal JPEG
    try:
        from PIL import Image

        img = Image.new("RGB", (width, height), color)
        buffer = io.BytesIO()
        img.save(buffer, format="JPEG", quality=85)
        buffer.seek(0)
        return buffer.getvalue()
    except ImportError:
        # Fall back to a pre-generated minimal 1x1 JPEG (solid gray)
        # This is a valid minimal JPEG that can be used as placeholder
        # Real thumbnails would require PIL for proper generation
        return _MINIMAL_JPEG_PLACEHOLDER


# Minimal valid JPEG - 1x1 gray pixel (used as fallback if PIL unavailable)
_MINIMAL_JPEG_PLACEHOLDER = bytes(
    [
        0xFF,
        0xD8,
        0xFF,
        0xE0,
        0x00,
        0x10,
        0x4A,
        0x46,
        0x49,
        0x46,
        0x00,
        0x01,
        0x01,
        0x00,
        0x00,
        0x01,
        0x00,
        0x01,
        0x00,
        0x00,
        0xFF,
        0xDB,
        0x00,
        0x43,
        0x00,
        0x08,
        0x06,
        0x06,
        0x07,
        0x06,
        0x05,
        0x08,
        0x07,
        0x07,
        0x07,
        0x09,
        0x09,
        0x08,
        0x0A,
        0x0C,
        0x14,
        0x0D,
        0x0C,
        0x0B,
        0x0B,
        0x0C,
        0x19,
        0x12,
        0x13,
        0x0F,
        0x14,
        0x1D,
        0x1A,
        0x1F,
        0x1E,
        0x1D,
        0x1A,
        0x1C,
        0x1C,
        0x20,
        0x24,
        0x2E,
        0x27,
        0x20,
        0x22,
        0x2C,
        0x23,
        0x1C,
        0x1C,
        0x28,
        0x37,
        0x29,
        0x2C,
        0x30,
        0x31,
        0x34,
        0x34,
        0x34,
        0x1F,
        0x27,
        0x39,
        0x3D,
        0x38,
        0x32,
        0x3C,
        0x2E,
        0x33,
        0x34,
        0x32,
        0xFF,
        0xC0,
        0x00,
        0x0B,
        0x08,
        0x00,
        0x01,
        0x00,
        0x01,
        0x01,
        0x01,
        0x11,
        0x00,
        0xFF,
        0xC4,
        0x00,
        0x1F,
        0x00,
        0x00,
        0x01,
        0x05,
        0x01,
        0x01,
        0x01,
        0x01,
        0x01,
        0x01,
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
        0x00,
        0x01,
        0x02,
        0x03,
        0x04,
        0x05,
        0x06,
        0x07,
        0x08,
        0x09,
        0x0A,
        0x0B,
        0xFF,
        0xC4,
        0x00,
        0xB5,
        0x10,
        0x00,
        0x02,
        0x01,
        0x03,
        0x03,
        0x02,
        0x04,
        0x03,
        0x05,
        0x05,
        0x04,
        0x04,
        0x00,
        0x00,
        0x01,
        0x7D,
        0x01,
        0x02,
        0x03,
        0x00,
        0x04,
        0x11,
        0x05,
        0x12,
        0x21,
        0x31,
        0x41,
        0x06,
        0x13,
        0x51,
        0x61,
        0x07,
        0x22,
        0x71,
        0x14,
        0x32,
        0x81,
        0x91,
        0xA1,
        0x08,
        0x23,
        0x42,
        0xB1,
        0xC1,
        0x15,
        0x52,
        0xD1,
        0xF0,
        0x24,
        0x33,
        0x62,
        0x72,
        0x82,
        0x09,
        0x0A,
        0x16,
        0x17,
        0x18,
        0x19,
        0x1A,
        0x25,
        0x26,
        0x27,
        0x28,
        0x29,
        0x2A,
        0x34,
        0x35,
        0x36,
        0x37,
        0x38,
        0x39,
        0x3A,
        0x43,
        0x44,
        0x45,
        0x46,
        0x47,
        0x48,
        0x49,
        0x4A,
        0x53,
        0x54,
        0x55,
        0x56,
        0x57,
        0x58,
        0x59,
        0x5A,
        0x63,
        0x64,
        0x65,
        0x66,
        0x67,
        0x68,
        0x69,
        0x6A,
        0x73,
        0x74,
        0x75,
        0x76,
        0x77,
        0x78,
        0x79,
        0x7A,
        0x83,
        0x84,
        0x85,
        0x86,
        0x87,
        0x88,
        0x89,
        0x8A,
        0x92,
        0x93,
        0x94,
        0x95,
        0x96,
        0x97,
        0x98,
        0x99,
        0x9A,
        0xA2,
        0xA3,
        0xA4,
        0xA5,
        0xA6,
        0xA7,
        0xA8,
        0xA9,
        0xAA,
        0xB2,
        0xB3,
        0xB4,
        0xB5,
        0xB6,
        0xB7,
        0xB8,
        0xB9,
        0xBA,
        0xC2,
        0xC3,
        0xC4,
        0xC5,
        0xC6,
        0xC7,
        0xC8,
        0xC9,
        0xCA,
        0xD2,
        0xD3,
        0xD4,
        0xD5,
        0xD6,
        0xD7,
        0xD8,
        0xD9,
        0xDA,
        0xE1,
        0xE2,
        0xE3,
        0xE4,
        0xE5,
        0xE6,
        0xE7,
        0xE8,
        0xE9,
        0xEA,
        0xF1,
        0xF2,
        0xF3,
        0xF4,
        0xF5,
        0xF6,
        0xF7,
        0xF8,
        0xF9,
        0xFA,
        0xFF,
        0xDA,
        0x00,
        0x08,
        0x01,
        0x01,
        0x00,
        0x00,
        0x3F,
        0x00,
        0xFB,
        0xD3,
        0x28,
        0xA2,
        0x80,
        0x0A,
        0x28,
        0xA0,
        0x02,
        0x8A,
        0x28,
        0x00,
        0xFF,
        0xD9,
    ]
)

# Use shared namespace constants from xml_helpers
_DML_NS = A_NS
_THEMEML_NS_LOCAL = THEMEML_NS  # Local alias for backwards compatibility
_THEME1_SUFFIX = "theme/theme/theme1.xml"

if TYPE_CHECKING:
    from tokenmoulds.ir import ColorTheme, TypographyScheme
    from tokenmoulds.ir.style_primitives import Effects


@dataclass
class SuperThemeVariant:
    """A single SuperTheme variant with aspect ratio and design settings."""

    name: str
    design_name: str
    aspect_ratio: AspectRatio
    variant_id: int
    guid: str

    # Theme colors (hex without #)
    dk1: str = "000000"
    lt1: str = "FFFFFF"
    dk2: str = "44546A"
    lt2: str = "E7E6E6"
    accent1: str = "4472C4"
    accent2: str = "ED7D31"
    accent3: str = "A5A5A5"
    accent4: str = "FFC000"
    accent5: str = "5B9BD5"
    accent6: str = "70AD47"
    hlink: str = "0563C1"
    folHlink: str = "954F72"

    # Fonts
    major_font: str = "Inter"
    minor_font: str = "Inter"

    # Effect styles (3 styles for subtle, moderate, intense)
    effect_styles: Optional[List["Effects"]] = None

    @property
    def width_emu(self) -> int:
        """Slide width in EMU."""
        return self.aspect_ratio.width_emu

    @property
    def height_emu(self) -> int:
        """Slide height in EMU."""
        return self.aspect_ratio.height_emu

    @property
    def slide_type(self) -> str:
        """PowerPoint slide type identifier."""
        # Map common aspect ratios to PowerPoint slide types
        key = self.aspect_ratio.key
        type_map = {
            "16_9": "screen16x9",
            "16_10": "screen16x10",
            "4_3": "screen4x3",
            "a4_landscape": "A4",
            "a4_portrait": "A4",
            "letter_landscape": "letter",
            "letter_portrait": "letter",
        }
        return type_map.get(key, "custom")


@dataclass
class DesignVariant:
    """A design variant with colors and fonts that can be applied to multiple aspect ratios."""

    name: str

    # Theme colors (hex without #)
    dk1: str = "000000"
    lt1: str = "FFFFFF"
    dk2: str = "44546A"
    lt2: str = "E7E6E6"
    accent1: str = "4472C4"
    accent2: str = "ED7D31"
    accent3: str = "A5A5A5"
    accent4: str = "FFC000"
    accent5: str = "5B9BD5"
    accent6: str = "70AD47"
    hlink: str = "0563C1"
    folHlink: str = "954F72"

    # Fonts
    major_font: str = "Inter"
    minor_font: str = "Inter"

    # Effect styles (3 styles for subtle, moderate, intense)
    effect_styles: Optional[List["Effects"]] = None

    @classmethod
    def from_color_theme(
        cls,
        name: str,
        color_theme: ColorTheme,
        typography: Optional[TypographyScheme] = None,
    ) -> DesignVariant:
        """Create DesignVariant from IR models."""

        def strip_hash(color: str) -> str:
            return color.lstrip("#").upper()

        return cls(
            name=name,
            dk1=strip_hash(color_theme.dk1),
            lt1=strip_hash(color_theme.lt1),
            dk2=strip_hash(color_theme.dk2),
            lt2=strip_hash(color_theme.lt2),
            accent1=strip_hash(color_theme.accent1),
            accent2=strip_hash(color_theme.accent2),
            accent3=strip_hash(color_theme.accent3),
            accent4=strip_hash(color_theme.accent4),
            accent5=strip_hash(color_theme.accent5),
            accent6=strip_hash(color_theme.accent6),
            hlink=strip_hash(color_theme.hyperlink),
            folHlink=strip_hash(color_theme.hyperlink_followed),
            major_font=typography.heading_font if typography else "Inter",
            minor_font=typography.body_font if typography else "Inter",
        )


class SuperThemeEmitter:
    """Emitter for Microsoft PowerPoint SuperTheme packages.

    Uses SSOT templates from xml-structures/ecma-376/supertheme/templates/.
    XML structure comes from templates; Python only substitutes values.

    Example:
        # Create with design variants and aspect ratios
        emitter = SuperThemeEmitter()
        emitter.add_design_variant(DesignVariant(
            name="Corporate Blue",
            accent1="0066CC",
            accent2="FF6600",
        ))
        emitter.add_aspect_ratios(["16_9", "a4_landscape", "a4_portrait"])
        data = emitter.emit()  # Returns bytes
    """

    # Standard slide layout types in PowerPoint
    SLIDE_LAYOUT_TYPES = [
        ("title", "Title Slide"),
        ("obj", "Title and Content"),
        ("secHead", "Section Header"),
        ("twoObj", "Two Content"),
        ("twoTxTwoObj", "Comparison"),
        ("titleOnly", "Title Only"),
        ("blank", "Blank"),
        ("objTx", "Content with Caption"),
        ("picTx", "Picture with Caption"),
        ("vertTx", "Vertical Text"),
        ("vertTitleAndTx", "Vertical Title and Text"),
    ]

    def __init__(
        self,
        template_root: str | Path | None = None,
        use_soffice_thumbnails: bool = False,
    ) -> None:
        """Initialize SuperTheme emitter.

        Args:
            template_root: Optional custom template root directory
            use_soffice_thumbnails: If True, use LibreOffice/soffice to generate
                real theme thumbnails by rendering a sample PPTX. Requires
                LibreOffice to be installed. Falls back to placeholder thumbnails
                if soffice is not available.
        """
        self.resolver = TemplateResolver("supertheme", template_root)
        self.thmx_resolver = TemplateResolver("thmx", template_root)
        self.pptx_resolver = TemplateResolver("presentationml", template_root)
        self.design_variants: List[DesignVariant] = []
        self.aspect_ratio_keys: List[str] = []
        self._guid_cache: Dict[str, str] = {}
        self._use_soffice_thumbnails = use_soffice_thumbnails
        self._soffice_available: Optional[bool] = None
        self.slide_regions: Optional[Dict[str, Any]] = None

    def add_design_variant(self, variant: DesignVariant) -> None:
        """Add a design variant to the SuperTheme."""
        self.design_variants.append(variant)

    def add_aspect_ratios(self, keys: List[str]) -> None:
        """Add aspect ratios by key (e.g., '16_9', 'a4_landscape').

        Args:
            keys: List of aspect ratio keys from ASPECT_RATIOS
        """
        for key in keys:
            if key not in ASPECT_RATIOS:
                raise ValueError(f"Unknown aspect ratio: {key}")
            if key not in self.aspect_ratio_keys:
                self.aspect_ratio_keys.append(key)

    def _read_template(self, template_name: str) -> str:
        """Read template content from resolver."""
        return self.resolver.read_text(template_name)

    def _read_thmx_template_tree(self, template_name: str) -> etree._Element:
        """Read and parse thmx template as lxml tree (cached)."""
        return self.thmx_resolver.read_tree(template_name)

    def _get_design_guid(self, design_name: str) -> str:
        """Get or generate consistent GUID for design variant group."""
        if design_name not in self._guid_cache:
            hash_input = f"tokenmoulds-design-{design_name}".encode("utf-8")
            hash_hex = hashlib.md5(hash_input).hexdigest()
            guid = f"{{{hash_hex[:8]}-{hash_hex[8:12]}-{hash_hex[12:16]}-{hash_hex[16:20]}-{hash_hex[20:32]}}}"
            self._guid_cache[design_name] = guid.upper()
        return self._guid_cache[design_name]

    def _build_variants(self) -> List[SuperThemeVariant]:
        """Build all SuperTheme variants (design × aspect ratio).

        Variants are ordered BY SIZE FIRST to match Microsoft's convention:
        - All designs at size 1 (e.g., 16:9)
        - All designs at size 2 (e.g., 4:3)
        - etc.

        This ordering is important for PowerPoint's Variants gallery, which
        displays color variants for the current slide size.
        """
        variants = []
        variant_id = 1

        # Microsoft orders by size first, then by design
        for ar_key in self.aspect_ratio_keys:
            aspect_ratio = ASPECT_RATIOS[ar_key]

            for design in self.design_variants:
                guid = self._get_design_guid(design.name)
                variant_name = f"{design.name} {aspect_ratio.name}"

                variant = SuperThemeVariant(
                    name=variant_name,
                    design_name=design.name,
                    aspect_ratio=aspect_ratio,
                    variant_id=variant_id,
                    guid=guid,
                    dk1=design.dk1,
                    lt1=design.lt1,
                    dk2=design.dk2,
                    lt2=design.lt2,
                    accent1=design.accent1,
                    accent2=design.accent2,
                    accent3=design.accent3,
                    accent4=design.accent4,
                    accent5=design.accent5,
                    accent6=design.accent6,
                    hlink=design.hlink,
                    folHlink=design.folHlink,
                    major_font=design.major_font,
                    minor_font=design.minor_font,
                    effect_styles=design.effect_styles,
                )
                variants.append(variant)
                variant_id += 1

        return variants

    def _build_theme_xml(self, variant: SuperThemeVariant) -> bytes:
        """Build theme XML for a variant using xml_helpers.

        Uses cached parsed tree from TemplateResolver for performance.
        """
        root = self._read_thmx_template_tree("theme.xml")

        # Set theme and scheme names
        set_theme_name(root, f"{variant.design_name} Theme")
        set_scheme_name(root, "clrScheme", f"{variant.design_name} Colors")
        set_scheme_name(root, "fontScheme", f"{variant.design_name} Fonts")
        set_scheme_name(root, "fmtScheme", f"{variant.design_name} Effects")

        # Set colors using xml_helpers (handles sysClr/srgbClr automatically)
        set_color_value(root, "dk1", variant.dk1)
        set_color_value(root, "lt1", variant.lt1)
        set_color_value(root, "dk2", variant.dk2)
        set_color_value(root, "lt2", variant.lt2)
        set_color_value(root, "accent1", variant.accent1)
        set_color_value(root, "accent2", variant.accent2)
        set_color_value(root, "accent3", variant.accent3)
        set_color_value(root, "accent4", variant.accent4)
        set_color_value(root, "accent5", variant.accent5)
        set_color_value(root, "accent6", variant.accent6)
        set_color_value(root, "hlink", variant.hlink)
        set_color_value(root, "folHlink", variant.folHlink)

        # Set font schemes
        set_font_typeface(root, "majorFont", "latin", variant.major_font)
        set_font_typeface(root, "minorFont", "latin", variant.minor_font)

        # Apply effect styles if provided
        if variant.effect_styles and len(variant.effect_styles) == 3:
            self._apply_effect_styles(root, variant.effect_styles)

        return serialize_tree(root)

    def _apply_effect_styles(
        self,
        root: etree._Element,
        effect_styles: "List[Effects]",
    ) -> None:
        """Apply effect styles to theme fmtScheme effectStyleLst.

        OOXML themes have exactly 3 effect styles: subtle, moderate, intense.
        The effect styles are defined in the effectStyleLst within fmtScheme.

        Args:
            root: Theme XML root element
            effect_styles: List of exactly 3 Effects (subtle, moderate, intense)
        """
        if len(effect_styles) != 3:
            return

        # Find effectStyleLst in fmtScheme
        ns = {"a": A_NS}
        effect_lst = find_element(root, ".//a:effectStyleLst", ns)
        if effect_lst is None:
            return

        # Remove existing effect styles
        for child in list(effect_lst):
            effect_lst.remove(child)

        # Add new effect styles
        for effects in effect_styles:
            create_effect_style(effect_lst, effects)

    def _build_presentation_xml(self, variant: SuperThemeVariant) -> str:
        """Build presentation XML for a variant."""
        template = self._read_template("presentation.xml")
        return Template(template).safe_substitute(
            WIDTH_EMU=str(variant.width_emu),
            HEIGHT_EMU=str(variant.height_emu),
            SLIDE_TYPE=variant.slide_type,
        )

    def _hex_to_rgb(self, hex_color: str) -> Tuple[int, int, int]:
        """Convert hex color string to RGB tuple."""
        hex_color = hex_color.lstrip("#").upper()
        return (
            int(hex_color[0:2], 16),
            int(hex_color[2:4], 16),
            int(hex_color[4:6], 16),
        )

    def _check_soffice_available(self) -> bool:
        """Check if soffice/LibreOffice is available (cached)."""
        if self._soffice_available is None:
            self._soffice_available = is_soffice_available()
        return self._soffice_available

    def _create_sample_pptx_for_thumbnail(
        self,
        variant: SuperThemeVariant,
        output_path: Path,
    ) -> bool:
        """Create a sample PPTX with the variant's theme for thumbnail capture.

        Args:
            variant: The SuperTheme variant with colors/fonts
            output_path: Path to write the sample PPTX

        Returns:
            True if successful, False otherwise
        """
        try:
            from pptx import Presentation  # type: ignore[import-not-found]
            from pptx.dml.color import RGBColor  # type: ignore[import-not-found]
            from pptx.enum.shapes import MSO_SHAPE  # type: ignore[import-not-found]
            from pptx.util import Emu, Inches, Pt  # type: ignore[import-not-found]

            prs = Presentation()

            # Set slide size to match variant
            prs.slide_width = Emu(variant.width_emu)
            prs.slide_height = Emu(variant.height_emu)

            # Add a blank slide
            slide_layout = prs.slide_layouts[6]  # Blank layout
            slide = prs.slides.add_slide(slide_layout)

            # Add a large rectangle with accent1 color as header
            color_rgb = self._hex_to_rgb(variant.accent1)
            sw = int(prs.slide_width or 0)
            sh = int(prs.slide_height or 0)

            shape = slide.shapes.add_shape(
                MSO_SHAPE.RECTANGLE, Emu(0), Emu(0), Emu(sw), Emu(int(sh * 0.33))
            )
            shape.fill.solid()
            shape.fill.fore_color.rgb = RGBColor(*color_rgb)
            shape.line.fill.background()

            # Add title text in lt1 color
            lt1_rgb = self._hex_to_rgb(variant.lt1)
            textbox_width = Emu(sw - int(Inches(1)))

            textbox = slide.shapes.add_textbox(
                Inches(0.5), Inches(0.3), textbox_width, Inches(1.5)
            )
            tf = textbox.text_frame
            p = tf.paragraphs[0]
            p.text = variant.design_name
            p.font.size = Pt(44)
            p.font.bold = True
            p.font.color.rgb = RGBColor(*lt1_rgb)
            p.font.name = variant.major_font

            # Add content area with accent color blocks
            content_top = int(sh * 0.40)
            content_height = int(sh * 0.35)
            block_width = int((sw - int(Inches(2))) / 3)

            # Three accent color blocks
            accent_colors = [
                self._hex_to_rgb(variant.accent1),
                self._hex_to_rgb(variant.accent2),
                self._hex_to_rgb(variant.accent3),
            ]

            for i, accent_rgb in enumerate(accent_colors):
                box_left = int(Inches(0.5)) + i * (block_width + int(Inches(0.25)))
                box = slide.shapes.add_shape(
                    MSO_SHAPE.ROUNDED_RECTANGLE,
                    Emu(box_left),
                    Emu(content_top),
                    Emu(block_width),
                    Emu(content_height),
                )
                box.fill.solid()
                box.fill.fore_color.rgb = RGBColor(*accent_rgb)
                box.line.fill.background()

            prs.save(str(output_path))
            return True

        except ImportError:
            # python-pptx not available
            return False
        except Exception:
            return False

    def _generate_thumbnails(self, variant: SuperThemeVariant) -> Dict[str, bytes]:
        """Generate thumbnail images for a variant.

        If soffice thumbnails are enabled and LibreOffice is available,
        generates real thumbnails by creating a sample PPTX and rendering it.
        Otherwise, falls back to placeholder thumbnails.

        Returns a dict mapping filename to JPEG bytes:
        - themeThumbnail.jpeg (64x48)
        - auxiliaryThemeThumbnail.jpeg (85x48)
        - auxiliaryThemeThumbnailMedium.jpeg (124x70)
        - docProps/thumbnail.jpeg (341x192) - package thumbnail
        """
        # Try soffice-based thumbnails if enabled
        if self._use_soffice_thumbnails and self._check_soffice_available():
            try:
                import tempfile

                with tempfile.TemporaryDirectory() as temp_dir:
                    temp_path = Path(temp_dir)
                    pptx_path = temp_path / "sample.pptx"

                    if self._create_sample_pptx_for_thumbnail(variant, pptx_path):
                        thumbnails = generate_thumbnails_from_pptx(pptx_path)
                        if thumbnails:
                            return thumbnails
            except Exception:
                pass  # Fall through to placeholder generation

        # Fall back to placeholder thumbnails
        color = self._hex_to_rgb(variant.accent1)

        return {
            "themeThumbnail.jpeg": _create_placeholder_jpeg(64, 48, color),
            "auxiliaryThemeThumbnail.jpeg": _create_placeholder_jpeg(85, 48, color),
            "auxiliaryThemeThumbnailMedium.jpeg": _create_placeholder_jpeg(
                124, 70, color
            ),
            "thumbnail.jpeg": _create_placeholder_jpeg(341, 192, color),
        }

    def _build_variant_manager(self, variants: List[SuperThemeVariant]) -> str:
        """Build themeVariantManager.xml.

        All variants are listed here. rId1 corresponds to the base theme,
        rId2+ correspond to variant folders.
        """
        variant_template = self._read_template("theme_variant.xml")
        variant_entries = []

        for i, variant in enumerate(variants):
            entry = Template(variant_template).safe_substitute(
                VARIANT_NAME=variant.name,
                VARIANT_GUID=variant.guid,
                WIDTH_EMU=str(variant.width_emu),
                HEIGHT_EMU=str(variant.height_emu),
                REL_ID=f"rId{i + 1}",
            )
            variant_entries.append(entry)

        manager_template = self._read_template("theme_variant_manager.xml")
        return Template(manager_template).safe_substitute(
            THEME_VARIANTS="".join(variant_entries),
        )

    def _build_variant_manager_rels(self, variants: List[SuperThemeVariant]) -> str:
        """Build themeVariantManager.xml.rels.

        The first relationship (rId1) points to the base theme.
        Subsequent relationships point to variant folders (variant1, variant2, etc.).
        """
        relationships = []

        # rId1 points to base theme (used by first variant)
        relationships.append(
            '  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="/theme/theme/themeManager.xml"/>'
        )

        # rId2+ point to variant folders for remaining variants
        for i, variant in enumerate(variants[1:], start=1):
            folder_num = i  # variant1 for first additional variant, etc.
            relationships.append(
                f'  <Relationship Id="rId{i + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="/themeVariants/variant{folder_num}/theme/theme/themeManager.xml"/>'
            )

        rels_template = self._read_template("variant_manager_rels.xml")
        return Template(rels_template).safe_substitute(
            VARIANT_RELATIONSHIPS="\n".join(relationships),
        )

    def _get_slide_layout_xml(
        self,
        layout_index: int,
        layout_type: str,
        layout_name: str,
        slide_grid: SlideGrid | None = None,
        slide_regions: dict[str, Any] | None = None,
    ) -> str:
        """Get slideLayout XML from template or build minimal fallback.

        Uses SSOT templates for layouts 1-9 (slide_layouts/slideLayout{n}.xml).
        Falls back to minimal template for layouts 10-11 (vertTx, vertTitleAndTx).

        When a ``slide_grid`` is provided, all explicit ``<a:xfrm>`` placeholder
        positions are snapped to the baseline grid so that every dimension is a
        clean multiple of the grid module.

        Args:
            layout_index: 1-based layout index
            layout_type: Layout type attribute (e.g., 'title', 'obj')
            layout_name: Layout display name (e.g., 'Title Slide')
            slide_grid: Optional grid for snapping placeholder positions

        Returns:
            Complete slideLayout XML string
        """
        # Use SSOT templates for layouts 1-9
        if layout_index <= 9:
            template_path = f"slide_layouts/slideLayout{layout_index}.xml"
            xml_str = self._read_template(template_path)
            if slide_grid is not None:
                xml_str = self._apply_layout_recipe(
                    xml_str, layout_type, slide_grid, slide_regions
                )
            return xml_str

        # Fallback to minimal template for layouts 10-11 (vertical layouts)
        return f"""<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sldLayout xmlns:p="http://schemas.openxmlformats.org/presentationml/2006/main"
             xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
             xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
             type="{layout_type}" preserve="1">
  <p:cSld name="{layout_name}">
    <p:spTree>
      <p:nvGrpSpPr>
        <p:cNvPr id="1" name=""/>
        <p:cNvGrpSpPr/>
        <p:nvPr/>
      </p:nvGrpSpPr>
      <p:grpSpPr>
        <a:xfrm>
          <a:off x="0" y="0"/>
          <a:ext cx="0" cy="0"/>
          <a:chOff x="0" y="0"/>
          <a:chExt cx="0" cy="0"/>
        </a:xfrm>
      </p:grpSpPr>
    </p:spTree>
  </p:cSld>
  <p:clrMapOvr><a:masterClrMapping/></p:clrMapOvr>
</p:sldLayout>"""

    @staticmethod
    def _apply_layout_recipe(
        xml_str: str,
        layout_type: str,
        grid: SlideGrid,
        slide_regions: dict[str, Any] | None = None,
    ) -> str:
        """Apply layout recipe to compute placeholder positions.

        When *slide_regions* is provided, positions come from the
        token-driven recipes.  Each placeholder is identified by its
        ``<p:ph type="..." idx="..."/>`` attributes and matched against
        the recipe.  Shapes without a recipe entry have their existing
        explicit positions snapped to the grid.
        """
        P_NS = "http://schemas.openxmlformats.org/presentationml/2006/main"
        A_NS_STR = "http://schemas.openxmlformats.org/drawingml/2006/main"
        root = etree.fromstring(xml_str.encode("utf-8"))

        if slide_regions is not None:
            from tokenmoulds.dtcg.layout_recipe_resolver import (
                resolve_for_pptx_layout_type,
            )

            recipe = resolve_for_pptx_layout_type(layout_type, slide_regions, grid)
        else:
            recipe = {}

        for sp in root.iter(f"{{{P_NS}}}sp"):
            # Find placeholder identity
            ph = sp.find(f".//{{{P_NS}}}ph")
            if ph is None:
                continue

            ph_type = ph.get("type")  # None if absent
            ph_idx = ph.get("idx")  # None if absent

            # Find the xfrm element
            sp_pr = sp.find(f"{{{P_NS}}}spPr")
            if sp_pr is None:
                continue
            xfrm = sp_pr.find(f"{{{A_NS_STR}}}xfrm")

            key = (ph_type, ph_idx)
            if key in recipe:
                # Apply formulaic position from recipe
                x, y, cx, cy = recipe[key]

                if xfrm is None:
                    # Create xfrm for placeholders that need explicit pos
                    xfrm = etree.SubElement(sp_pr, f"{{{A_NS_STR}}}xfrm")
                    etree.SubElement(xfrm, f"{{{A_NS_STR}}}off")
                    etree.SubElement(xfrm, f"{{{A_NS_STR}}}ext")

                off = xfrm.find(f"{{{A_NS_STR}}}off")
                ext = xfrm.find(f"{{{A_NS_STR}}}ext")
                if off is None or ext is None:
                    continue

                off.set("x", str(x))
                off.set("y", str(y))
                ext.set("cx", str(cx))
                ext.set("cy", str(cy))

            elif xfrm is not None:
                # No recipe entry — snap existing explicit position to grid
                off = xfrm.find(f"{{{A_NS_STR}}}off")
                ext = xfrm.find(f"{{{A_NS_STR}}}ext")
                if off is None or ext is None:
                    continue
                ox = int(off.get("x", "0"))
                oy = int(off.get("y", "0"))
                ocx = int(ext.get("cx", "0"))
                ocy = int(ext.get("cy", "0"))
                if ocx == 0 and ocy == 0:
                    continue
                nx, ny, ncx, ncy = grid.snap_position(ox, oy, ocx, ocy)
                # Clamp to slide bounds
                if nx + ncx > grid.slide_width_emu:
                    ncx = grid.slide_width_emu - nx
                if ny + ncy > grid.slide_height_emu:
                    ncy = grid.slide_height_emu - ny
                off.set("x", str(nx))
                off.set("y", str(ny))
                ext.set("cx", str(ncx))
                ext.set("cy", str(ncy))

        return etree.tostring(
            root, xml_declaration=True, encoding="UTF-8", standalone=True
        ).decode("utf-8")

    def _build_base_slide_layout_content_types(self) -> str:
        """Build content type entries for base theme slideLayouts."""
        entries = []
        for i in range(1, len(self.SLIDE_LAYOUT_TYPES) + 1):
            entries.append(
                f'  <Override PartName="/theme/slideLayouts/slideLayout{i}.xml" ContentType="application/vnd.openxmlformats-officedocument.presentationml.slideLayout+xml"/>'
            )
        return "\n".join(entries)

    def _build_content_types(self, variants: List[SuperThemeVariant]) -> str:
        """Build [Content_Types].xml.

        Only variant folders for variants[1:] are included (first variant uses base theme).
        """
        ct_template = self._read_template("variant_content_type.xml")
        variant_types = []

        # Only create content types for variant folders (variants[1:])
        for i, variant in enumerate(variants[1:], start=1):
            folder_num = i  # variant1 for first additional variant
            ct = Template(ct_template).safe_substitute(
                VARIANT_ID=str(folder_num),
            )
            variant_types.append(ct)

        types_template = self._read_template("content_types.xml")
        return Template(types_template).safe_substitute(
            BASE_SLIDE_LAYOUT_CONTENT_TYPES=self._build_base_slide_layout_content_types(),
            VARIANT_CONTENT_TYPES="".join(variant_types),
        )

    def emit(self) -> bytes:
        """Generate SuperTheme package as bytes.

        Returns:
            Bytes content of the SuperTheme archive

        Raises:
            ValueError: If no design variants or aspect ratios configured
        """
        if not self.design_variants:
            raise ValueError("No design variants configured")
        if not self.aspect_ratio_keys:
            raise ValueError("No aspect ratios configured")

        variants = self._build_variants()
        buffer = io.BytesIO()

        with zipfile.ZipFile(
            buffer, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
        ) as zf:
            # Core package files
            zf.writestr("[Content_Types].xml", self._build_content_types(variants))
            zf.writestr("_rels/.rels", self._read_template("package_rels.xml"))

            # Theme variant manager
            zf.writestr(
                "themeVariants/themeVariantManager.xml",
                self._build_variant_manager(variants),
            )
            zf.writestr(
                "themeVariants/_rels/themeVariantManager.xml.rels",
                self._build_variant_manager_rels(variants),
            )

            # Pre-compute static templates (reused across default theme + variants)
            theme_manager_xml = self._read_template("theme_manager.xml")
            theme_manager_rels_xml = self._read_template("theme_manager_rels.xml")
            presentation_rels_xml = self._read_template("presentation_rels.xml")
            slide_master_template = self._read_template("slide_master.xml")
            slide_master_rels_xml = self._read_template("slide_master_rels.xml")
            slide_layout_rels = self._read_template("slide_layout_rels.xml")
            variant_rels_xml = self._read_template("variant_rels.xml")

            # Build slide layouts per-aspect-ratio with grid-snapped positions.
            # Different aspect ratios need different placeholder positions.
            _DEFAULT_GRID_PT = 12  # Standard presentation baseline grid
            _grid_emu = pt_to_emu(_DEFAULT_GRID_PT)
            _layout_cache: Dict[str, list[str]] = {}

            def _layouts_for_ar(ar: AspectRatio) -> list[str]:
                if ar.key in _layout_cache:
                    return _layout_cache[ar.key]
                grid = create_slide_grid(ar.width_emu, ar.height_emu, _grid_emu)
                # Use provided regions or compute defaults for this grid
                regions = self.slide_regions
                if regions is None:
                    from tokenmoulds.dtcg.layout_recipe_resolver import (
                        compute_default_slide_regions,
                    )

                    regions = compute_default_slide_regions(grid)
                layouts = [
                    self._get_slide_layout_xml(
                        i,
                        layout_type,
                        layout_name,
                        slide_grid=grid,
                        slide_regions=regions,
                    )
                    for i, (layout_type, layout_name) in enumerate(
                        self.SLIDE_LAYOUT_TYPES, 1
                    )
                ]
                _layout_cache[ar.key] = layouts
                return layouts

            # Default theme (first variant)
            default_variant = variants[0]
            zf.writestr("theme/theme/themeManager.xml", theme_manager_xml)
            zf.writestr(
                "theme/theme/_rels/themeManager.xml.rels", theme_manager_rels_xml
            )
            zf.writestr(
                "theme/theme/theme1.xml", self._build_theme_xml(default_variant)
            )
            zf.writestr(
                "theme/presentation.xml", self._build_presentation_xml(default_variant)
            )
            zf.writestr("theme/_rels/presentation.xml.rels", presentation_rels_xml)
            zf.writestr(
                "theme/slideMasters/slideMaster1.xml",
                Template(slide_master_template).safe_substitute(
                    MASTER_NAME=f"{default_variant.design_name} Master"
                ),
            )
            zf.writestr(
                "theme/slideMasters/_rels/slideMaster1.xml.rels",
                slide_master_rels_xml,
            )

            # Default theme slideLayouts (grid-snapped per aspect ratio)
            default_layouts = _layouts_for_ar(default_variant.aspect_ratio)
            for i, layout_xml in enumerate(default_layouts, 1):
                zf.writestr(f"theme/slideLayouts/slideLayout{i}.xml", layout_xml)
                zf.writestr(
                    f"theme/slideLayouts/_rels/slideLayout{i}.xml.rels",
                    slide_layout_rels,
                )

            # Default theme thumbnails
            default_thumbnails = self._generate_thumbnails(default_variant)
            zf.writestr(
                "theme/theme/themeThumbnail.jpeg",
                default_thumbnails["themeThumbnail.jpeg"],
            )
            zf.writestr(
                "theme/theme/auxiliaryThemeThumbnail.jpeg",
                default_thumbnails["auxiliaryThemeThumbnail.jpeg"],
            )
            zf.writestr(
                "theme/theme/auxiliaryThemeThumbnailMedium.jpeg",
                default_thumbnails["auxiliaryThemeThumbnailMedium.jpeg"],
            )
            zf.writestr("docProps/thumbnail.jpeg", default_thumbnails["thumbnail.jpeg"])

            # Additional variants (variants[1:] - first variant uses base theme)
            # variant_id is 1-based, so variant folder numbering: variant.variant_id - 1
            for variant in variants[1:]:
                folder_num = variant.variant_id - 1  # variant1 for 2nd variant, etc.
                base_dir = f"themeVariants/variant{folder_num}"

                # Variant theme and presentation
                zf.writestr(
                    f"{base_dir}/theme/theme/themeManager.xml", theme_manager_xml
                )
                zf.writestr(
                    f"{base_dir}/theme/theme/_rels/themeManager.xml.rels",
                    theme_manager_rels_xml,
                )
                zf.writestr(
                    f"{base_dir}/theme/theme/theme1.xml", self._build_theme_xml(variant)
                )
                zf.writestr(
                    f"{base_dir}/theme/presentation.xml",
                    self._build_presentation_xml(variant),
                )
                zf.writestr(
                    f"{base_dir}/theme/_rels/presentation.xml.rels",
                    presentation_rels_xml,
                )

                # Variant slide master
                zf.writestr(
                    f"{base_dir}/theme/slideMasters/slideMaster1.xml",
                    Template(slide_master_template).safe_substitute(
                        MASTER_NAME=f"{variant.name} Master"
                    ),
                )
                zf.writestr(
                    f"{base_dir}/theme/slideMasters/_rels/slideMaster1.xml.rels",
                    slide_master_rels_xml,
                )

                # Variant slideLayouts (grid-snapped per aspect ratio)
                variant_layouts = _layouts_for_ar(variant.aspect_ratio)
                for i, layout_xml in enumerate(variant_layouts, 1):
                    zf.writestr(
                        f"{base_dir}/theme/slideLayouts/slideLayout{i}.xml",
                        layout_xml,
                    )
                    zf.writestr(
                        f"{base_dir}/theme/slideLayouts/_rels/slideLayout{i}.xml.rels",
                        slide_layout_rels,
                    )

                # Variant thumbnails
                variant_thumbnails = self._generate_thumbnails(variant)
                zf.writestr(
                    f"{base_dir}/theme/theme/themeThumbnail.jpeg",
                    variant_thumbnails["themeThumbnail.jpeg"],
                )
                zf.writestr(
                    f"{base_dir}/theme/theme/auxiliaryThemeThumbnail.jpeg",
                    variant_thumbnails["auxiliaryThemeThumbnail.jpeg"],
                )
                zf.writestr(
                    f"{base_dir}/theme/theme/auxiliaryThemeThumbnailMedium.jpeg",
                    variant_thumbnails["auxiliaryThemeThumbnailMedium.jpeg"],
                )
                zf.writestr(
                    f"{base_dir}/docProps/thumbnail.jpeg",
                    variant_thumbnails["thumbnail.jpeg"],
                )

                # Variant relationships
                zf.writestr(f"{base_dir}/_rels/.rels", variant_rels_xml)

        buffer.seek(0)
        return buffer.getvalue()

    def save(self, path: str | Path) -> None:
        """Save SuperTheme to disk.

        Args:
            path: Output file path (should end with .thmx)
        """
        path = Path(path)
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_bytes(self.emit())


def create_supertheme(
    design_variants: List[DesignVariant],
    aspect_ratio_keys: List[str],
    output_path: Optional[str | Path] = None,
    use_soffice_thumbnails: bool = False,
) -> bytes:
    """Convenience function to create a SuperTheme.

    Args:
        design_variants: List of design variants
        aspect_ratio_keys: List of aspect ratio keys (e.g., ['16_9', 'a4_landscape'])
        output_path: Optional path to save the SuperTheme
        use_soffice_thumbnails: If True, use LibreOffice to generate real thumbnails

    Returns:
        Bytes content of the SuperTheme archive
    """
    emitter = SuperThemeEmitter(use_soffice_thumbnails=use_soffice_thumbnails)
    for variant in design_variants:
        emitter.add_design_variant(variant)
    emitter.add_aspect_ratios(aspect_ratio_keys)

    data = emitter.emit()

    if output_path:
        emitter.save(output_path)

    return data


def _register_theme_namespaces() -> None:
    """Register namespace prefixes for serialization."""
    etree.register_namespace("a", _DML_NS)
    etree.register_namespace("thm15", THEMEML_NS)


def _parse_theme_variant_vids(manager_xml: str) -> List[str]:
    """Parse variant IDs from themeVariantManager.xml."""
    root = parse_xml(manager_xml)
    ns = {"t": THEMEML_NS}
    vids: List[str] = []
    for node in find_elements(root, ".//t:themeVariant", ns):
        vid = node.attrib.get("vid")
        if vid and vid not in vids:
            vids.append(vid)
    return vids


def _parse_theme_variant_names(manager_xml: str) -> Dict[str, str]:
    """Parse variant names from themeVariantManager.xml."""
    root = parse_xml(manager_xml)
    ns = {"t": THEMEML_NS}
    names: Dict[str, str] = {}
    for node in find_elements(root, ".//t:themeVariant", ns):
        vid = node.attrib.get("vid")
        name = node.attrib.get("name")
        if vid and name and vid not in names:
            names[vid] = name
    return names


def _extract_theme_family_vid(root: etree._Element) -> Optional[str]:
    """Extract theme family VID from theme element."""
    ns = {"thm15": THEMEML_NS}
    family = find_element(root, ".//thm15:themeFamily", ns)
    if family is None:
        return None
    return family.attrib.get("vid")


def _hex_to_rgb(hex_color: str) -> Tuple[int, int, int]:
    """Convert hex color to RGB tuple."""
    value = normalize_hex_color(hex_color)
    if len(value) != 6:
        return (128, 128, 128)
    try:
        return (int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16))
    except ValueError:
        return (128, 128, 128)


def _build_thumbnail_payloads(variant: DesignVariant) -> Dict[str, bytes]:
    color = _hex_to_rgb(variant.accent1)
    return {
        "themeThumbnail.jpeg": _create_placeholder_jpeg(64, 48, color),
        "auxiliaryThemeThumbnail.jpeg": _create_placeholder_jpeg(85, 48, color),
        "auxiliaryThemeThumbnailMedium.jpeg": _create_placeholder_jpeg(124, 70, color),
        "thumbnail.jpeg": _create_placeholder_jpeg(341, 192, color),
    }


def _design_variant_from_theme_xml(theme_xml: str, name: str) -> DesignVariant:
    """Extract DesignVariant from theme XML."""
    root = parse_xml(theme_xml)
    ns = {"a": A_NS}
    defaults = DesignVariant(name=name)
    clr_scheme = find_element(root, ".//a:clrScheme", ns)

    def read_color(slot: str, fallback: str) -> str:
        if clr_scheme is None:
            return fallback
        node = clr_scheme.find(f"a:{slot}", ns)
        if node is None:
            return fallback
        sys = node.find("a:sysClr", ns)
        if sys is not None:
            last = sys.attrib.get("lastClr")
            return normalize_hex_color(last) if last else fallback
        srgb = node.find("a:srgbClr", ns)
        if srgb is not None:
            val = srgb.attrib.get("val")
            return normalize_hex_color(val) if val else fallback
        return fallback

    font_scheme = find_element(root, ".//a:fontScheme", ns)
    major_font = defaults.major_font
    minor_font = defaults.minor_font
    if font_scheme is not None:
        major = font_scheme.find("a:majorFont/a:latin", ns)
        minor = font_scheme.find("a:minorFont/a:latin", ns)
        if major is not None and major.attrib.get("typeface"):
            major_font = major.attrib["typeface"]
        if minor is not None and minor.attrib.get("typeface"):
            minor_font = minor.attrib["typeface"]

    return DesignVariant(
        name=name,
        dk1=read_color("dk1", defaults.dk1),
        lt1=read_color("lt1", defaults.lt1),
        dk2=read_color("dk2", defaults.dk2),
        lt2=read_color("lt2", defaults.lt2),
        accent1=read_color("accent1", defaults.accent1),
        accent2=read_color("accent2", defaults.accent2),
        accent3=read_color("accent3", defaults.accent3),
        accent4=read_color("accent4", defaults.accent4),
        accent5=read_color("accent5", defaults.accent5),
        accent6=read_color("accent6", defaults.accent6),
        hlink=read_color("hlink", defaults.hlink),
        folHlink=read_color("folHlink", defaults.folHlink),
        major_font=str(major_font),
        minor_font=str(minor_font),
    )


def _collect_theme_xml_by_vid(zf: zipfile.ZipFile) -> Dict[str, str]:
    """Collect theme XML indexed by variant ID."""
    theme_xml_by_vid: Dict[str, str] = {}
    for name in zf.namelist():
        if not name.endswith(_THEME1_SUFFIX):
            continue
        theme_xml = zf.read(name).decode("utf-8-sig")
        root = parse_xml(theme_xml)
        vid = _extract_theme_family_vid(root)
        if vid and vid not in theme_xml_by_vid:
            theme_xml_by_vid[vid] = theme_xml
    return theme_xml_by_vid


def extract_design_variants_from_supertheme(path: str | Path) -> List[DesignVariant]:
    """Extract design variants from an existing SuperTheme package."""
    path = Path(path)
    with zipfile.ZipFile(path) as zf:
        manager_xml = zf.read("themeVariants/themeVariantManager.xml").decode(
            "utf-8-sig"
        )
        vid_order = _parse_theme_variant_vids(manager_xml)
        vid_names = _parse_theme_variant_names(manager_xml)
        theme_xml_by_vid = _collect_theme_xml_by_vid(zf)

    variants: List[DesignVariant] = []
    for index, vid in enumerate(vid_order, start=1):
        theme_xml = theme_xml_by_vid.get(vid)
        if theme_xml is None:
            continue
        name = vid_names.get(vid, f"Design {index}")
        variants.append(_design_variant_from_theme_xml(theme_xml, name))
    return variants


def _update_theme_colors(root: etree._Element, variant: DesignVariant) -> None:
    """Update all theme colors from a DesignVariant.

    Uses SSOT pattern: only modifies existing elements, does not create new ones.
    This preserves the structure of user-provided themes when patching.
    """
    ns = {"a": A_NS}
    clr_scheme = find_element(root, ".//a:clrScheme", ns)
    if clr_scheme is None:
        return
    colors = {
        "dk1": variant.dk1,
        "lt1": variant.lt1,
        "dk2": variant.dk2,
        "lt2": variant.lt2,
        "accent1": variant.accent1,
        "accent2": variant.accent2,
        "accent3": variant.accent3,
        "accent4": variant.accent4,
        "accent5": variant.accent5,
        "accent6": variant.accent6,
        "hlink": variant.hlink,
        "folHlink": variant.folHlink,
    }
    for slot_name, value in colors.items():
        slot = clr_scheme.find(f"a:{slot_name}", ns)
        if slot is not None:
            set_color_slot(slot, value)


def _update_theme_fonts(root: etree._Element, variant: DesignVariant) -> None:
    """Update theme fonts from a DesignVariant.

    Uses SSOT pattern: only modifies existing elements, does not create new ones.
    This preserves the structure of user-provided themes when patching.
    """
    ns = {"a": A_NS}
    font_scheme = find_element(root, ".//a:fontScheme", ns)
    if font_scheme is None:
        return

    def update_font(tag: str, font_name: str) -> None:
        group = font_scheme.find(f"a:{tag}", ns)
        if group is None:
            return
        latin = group.find("a:latin", ns)
        if latin is not None:
            latin.set("typeface", font_name)

    update_font("majorFont", variant.major_font)
    update_font("minorFont", variant.minor_font)


def _update_theme_names(root: etree._Element, variant: DesignVariant) -> None:
    """Update theme and scheme names from a DesignVariant."""
    set_theme_name(root, variant.name)
    set_scheme_name(root, "clrScheme", variant.name)
    set_scheme_name(root, "fontScheme", variant.name)
    set_scheme_name(root, "fmtScheme", variant.name)


def _render_theme_xml(root: etree._Element) -> bytes:
    """Render theme XML with proper namespace prefixes."""
    _register_theme_namespaces()
    return serialize_tree(root)


def _apply_variant_to_theme_xml(
    theme_xml: bytes,
    variant_map: Dict[str, DesignVariant],
    *,
    update_names: bool,
) -> bytes:
    """Apply a DesignVariant to theme XML."""
    root = parse_xml(theme_xml.decode("utf-8-sig"))
    vid = _extract_theme_family_vid(root)
    variant = variant_map.get(vid) if vid else None
    if variant is None:
        variant = next(iter(variant_map.values()))
    _update_theme_colors(root, variant)
    _update_theme_fonts(root, variant)
    if update_names:
        _update_theme_names(root, variant)
    return _render_theme_xml(root)


def _map_design_variants_to_vids(
    vids: List[str],
    design_variants: List[DesignVariant],
) -> Dict[str, DesignVariant]:
    if not design_variants:
        raise ValueError("No design variants provided")
    if len(design_variants) == 1:
        return {vid: design_variants[0] for vid in vids}
    if len(design_variants) < len(vids):
        raise ValueError(
            f"Need at least {len(vids)} design variants to patch this SuperTheme"
        )
    return {vid: design_variants[index] for index, vid in enumerate(vids)}


def _collect_variant_by_prefix(
    zf: zipfile.ZipFile, variant_map: Dict[str, DesignVariant]
) -> Dict[str, DesignVariant]:
    """Collect DesignVariant by path prefix for thumbnail generation."""
    default_variant = next(iter(variant_map.values()))
    variants: Dict[str, DesignVariant] = {}
    for name in zf.namelist():
        if not name.endswith(_THEME1_SUFFIX):
            continue
        theme_xml = zf.read(name).decode("utf-8-sig")
        root = parse_xml(theme_xml)
        vid = _extract_theme_family_vid(root)
        if vid is None:
            continue
        variant = variant_map.get(vid, default_variant)
        prefix = name[: -len(_THEME1_SUFFIX)]
        variants[prefix] = variant
    return variants


def _build_thumbnail_replacements(
    zf: zipfile.ZipFile, variant_map: Dict[str, DesignVariant]
) -> Dict[str, bytes]:
    names = set(zf.namelist())
    replacements: Dict[str, bytes] = {}
    for prefix, variant in _collect_variant_by_prefix(zf, variant_map).items():
        thumbnails = _build_thumbnail_payloads(variant)
        theme_prefix = f"{prefix}theme/theme/"
        for key in (
            "themeThumbnail.jpeg",
            "auxiliaryThemeThumbnail.jpeg",
            "auxiliaryThemeThumbnailMedium.jpeg",
        ):
            path = f"{theme_prefix}{key}"
            if path in names:
                replacements[path] = thumbnails[key]
        doc_path = f"{prefix}docProps/thumbnail.jpeg"
        if doc_path in names:
            replacements[doc_path] = thumbnails["thumbnail.jpeg"]
    return replacements


def patch_supertheme(
    base_path: str | Path,
    design_variants: List[DesignVariant],
    output_path: Optional[str | Path] = None,
    *,
    update_theme_names: bool = False,
    update_thumbnails: bool = False,
) -> bytes:
    """Patch an existing SuperTheme with new colors/fonts."""
    base_path = Path(base_path)
    with zipfile.ZipFile(base_path) as zf:
        manager_xml = zf.read("themeVariants/themeVariantManager.xml").decode(
            "utf-8-sig"
        )
        vids = _parse_theme_variant_vids(manager_xml)
        variant_map = _map_design_variants_to_vids(vids, design_variants)
        thumbnail_replacements = (
            _build_thumbnail_replacements(zf, variant_map) if update_thumbnails else {}
        )

        buffer = io.BytesIO()
        with zipfile.ZipFile(
            buffer, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
        ) as out:
            for info in zf.infolist():
                data = zf.read(info.filename)
                if info.filename.endswith(_THEME1_SUFFIX):
                    data = _apply_variant_to_theme_xml(
                        data, variant_map, update_names=update_theme_names
                    )
                elif update_thumbnails and info.filename in thumbnail_replacements:
                    data = thumbnail_replacements[info.filename]
                out.writestr(info.filename, data)

    buffer.seek(0)
    data = buffer.getvalue()
    if output_path:
        output_path = Path(output_path)
        output_path.parent.mkdir(parents=True, exist_ok=True)
        output_path.write_bytes(data)
    return data


def patch_supertheme_from_source(
    base_path: str | Path,
    source_path: str | Path,
    output_path: Optional[str | Path] = None,
    *,
    update_theme_names: bool = False,
    update_thumbnails: bool = False,
) -> bytes:
    """Patch a SuperTheme using design variants extracted from another package."""
    design_variants = extract_design_variants_from_supertheme(source_path)
    return patch_supertheme(
        base_path,
        design_variants,
        output_path=output_path,
        update_theme_names=update_theme_names,
        update_thumbnails=update_thumbnails,
    )


__all__ = [
    "SuperThemeEmitter",
    "SuperThemeVariant",
    "DesignVariant",
    "create_supertheme",
    "extract_design_variants_from_supertheme",
    "patch_supertheme",
    "patch_supertheme_from_source",
    "is_soffice_available",
]
