#!/usr/bin/env python3
"""
PPT Master - PPTX Animation Module

Provides one strict object-animation registry plus OOXML read/write helpers.

Supported transition effects:
    - Complete current PowerPoint gallery: Subtle, Exciting, Dynamic Content
    - Compatibility aliases: strips, circle, diamond, newsflash, plus, pull,
      wedge, wheel
    - none: no visual transition (handled by the shared transition core)

PowerPoint-native object animations:
    - 53 entrance effects (``entrance_*``)
    - 33 emphasis effects (``emphasis_*``)
    - 64 motion paths (``path_*``)
    - 53 exit effects (``exit_*``)

Legacy compatibility inputs (accepted but never selected for new output):
    appear, fade, fly, fly_left, fly_right, fly_top, cut, zoom, wipe,
    wipe_left, wipe_right, wipe_up, wipe_down, split, blinds, checkerboard,
    dissolve, random_bars, peek, wheel, box, circle, diamond, plus, strips,
    wedge, stretch, expand, swivel

The four media commands in MsoAnimEffect are intentionally excluded because
they require audio/video shapes or bookmarks rather than generated SVG groups.

Animation modes used by the builder:
    - single effect name (one of the above) — apply to every element
    - 'auto'   — pick effect from the group's SVG id. Image-like ids
                 (hero / figure- / image / img- / kpi) cycle through a
                 visual pool (``entrance_zoom`` / ``entrance_dissolve`` /
                 ``entrance_circle`` / ``entrance_box`` /
                 ``entrance_diamond`` / ``entrance_wheel``) so multiple
                 images vary across the deck. Other
                 semantic matches map to a single stable effect
                 (chart→``entrance_wipe``,
                 card-/step-/pillar-→``entrance_fly``,
                 title/takeaway→``entrance_fade``).
                 Unmatched ids cycle through a small modern pool
                 (``entrance_fade`` / ``entrance_wipe`` /
                 ``entrance_fly`` / ``entrance_zoom``).
    - 'mixed'  — compatible mode name: first element fades, the rest cycle
                 through a larger canonical PowerPoint entrance pool.
    - 'random' — pick a seeded canonical PowerPoint entrance per element

Generated animation rows are validated against their requested effect, target,
duration, order, and Start mode before a PPTX is published.  Package validation
also checks timing-tree placement, time-node identifiers, and shape references.

See references/animations.md for the public workflow contract.

Dependencies: None (standard-library XML generation and validation)

Usage:
    python3 scripts/pptx_animations.py --demo
    python3 scripts/pptx_animations.py --list
    python3 scripts/pptx_animations.py --describe entrance_fly
"""

import argparse
import copy
import hashlib
import json
import math
import posixpath
import random
import re
import zipfile
from dataclasses import dataclass, replace
from pathlib import Path
from typing import Any, Mapping, Sequence
from xml.etree import ElementTree as ET

from console_encoding import configure_utf8_stdio
from pptx_transitions import (
    LEGACY_TRANSITION_KEYS,
    MAX_OOXML_MILLISECONDS,
    MAX_OOXML_UNSIGNED_INT,
    NATIVE_TRANSITION_KEYS,
    NATIVE_TRANSITIONS,
    PML_NS,
    TRANSITION_ALIAS_OPTIONS,
    TRANSITION_ALIASES,
    TRANSITION_CATEGORIES,
    create_transition_xml,
    describe_transition_effect,
    validate_seconds,
)

configure_utf8_stdio()


# ============================================================================
# Object animation definitions
# ============================================================================

# Compatibility names normalize to canonical PowerPoint-authored presets.
# ``cut`` has no current object-animation preset, so the compatibility name
# resolves to the standard instantaneous entrance, ``entrance_appear``.
ANIMATION_ALIASES: dict[str, str] = {
    'appear': 'entrance_appear',
    'fade': 'entrance_fade',
    'fly': 'entrance_fly',
    'fly_left': 'entrance_fly',
    'fly_right': 'entrance_fly',
    'fly_top': 'entrance_fly',
    'cut': 'entrance_appear',
    'zoom': 'entrance_zoom',
    'wipe': 'entrance_wipe',
    'wipe_left': 'entrance_wipe',
    'wipe_right': 'entrance_wipe',
    'wipe_up': 'entrance_wipe',
    'wipe_down': 'entrance_wipe',
    'split': 'entrance_split',
    'blinds': 'entrance_blinds',
    'checkerboard': 'entrance_checkerboard',
    'dissolve': 'entrance_dissolve',
    'random_bars': 'entrance_random_bars',
    'peek': 'entrance_peek',
    'wheel': 'entrance_wheel',
    'box': 'entrance_box',
    'circle': 'entrance_circle',
    'diamond': 'entrance_diamond',
    'plus': 'entrance_plus',
    'strips': 'entrance_strips',
    'wedge': 'entrance_wedge',
    'stretch': 'entrance_stretch',
    'expand': 'entrance_expand',
    'swivel': 'entrance_swivel',
}

LEGACY_ANIMATION_KEYS = tuple(ANIMATION_ALIASES)
ANIMATION_CATEGORIES = ('entrance', 'emphasis', 'path', 'exit')
_PRESET_CLASS_BY_CATEGORY = {
    'entrance': 'entr',
    'emphasis': 'emph',
    'path': 'path',
    'exit': 'exit',
}
_DML_NS = 'http://schemas.openxmlformats.org/drawingml/2006/main'
_REL_NS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
_PACKAGE_REL_NS = 'http://schemas.openxmlformats.org/package/2006/relationships'
_AUDIO_REL_TYPE = (
    'http://schemas.openxmlformats.org/officeDocument/2006/relationships/audio'
)
_P14_NS = 'http://schemas.microsoft.com/office/powerpoint/2010/main'
_MC_NS = 'http://schemas.openxmlformats.org/markup-compatibility/2006'
ET.register_namespace('p', PML_NS)
ET.register_namespace('a', _DML_NS)
ET.register_namespace('r', _REL_NS)
ET.register_namespace('p14', _P14_NS)

ANIMATION_EFFECT_OPTION_FIELDS = (
    'direction',
    'amount',
    'color',
    'font_name',
    'relative',
    'size',
)
ANIMATION_TIMING_OPTION_FIELDS = (
    'repeat_count',
    'repeat_duration',
    'auto_reverse',
    'rewind',
    'accelerate',
    'decelerate',
    'bounce_end',
    'restart',
)
ANIMATION_RESTARTS = ('always', 'when-not-active', 'never')
ANIMATION_AFTER_EFFECTS = ('none', 'dim', 'hide', 'hide-on-next-click')
_INTERPOLATED_BEHAVIOR_TAGS = frozenset({
    'anim',
    'animClr',
    'animEffect',
    'animMotion',
    'animRot',
    'animScale',
})
_NON_CONCRETE_FONT_NAMES = frozenset({
    '-apple-system',
    'blinkmacsystemfont',
    'cursive',
    'emoji',
    'fantasy',
    'inherit',
    'initial',
    'math',
    'monospace',
    'revert',
    'revert-layer',
    'sans-serif',
    'serif',
    'system-ui',
    'ui-monospace',
    'ui-rounded',
    'ui-sans-serif',
    'ui-serif',
    'unset',
})

# Legacy directional names retain their historical semantics by desugaring
# into one canonical effect plus the matching PowerPoint EffectParameters
# value. New plans never select these aliases.
ANIMATION_ALIAS_OPTIONS: dict[str, dict[str, object]] = {
    'fly_left': {'direction': 'left'},
    'fly_right': {'direction': 'right'},
    'fly_top': {'direction': 'up'},
    'wipe_left': {'direction': 'left'},
    'wipe_right': {'direction': 'right'},
    'wipe_up': {'direction': 'up'},
    'wipe_down': {'direction': 'down'},
    'wheel': {'amount': 4},
}


def _load_native_animations() -> dict[str, dict[str, Any]]:
    """Load the PowerPoint-authored preset rows shipped with this module."""
    manifest_path = Path(__file__).with_name('pptx_animation_presets.json')
    try:
        manifest = json.loads(manifest_path.read_text(encoding='utf-8'))
    except (OSError, json.JSONDecodeError) as exc:
        raise RuntimeError(
            f'unable to load native animation presets from {manifest_path}: {exc}'
        ) from exc
    if manifest.get('version') != 2:
        raise RuntimeError(
            f'unsupported native animation preset version: {manifest.get("version")!r}'
        )
    raw_effects = manifest.get('effects')
    if not isinstance(raw_effects, list):
        raise RuntimeError('native animation preset manifest field "effects" must be a list')

    native: dict[str, dict[str, Any]] = {}
    category_counts = {category: 0 for category in ANIMATION_CATEGORIES}
    for raw in raw_effects:
        if not isinstance(raw, dict):
            raise RuntimeError('native animation preset entries must be objects')
        key = raw.get('key')
        category = raw.get('category')
        if not isinstance(key, str) or not key:
            raise RuntimeError(f'native animation preset has invalid key: {key!r}')
        if category not in ANIMATION_CATEGORIES:
            raise RuntimeError(
                f'native animation preset {key!r} has invalid category: {category!r}'
            )
        if key in native or key in ANIMATION_ALIASES:
            raise RuntimeError(f'duplicate animation preset key: {key}')
        row_xml = raw.get('row_xml')
        if not isinstance(row_xml, str):
            raise RuntimeError(f'native animation preset {key!r} is missing row_xml')
        try:
            row = ET.fromstring(row_xml)
        except ET.ParseError as exc:
            raise RuntimeError(
                f'native animation preset {key!r} contains invalid row_xml: {exc}'
            ) from exc
        if row.tag != f'{{{PML_NS}}}cTn':
            raise RuntimeError(f'native animation preset {key!r} is not a p:cTn row')

        spec = {
            'name': str(raw.get('name') or key),
            'filter': raw.get('filter'),
            'presetID': int(raw.get('preset_id')),
            'presetSubtype': int(raw.get('preset_subtype')),
            'presetClass': _PRESET_CLASS_BY_CATEGORY[category],
            'category': category,
            'msoEffectId': int(raw.get('mso_effect_id')),
            'defaultDurationMs': raw.get('default_duration_ms'),
            'durationScalable': bool(raw.get('duration_scalable')),
            'rowXml': row_xml,
            'effectOptions': raw.get('effect_options', {}),
        }
        if row.get('presetClass') != spec['presetClass']:
            raise RuntimeError(f'native animation preset {key!r} changed presetClass')
        if int(row.get('presetID', '-1')) != spec['presetID']:
            raise RuntimeError(f'native animation preset {key!r} changed presetID')
        if int(row.get('presetSubtype', '-1')) != spec['presetSubtype']:
            raise RuntimeError(f'native animation preset {key!r} changed presetSubtype')
        effect_options = spec['effectOptions']
        if not isinstance(effect_options, dict):
            raise RuntimeError(
                f'native animation preset {key!r} effect_options must be an object'
            )
        unknown_options = set(effect_options) - set(ANIMATION_EFFECT_OPTION_FIELDS)
        if unknown_options:
            raise RuntimeError(
                f'native animation preset {key!r} has unknown effect option(s): '
                + ', '.join(sorted(unknown_options))
            )
        for option_name, option_spec in effect_options.items():
            if not isinstance(option_spec, dict):
                raise RuntimeError(
                    f'native animation preset {key!r} option {option_name!r} '
                    'must be an object'
                )
            required = option_spec.get('required', False)
            if not isinstance(required, bool):
                raise RuntimeError(
                    f'native animation preset {key!r} option {option_name!r} '
                    'required must be a boolean'
                )
            if required and 'default' in option_spec:
                raise RuntimeError(
                    f'native animation preset {key!r} option {option_name!r} '
                    'cannot define both required and default'
                )
            option_type = option_spec.get('type')
            if option_type == 'enum':
                values = option_spec.get('values')
                if not isinstance(values, dict) or not values:
                    raise RuntimeError(
                        f'native animation preset {key!r} enum option '
                        f'{option_name!r} must define values'
                    )
                default = str(option_spec.get('default'))
                if default not in values:
                    raise RuntimeError(
                        f'native animation preset {key!r} enum option '
                        f'{option_name!r} has an unknown default'
                    )
                for option_value, variant_xml in values.items():
                    if not isinstance(option_value, str) or not isinstance(
                        variant_xml,
                        str,
                    ):
                        raise RuntimeError(
                            f'native animation preset {key!r} enum option '
                            f'{option_name!r} contains an invalid variant'
                        )
                    try:
                        variant = ET.fromstring(variant_xml)
                    except ET.ParseError as exc:
                        raise RuntimeError(
                            f'native animation preset {key!r} enum option '
                            f'{option_name!r}/{option_value!r} contains invalid XML: '
                            f'{exc}'
                        ) from exc
                    if variant.tag != f'{{{PML_NS}}}cTn':
                        raise RuntimeError(
                            f'native animation preset {key!r} enum option '
                            f'{option_name!r}/{option_value!r} is not a p:cTn row'
                        )
                    if variant.get('presetClass') != spec['presetClass']:
                        raise RuntimeError(
                            f'native animation preset {key!r} enum option '
                            f'{option_name!r}/{option_value!r} changed presetClass'
                        )
                    if int(variant.get('presetID', '-1')) != spec['presetID']:
                        raise RuntimeError(
                            f'native animation preset {key!r} enum option '
                            f'{option_name!r}/{option_value!r} changed presetID'
                        )
            elif option_type not in {'number', 'string', 'boolean', 'color'}:
                raise RuntimeError(
                    f'native animation preset {key!r} option {option_name!r} '
                    f'has unknown type: {option_type!r}'
                )
        native[key] = spec
        category_counts[category] += 1

    expected_counts = {'entrance': 53, 'emphasis': 33, 'path': 64, 'exit': 53}
    if category_counts != expected_counts:
        raise RuntimeError(
            'native animation preset category counts changed: '
            f'{category_counts!r}; expected {expected_counts!r}'
        )
    return native


NATIVE_ANIMATIONS = _load_native_animations()
NATIVE_ANIMATION_KEYS = tuple(NATIVE_ANIMATIONS)
ANIMATIONS = {
    **NATIVE_ANIMATIONS,
    **{
        alias: NATIVE_ANIMATIONS[canonical]
        for alias, canonical in ANIMATION_ALIASES.items()
    },
}

ANIMATION_MODES = ('auto', 'mixed', 'random')
ANIMATION_TRIGGERS = ('on-click', 'with-previous', 'after-previous')

_TRIGGER_NODE_TYPES = {
    'on-click': 'clickEffect',
    'with-previous': 'withEffect',
    'after-previous': 'afterEffect',
}
_NODE_TYPE_TRIGGERS = {
    value: key for key, value in _TRIGGER_NODE_TYPES.items()
}


@dataclass(frozen=True)
class AnimationTarget:
    """Resolved object-animation request for one PowerPoint shape."""

    shape_id: int
    delay_ms: int
    effect: str
    duration_ms: int
    effect_options: Mapping[str, object]
    trigger: str = 'after-previous'
    trigger_shape_id: int | None = None
    repeat_count: float | None = None
    repeat_duration_ms: int | None = None
    auto_reverse: bool | None = None
    rewind: bool | None = None
    accelerate: float | None = None
    decelerate: float | None = None
    bounce_end: float | None = None
    restart: str | None = None
    after_effect: str = 'none'
    after_effect_color: str | None = None
    sound_relationship_id: str | None = None
    sound_name: str | None = None

    @property
    def playback_duration_ms(self) -> int:
        """Return the wall-clock duration used by after-previous scheduling."""
        one_play = self.duration_ms * (2 if self.auto_reverse else 1)
        if self.repeat_duration_ms is not None:
            return self.repeat_duration_ms
        if self.repeat_count is not None:
            return max(1, round(one_play * self.repeat_count))
        return one_play


@dataclass(frozen=True)
class AnimationRowSummary:
    """Read-back summary for one object-animation row in the animation pane."""

    shape_id: int
    effect: str | None
    supported_effects: tuple[str, ...]
    preset_class: str
    trigger: str
    duration_ms: int | None
    offset_ms: int
    preset_id: int
    preset_subtype: int
    filter_name: str | None
    effect_options: Mapping[str, object]
    trigger_shape_id: int | None
    repeat_count: float | None
    repeat_duration_ms: int | None
    auto_reverse: bool
    rewind: bool
    accelerate: float
    decelerate: float
    bounce_end: float
    restart: str
    after_effect: str
    after_effect_color: str | None
    sound_relationship_id: str | None
    sound_name: str | None
    playback_duration_ms: int | None


@dataclass(frozen=True)
class AnimationSequenceSummary:
    """Read-back summary for the logical object sequence on one slide."""

    timing_count: int
    trigger: str | None
    rows: tuple[AnimationRowSummary, ...]
    audio_target_ids: tuple[int, ...]


def _qn(namespace: str, tag: str) -> str:
    return f'{{{namespace}}}{tag}'


def _local_name(tag: str) -> str:
    return tag.rsplit('}', 1)[-1]


def normalize_animation_effect(
    effect: object,
    *,
    allow_none: bool = True,
    allow_modes: bool = True,
) -> str | None:
    """Return a supported effect/mode without silently substituting another."""
    if effect is None or effect == 'none':
        if allow_none:
            return None
        raise ValueError('animation effect is required')
    if not isinstance(effect, str):
        raise ValueError(f'animation effect must be a string: {effect!r}')
    if effect in ANIMATION_ALIASES:
        return ANIMATION_ALIASES[effect]
    if effect in NATIVE_ANIMATIONS:
        return effect
    if allow_modes and effect in ANIMATION_MODES:
        return effect
    valid = list(ANIMATIONS)
    if allow_modes:
        valid.extend(ANIMATION_MODES)
    if allow_none:
        valid.append('none')
    raise ValueError(
        f'unknown animation effect {effect!r}; valid effects: {", ".join(valid)}'
    )


def _finite_number(value: object, field: str) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(f'{field} must be a finite number: {value!r}')
    number = float(value)
    if not math.isfinite(number):
        raise ValueError(f'{field} must be a finite number: {value!r}')
    return number


def _normalize_animation_color(value: object, field: str) -> str:
    if not isinstance(value, str):
        raise ValueError(
            f'{field} must be #RRGGBB or theme:<scheme-color>: {value!r}'
        )
    if re.fullmatch(r'#[0-9A-Fa-f]{6}', value):
        return value.upper()
    if re.fullmatch(
        r'theme:(?:dk1|lt1|dk2|lt2|tx1|tx2|bg1|bg2|accent[1-6]|'
        r'hlink|folHlink)',
        value,
    ):
        return value
    raise ValueError(
        f'{field} must be #RRGGBB or theme:<scheme-color>: {value!r}'
    )


def _normalize_powerpoint_font_name(value: object, field: str) -> str:
    """Return one concrete PowerPoint font name without checking installation."""
    if not isinstance(value, str) or not value.strip():
        raise ValueError(
            f'{field} must be one concrete PowerPoint font name: {value!r}'
        )
    normalized = value.strip()
    if len(normalized) > 255:
        raise ValueError(f'{field} exceeds 255 characters')
    if ',' in normalized:
        raise ValueError(
            f'{field} must be one concrete PowerPoint font name, '
            f'not a CSS font stack: {value!r}'
        )
    if normalized.casefold() in _NON_CONCRETE_FONT_NAMES:
        raise ValueError(
            f'{field} must be one concrete PowerPoint font name, '
            f'not a generic family or CSS-wide keyword: {value!r}'
        )
    return normalized


def normalize_animation_effect_options(
    effect: str,
    options: object = None,
) -> dict[str, object]:
    """Validate effect-specific PowerPoint EffectParameters values."""
    if effect not in NATIVE_ANIMATIONS:
        if options in (None, {}):
            return {}
        raise ValueError(
            'animation effect_options require one explicit canonical effect; '
            f'found {effect!r}'
        )
    if options is None:
        options = {}
    if not isinstance(options, Mapping):
        raise ValueError(f'animation effect_options must be an object: {options!r}')

    option_specs = NATIVE_ANIMATIONS[effect]['effectOptions']
    unknown = set(options) - set(option_specs)
    if unknown:
        unsupported = ', '.join(sorted(unknown))
        supported = ', '.join(option_specs) or '(none)'
        raise ValueError(
            f'animation effect {effect!r} does not support effect option(s): '
            f'{unsupported}; supported options: {supported}'
        )
    missing_required = sorted(
        name
        for name, spec in option_specs.items()
        if spec.get('required') and name not in options
    )
    if missing_required:
        required_fields = ', '.join(
            f'effect_options.{name}' for name in missing_required
        )
        raise ValueError(
            f'animation effect {effect!r} requires {required_fields}'
        )

    normalized: dict[str, object] = {}
    for name, value in options.items():
        spec = option_specs[name]
        option_type = spec['type']
        field = f'animation effect_options.{name}'
        if option_type == 'enum':
            key = str(value)
            if isinstance(value, bool) or key not in spec['values']:
                valid = ', '.join(spec['values'])
                raise ValueError(
                    f'{field} for {effect!r} must be one of {valid}: {value!r}'
                )
            normalized[name] = (
                int(key)
                if name == 'amount' and re.fullmatch(r'\d+', key)
                else key
            )
        elif option_type == 'number':
            number = _finite_number(value, field)
            minimum = spec.get('minimum')
            maximum = spec.get('maximum')
            if minimum is not None and number < float(minimum):
                raise ValueError(
                    f'{field} for {effect!r} must be at least {minimum}: {value!r}'
                )
            if maximum is not None and number > float(maximum):
                raise ValueError(
                    f'{field} for {effect!r} must be at most {maximum}: {value!r}'
                )
            normalized[name] = number
        elif option_type == 'string':
            if name == 'font_name':
                normalized[name] = _normalize_powerpoint_font_name(value, field)
            else:
                if not isinstance(value, str) or not value.strip():
                    raise ValueError(
                        f'{field} must be a non-empty string: {value!r}'
                    )
                normalized_value = value.strip()
                if len(normalized_value) > 255:
                    raise ValueError(f'{field} exceeds 255 characters')
                normalized[name] = normalized_value
        elif option_type == 'boolean':
            if not isinstance(value, bool):
                raise ValueError(f'{field} must be a boolean: {value!r}')
            normalized[name] = value
        elif option_type == 'color':
            normalized[name] = _normalize_animation_color(value, field)
        else:
            raise AssertionError(f'unhandled animation effect option type: {option_type}')
    return normalized


def normalize_animation_effect_request(
    effect: object,
    options: object = None,
    *,
    allow_none: bool = True,
    allow_modes: bool = True,
) -> tuple[str | None, dict[str, object]]:
    """Normalize one effect plus options, including legacy semantic aliases."""
    raw_effect = effect
    canonical = normalize_animation_effect(
        effect,
        allow_none=allow_none,
        allow_modes=allow_modes,
    )
    alias_options = (
        ANIMATION_ALIAS_OPTIONS.get(raw_effect, {})
        if isinstance(raw_effect, str)
        else {}
    )
    explicit_options: Mapping[str, object]
    if options is None:
        explicit_options = {}
    elif isinstance(options, Mapping):
        explicit_options = options
    else:
        raise ValueError(f'animation effect_options must be an object: {options!r}')
    for name, alias_value in alias_options.items():
        if name in explicit_options and explicit_options[name] != alias_value:
            raise ValueError(
                f'legacy animation effect {raw_effect!r} implies '
                f'effect_options.{name}={alias_value!r}, which conflicts with '
                f'{explicit_options[name]!r}'
            )
    merged = {**alias_options, **explicit_options}
    if canonical is None or canonical in ANIMATION_MODES:
        if merged:
            raise ValueError(
                'animation effect_options require one explicit canonical effect; '
                f'found {canonical or "none"!r}'
            )
        return canonical, {}
    return canonical, normalize_animation_effect_options(canonical, merged)


def normalize_animation_trigger(trigger: object) -> str:
    """Return a supported PowerPoint Start mode or raise a precise error."""
    if not isinstance(trigger, str):
        raise ValueError(f'animation trigger must be a string: {trigger!r}')
    if trigger not in ANIMATION_TRIGGERS:
        raise ValueError(
            f'unknown animation trigger {trigger!r}; valid triggers: '
            f'{", ".join(ANIMATION_TRIGGERS)}'
        )
    return trigger


def _seconds_to_ms(value: object, field: str, *, allow_zero: bool) -> int:
    seconds = validate_seconds(value, field, allow_zero=allow_zero)
    raw_milliseconds = seconds * 1000
    if (
        not math.isfinite(raw_milliseconds)
        or raw_milliseconds > MAX_OOXML_MILLISECONDS
    ):
        raise ValueError(f'{field} exceeds the OOXML millisecond limit: {value!r}')
    milliseconds = int(raw_milliseconds)
    return milliseconds if allow_zero else max(1, milliseconds)


def animation_seconds_to_milliseconds(
    value: object,
    field: str,
    *,
    allow_zero: bool,
) -> int:
    """Convert validated animation seconds to the OOXML millisecond range."""
    return _seconds_to_ms(value, field, allow_zero=allow_zero)


def _positive_shape_id(value: object, field: str = 'animation shape_id') -> int:
    if isinstance(value, bool):
        raise ValueError(f'{field} must be a positive integer: {value!r}')
    if isinstance(value, int):
        shape_id = value
    elif isinstance(value, str) and re.fullmatch(r'[1-9]\d*', value):
        shape_id = int(value)
    else:
        raise ValueError(f'{field} must be a positive integer: {value!r}')
    if shape_id <= 0 or shape_id > MAX_OOXML_UNSIGNED_INT:
        raise ValueError(f'{field} must be a positive integer: {value!r}')
    return shape_id


def _non_negative_milliseconds(value: object, field: str) -> int:
    if isinstance(value, bool):
        raise ValueError(f'{field} must be a non-negative integer: {value!r}')
    if isinstance(value, int):
        milliseconds = value
    elif isinstance(value, str) and re.fullmatch(r'\d+', value):
        milliseconds = int(value)
    else:
        raise ValueError(f'{field} must be a non-negative integer: {value!r}')
    if milliseconds < 0 or milliseconds > MAX_OOXML_MILLISECONDS:
        raise ValueError(
            f'{field} must be between 0 and {MAX_OOXML_MILLISECONDS}: {value!r}'
        )
    return milliseconds


def _optional_bool(value: object, field: str) -> bool:
    if not isinstance(value, bool):
        raise ValueError(f'{field} must be a boolean: {value!r}')
    return value


def _optional_ratio(value: object, field: str) -> float:
    ratio = _finite_number(value, field)
    if ratio < 0 or ratio > 1:
        raise ValueError(f'{field} must be between 0 and 1: {value!r}')
    return ratio


def _normalize_repeat_count(value: object) -> float:
    count = _finite_number(value, 'animation repeat_count')
    if count <= 0 or count * 1000 > MAX_OOXML_UNSIGNED_INT:
        raise ValueError(
            'animation repeat_count must be positive and fit the OOXML range: '
            f'{value!r}'
        )
    return count


def _normalize_after_effect(value: object) -> tuple[str, str | None]:
    if value is None:
        return 'none', None
    if isinstance(value, str):
        effect_type = value
        color = None
    elif isinstance(value, Mapping):
        unknown = set(value) - {'type', 'color'}
        if unknown:
            raise ValueError(
                'animation after_effect has unknown field(s): '
                + ', '.join(sorted(unknown))
            )
        effect_type = value.get('type', 'none')
        color = value.get('color')
    else:
        raise ValueError(
            f'animation after_effect must be a string or object: {value!r}'
        )
    if effect_type not in ANIMATION_AFTER_EFFECTS:
        raise ValueError(
            f'animation after_effect.type must be one of '
            f'{", ".join(ANIMATION_AFTER_EFFECTS)}: {effect_type!r}'
        )
    if effect_type == 'dim':
        if color is None:
            raise ValueError('animation dim after_effect requires color')
        return effect_type, _normalize_animation_color(
            color,
            'animation after_effect.color',
        )
    if color is not None:
        raise ValueError(
            f'animation after_effect.color is valid only with type "dim": {color!r}'
        )
    return effect_type, None


def _normalize_sound(value: object) -> tuple[str | None, str | None]:
    if value is None:
        return None, None
    if not isinstance(value, Mapping):
        raise ValueError(
            'low-level animation sound must be an object with '
            'relationship_id and name'
        )
    unknown = set(value) - {'relationship_id', 'name'}
    if unknown:
        raise ValueError(
            'low-level animation sound has unknown field(s): '
            + ', '.join(sorted(unknown))
        )
    relationship_id = value.get('relationship_id')
    name = value.get('name')
    if not isinstance(relationship_id, str) or not re.fullmatch(
        r'rId[1-9]\d*',
        relationship_id,
    ):
        raise ValueError(
            'low-level animation sound relationship_id must match rIdN: '
            f'{relationship_id!r}'
        )
    if not isinstance(name, str) or not name.strip():
        raise ValueError(
            f'low-level animation sound name must be non-empty: {name!r}'
        )
    return relationship_id, name


def _normalize_target_mapping(
    target: Mapping[str, object],
    default_duration_ms: int,
    default_trigger: str,
) -> AnimationTarget:
    allowed = {
        'shape_id',
        'delay_ms',
        'effect',
        'duration',
        'effect_options',
        'trigger',
        'trigger_shape_id',
        *ANIMATION_TIMING_OPTION_FIELDS,
        'after_effect',
        'sound',
    }
    unknown = set(target) - allowed
    if unknown:
        raise ValueError(
            'animation target has unknown field(s): ' + ', '.join(sorted(unknown))
        )
    shape_id = _positive_shape_id(target.get('shape_id'))
    delay_ms = _non_negative_milliseconds(
        target.get('delay_ms', 0),
        'animation target delay_ms',
    )
    effect, effect_options = normalize_animation_effect_request(
        target.get('effect'),
        target.get('effect_options'),
        allow_none=False,
        allow_modes=False,
    )
    duration_ms = default_duration_ms
    if target.get('duration') is not None:
        duration_ms = _seconds_to_ms(
            target.get('duration'),
            'animation target duration',
            allow_zero=False,
        )
    trigger_shape_id = (
        _positive_shape_id(
            target['trigger_shape_id'],
            'animation target trigger_shape_id',
        )
        if 'trigger_shape_id' in target
        else None
    )
    if trigger_shape_id == shape_id:
        raise ValueError(
            'animation trigger_shape_id must target a different shape'
        )
    target_trigger = (
        normalize_animation_trigger(target['trigger'])
        if 'trigger' in target
        else default_trigger
    )
    if trigger_shape_id is not None:
        if 'trigger' in target and target_trigger != 'on-click':
            raise ValueError(
                'animation target with trigger_shape_id must use '
                'trigger "on-click"'
            )
        target_trigger = 'on-click'
    repeat_count = (
        _normalize_repeat_count(target['repeat_count'])
        if 'repeat_count' in target
        else None
    )
    repeat_duration_ms = (
        _seconds_to_ms(
            target['repeat_duration'],
            'animation repeat_duration',
            allow_zero=False,
        )
        if 'repeat_duration' in target
        else None
    )
    if repeat_count is not None and repeat_duration_ms is not None:
        raise ValueError(
            'animation repeat_count and repeat_duration are mutually exclusive'
        )
    auto_reverse = (
        _optional_bool(target['auto_reverse'], 'animation auto_reverse')
        if 'auto_reverse' in target
        else None
    )
    rewind = (
        _optional_bool(target['rewind'], 'animation rewind')
        if 'rewind' in target
        else None
    )
    accelerate = (
        _optional_ratio(target['accelerate'], 'animation accelerate')
        if 'accelerate' in target
        else None
    )
    decelerate = (
        _optional_ratio(target['decelerate'], 'animation decelerate')
        if 'decelerate' in target
        else None
    )
    if (
        accelerate is not None
        and decelerate is not None
        and accelerate + decelerate > 1
    ):
        raise ValueError(
            'animation accelerate + decelerate must not exceed 1'
        )
    bounce_end = (
        _optional_ratio(target['bounce_end'], 'animation bounce_end')
        if 'bounce_end' in target
        else None
    )
    if bounce_end and decelerate:
        raise ValueError(
            'animation bounce_end and decelerate are mutually exclusive '
            'in PowerPoint'
        )
    restart = target.get('restart')
    if restart is not None and restart not in ANIMATION_RESTARTS:
        raise ValueError(
            f'animation restart must be one of '
            f'{", ".join(ANIMATION_RESTARTS)}: {restart!r}'
        )
    after_effect, after_effect_color = _normalize_after_effect(
        target.get('after_effect')
    )
    sound_relationship_id, sound_name = _normalize_sound(target.get('sound'))
    return AnimationTarget(
        shape_id=shape_id,
        delay_ms=delay_ms,
        effect=effect,
        duration_ms=duration_ms,
        effect_options=effect_options,
        trigger=target_trigger,
        trigger_shape_id=trigger_shape_id,
        repeat_count=repeat_count,
        repeat_duration_ms=repeat_duration_ms,
        auto_reverse=auto_reverse,
        rewind=rewind,
        accelerate=accelerate,
        decelerate=decelerate,
        bounce_end=bounce_end,
        restart=restart,
        after_effect=after_effect,
        after_effect_color=after_effect_color,
        sound_relationship_id=sound_relationship_id,
        sound_name=sound_name,
    )


def _normalize_target(
    target: Sequence[object] | Mapping[str, object],
    default_duration_ms: int,
    default_trigger: str = 'after-previous',
) -> AnimationTarget:
    if isinstance(target, Mapping):
        return _normalize_target_mapping(
            target,
            default_duration_ms,
            default_trigger,
        )
    if isinstance(target, (str, bytes)) or not isinstance(target, Sequence):
        raise ValueError(f'animation target must be a 3- or 4-item sequence: {target!r}')
    if len(target) not in (3, 4):
        raise ValueError(f'animation target must contain 3 or 4 items: {target!r}')
    shape_id = _positive_shape_id(target[0])
    delay_ms = _non_negative_milliseconds(target[1], 'animation target delay_ms')
    effect, effect_options = normalize_animation_effect_request(
        target[2],
        allow_none=False,
        allow_modes=False,
    )
    duration_ms = default_duration_ms
    if len(target) == 4 and target[3] is not None:
        duration_ms = _seconds_to_ms(
            target[3],
            'animation target duration',
            allow_zero=False,
        )
    return AnimationTarget(
        shape_id=shape_id,
        delay_ms=delay_ms,
        effect=effect,
        duration_ms=duration_ms,
        effect_options=effect_options,
        trigger=default_trigger,
    )

# Pool used by 'mixed' / 'random' modes. Every entry is a canonical
# PowerPoint-authored preset; compatibility aliases never enter selection.
_MIXED_POOL = [
    'entrance_blinds', 'entrance_checkerboard', 'entrance_dissolve',
    'entrance_fly', 'entrance_ascend', 'entrance_random_bars',
    'entrance_box', 'entrance_split', 'entrance_strips', 'entrance_wedge',
    'entrance_wheel', 'entrance_wipe', 'entrance_expand', 'entrance_fade',
    'entrance_swivel', 'entrance_zoom',
]

# Small modern pool used by 'auto' mode when the group id matches no semantic
# pattern. Restricted to four widely supported, restrained effects so the
# fallback cycle never produces PowerPoint-era visuals.
_AUTO_POOL = [
    'entrance_fade',
    'entrance_wipe',
    'entrance_fly',
    'entrance_zoom',
]

# Image-only diversity pool. Image-like groups (`hero`, `figure-`, `image`,
# `img-`, `kpi`) deliberately cycle through a richer set of visual effects
# rather than mapping to a single effect: images are visual focal points, so
# variation is desirable on them even when surrounding information-dense
# elements (titles, charts, lists) stay reserved. Pool members are chosen for
# image-friendly motion — no PowerPoint-era patterns (``entrance_blinds`` /
# ``entrance_checkerboard`` / ``entrance_random_bars`` / ``entrance_wedge``)
# that would dominate raster content.
_IMAGE_POOL = [
    'entrance_zoom',
    'entrance_dissolve',
    'entrance_circle',
    'entrance_box',
    'entrance_diamond',
    'entrance_wheel',
]
_IMAGE_KEYWORDS: tuple[str, ...] = ('hero', 'figure-', 'image', 'img-', 'kpi')

# Ordered (substring, effect) patterns consumed by 'auto' mode for non-image
# groups. The first matching substring in the lowercased group id wins;
# ordering matters where substrings could overlap (e.g. 'title' before 'item'
# prevents 'item-title' from being misread as a list item). All substrings are
# lowercase. Image-like ids are handled separately via ``_IMAGE_POOL`` because
# they cycle rather than map to a single effect.
_SEMANTIC_PATTERNS: list[tuple[tuple[str, ...], str]] = [
    (
        ('title', 'chapter-', 'section-', 'cover-', 'tagline', 'subtitle'),
        'entrance_fade',
    ),
    (
        ('chart', 'table', 'legend', 'timeline', 'track'),
        'entrance_wipe',
    ),
    (('card-', 'pillar-', 'item-', 'step-', 'stage-', 'tier-',
      'principle-', 'q-', 'schema-'),                           'entrance_fly'),
    (('takeaway', 'callout', 'quote', 'source', 'conclusion', 'note',
      'try-at-home'),                                           'entrance_fade'),
]


def _semantic_effect(group_id: str | None, idx: int = 0, offset: int = 0) -> str | None:
    """Return the effect mapped from a group id, or None if no pattern matches.

    Image-like ids cycle through ``_IMAGE_POOL`` using ``idx + offset`` so the
    same deck shows different effects across multiple images. Other semantic
    matches return a single stable effect because information-dense elements
    benefit from consistency, not variation.
    """
    if not group_id:
        return None
    lower = group_id.lower()
    if any(k in lower for k in _IMAGE_KEYWORDS):
        return _IMAGE_POOL[(idx + offset) % len(_IMAGE_POOL)]
    for substrings, effect in _SEMANTIC_PATTERNS:
        if any(s in lower for s in substrings):
            return effect
    return None


def create_timing_xml(
    animation: str = 'entrance_fade',
    duration: float = 1.0,
    delay: float = 0,
    shape_id: int = 2
) -> str:
    """
    Generate an object-animation timing XML fragment

    Args:
        animation: Canonical PowerPoint effect name
        duration: Animation duration (seconds)
        delay: Animation delay (seconds)
        shape_id: Target shape ID (SVG image is typically 2)

    Returns:
        A <p:timing> element string insertable into slide XML
    """
    animation = normalize_animation_effect(
        animation,
        allow_none=False,
        allow_modes=False,
    )
    shape_id = _positive_shape_id(shape_id)
    delay_ms = _seconds_to_ms(
        delay,
        'animation delay',
        allow_zero=True,
    )
    return create_sequence_timing_xml(
        [(shape_id, delay_ms, animation, duration)],
        duration=duration,
        trigger='after-previous',
    )


def _ctn_numeric_delay(ctn: ET.Element) -> int:
    """Return the largest direct numeric start delay on one time node."""
    conditions = ctn.find(_qn(PML_NS, 'stCondLst'))
    if conditions is None:
        return 0
    values = [
        int(condition.get('delay', '0'))
        for condition in conditions.findall(_qn(PML_NS, 'cond'))
        if condition.get('delay', '').isdigit()
    ]
    return max(values, default=0)


def _row_effective_duration_ms(row: ET.Element) -> int | None:
    """Return the finite end time PowerPoint exposes for one preset row."""
    ends: list[int] = []
    for ctn in row.iter(_qn(PML_NS, 'cTn')):
        if ctn is row:
            continue
        raw_duration = ctn.get('dur')
        if raw_duration is None or not raw_duration.isdigit():
            continue
        ends.append(_ctn_numeric_delay(ctn) + int(raw_duration))
    return max(ends) if ends else None


def _scale_animation_row_duration(
    row: ET.Element,
    *,
    base_duration_ms: int,
    requested_duration_ms: int,
) -> None:
    """Scale all finite behavior durations/delays as one PowerPoint row."""
    ratio = requested_duration_ms / base_duration_ms
    for ctn in row.iter(_qn(PML_NS, 'cTn')):
        if ctn is row:
            continue
        raw_duration = ctn.get('dur')
        if raw_duration is not None and raw_duration.isdigit():
            numeric_duration = int(raw_duration)
            ctn.set(
                'dur',
                (
                    '1'
                    if numeric_duration == 1
                    else str(max(1, round(numeric_duration * ratio)))
                ),
            )
        conditions = ctn.find(_qn(PML_NS, 'stCondLst'))
        if conditions is None:
            continue
        for condition in conditions.findall(_qn(PML_NS, 'cond')):
            raw_delay = condition.get('delay')
            if raw_delay is not None and raw_delay.isdigit():
                condition.set('delay', str(round(int(raw_delay) * ratio)))

    actual_duration = _row_effective_duration_ms(row)
    if actual_duration is None or actual_duration == requested_duration_ms:
        return
    end_nodes = [
        ctn
        for ctn in row.iter(_qn(PML_NS, 'cTn'))
        if ctn is not row
        and (ctn.get('dur') or '').isdigit()
        and _ctn_numeric_delay(ctn) + int(ctn.get('dur', '0')) == actual_duration
    ]
    for ctn in end_nodes:
        delay = _ctn_numeric_delay(ctn)
        if delay >= requested_duration_ms:
            conditions = ctn.find(_qn(PML_NS, 'stCondLst'))
            numeric = (
                [
                    condition
                    for condition in conditions.findall(_qn(PML_NS, 'cond'))
                    if condition.get('delay', '').isdigit()
                ]
                if conditions is not None
                else []
            )
            if numeric:
                numeric[-1].set('delay', str(max(0, requested_duration_ms - 1)))
                delay = _ctn_numeric_delay(ctn)
        ctn.set('dur', str(max(1, requested_duration_ms - delay)))


def _row_semantic_signature(row: ET.Element) -> tuple[object, ...]:
    """Return an id/target/duration-independent preset behavior signature."""
    def canonical(element: ET.Element) -> tuple[object, ...]:
        attributes: list[tuple[str, str]] = []
        local_name = _local_name(element.tag)
        for name, value in sorted(element.attrib.items()):
            if name in {'id', 'nodeType', 'grpId'}:
                continue
            if local_name == 'spTgt' and name == 'spid':
                value = '#shape'
            elif local_name == 'cTn' and name == 'dur' and value.isdigit():
                value = '#duration'
            elif local_name == 'cond' and name == 'delay' and value.isdigit():
                value = '#delay'
            attributes.append((name, value))
        return (
            element.tag,
            tuple(attributes),
            (element.text or '').strip(),
            tuple(canonical(child) for child in list(element)),
        )

    return canonical(row)


def _row_timing_profile(
    row: ET.Element,
) -> tuple[tuple[str, float, float], ...]:
    """Return behavior timing ratios, excluding 1ms visibility bookkeeping."""
    total = _row_effective_duration_ms(row)
    if total is None or total <= 1:
        return ()
    parent_map = {
        child: parent
        for parent in row.iter()
        for child in list(parent)
    }
    behavior_names = {
        'anim',
        'animClr',
        'animEffect',
        'animMotion',
        'animRot',
        'animScale',
        'set',
    }
    profile: list[tuple[str, float, float]] = []
    for ctn in row.iter(_qn(PML_NS, 'cTn')):
        if ctn is row:
            continue
        raw_duration = ctn.get('dur')
        if raw_duration is None or not raw_duration.isdigit():
            continue
        duration = int(raw_duration)
        owner = parent_map.get(ctn)
        while owner is not None and _local_name(owner.tag) not in behavior_names:
            owner = parent_map.get(owner)
        behavior = _local_name(owner.tag) if owner is not None else 'unknown'
        if (
            duration == 1
            and behavior == 'set'
            and owner is not None
            and any(
                (attribute.text or '') == 'style.visibility'
                for attribute in owner.iter(_qn(PML_NS, 'attrName'))
            )
        ):
            continue
        profile.append(
            (
                behavior,
                _ctn_numeric_delay(ctn) / total,
                duration / total,
            )
        )
    return tuple(profile)


def _animation_spec_matches_row(row: ET.Element, spec: Mapping[str, Any]) -> bool:
    """Require the PowerPoint-authored structure and internal timing ratios."""
    template = ET.fromstring(spec['rowXml'])
    if _row_semantic_signature(row) != _row_semantic_signature(template):
        return False
    actual_duration = _row_effective_duration_ms(row)
    if actual_duration is not None and actual_duration < 100:
        # Millisecond quantization necessarily collapses multi-step ratios at
        # sub-100ms speeds; structure and total duration remain authoritative.
        return True
    actual_profile = _row_timing_profile(row)
    template_profile = _row_timing_profile(template)
    if len(actual_profile) != len(template_profile):
        return False
    for actual, expected in zip(actual_profile, template_profile):
        if actual[0] != expected[0]:
            return False
        if abs(actual[1] - expected[1]) > 0.01:
            return False
        if abs(actual[2] - expected[2]) > 0.01:
            return False
    return True


def _format_decimal(value: float) -> str:
    """Format one finite decimal without exponent notation or negative zero."""
    rendered = f'{value:.9f}'.rstrip('0').rstrip('.')
    return '0' if rendered in {'', '-0'} else rendered


def _color_element(value: str) -> ET.Element:
    if value.startswith('#'):
        return ET.Element(_qn(_DML_NS, 'srgbClr'), {'val': value[1:]})
    return ET.Element(_qn(_DML_NS, 'schemeClr'), {'val': value.split(':', 1)[1]})


def _replace_animation_colors(row: ET.Element, value: str) -> None:
    parent_map = {
        child: parent
        for parent in row.iter()
        for child in list(parent)
    }
    color_tags = {
        _qn(_DML_NS, 'srgbClr'),
        _qn(_DML_NS, 'schemeClr'),
        _qn(_DML_NS, 'hslClr'),
        _qn(_DML_NS, 'scrgbClr'),
        _qn(_DML_NS, 'sysClr'),
        _qn(_DML_NS, 'prstClr'),
    }
    colors = [element for element in row.iter() if element.tag in color_tags]
    if not colors:
        raise RuntimeError('color-capable animation preset lost its color node')
    for color in colors:
        parent = parent_map[color]
        index = list(parent).index(color)
        parent.remove(color)
        parent.insert(index, _color_element(value))


def _set_effect_option_value(
    row: ET.Element,
    animation: str,
    name: str,
    value: object,
) -> None:
    if name == 'amount' and animation == 'emphasis_spin':
        rotations = list(row.iter(_qn(PML_NS, 'animRot')))
        if len(rotations) != 1:
            raise RuntimeError('emphasis_spin preset lost its p:animRot node')
        rotations[0].set('by', str(round(float(value) * 60000)))
        return
    if name == 'amount' and animation == 'emphasis_transparency':
        matched = False
        for node in row.iter(_qn(PML_NS, 'set')):
            attributes = {
                (attribute.text or '').strip()
                for attribute in node.iter(_qn(PML_NS, 'attrName'))
            }
            if 'style.opacity' not in attributes:
                continue
            values = list(node.iter(_qn(PML_NS, 'strVal')))
            if len(values) != 1:
                raise RuntimeError(
                    'emphasis_transparency preset lost its opacity value'
                )
            values[0].set('val', _format_decimal(1 - float(value)))
            matched = True
        if not matched:
            raise RuntimeError(
                'emphasis_transparency preset lost its opacity behavior'
            )
        return
    if name == 'color':
        _replace_animation_colors(row, str(value))
        return
    if name == 'font_name':
        matched = False
        for node in row.iter(_qn(PML_NS, 'set')):
            attributes = {
                (attribute.text or '').strip()
                for attribute in node.iter(_qn(PML_NS, 'attrName'))
            }
            if 'style.fontFamily' not in attributes:
                continue
            values = list(node.iter(_qn(PML_NS, 'strVal')))
            if len(values) != 1:
                raise RuntimeError(
                    'emphasis_change_font preset lost its font value'
                )
            values[0].set('val', str(value))
            matched = True
        if not matched:
            raise RuntimeError(
                'emphasis_change_font preset lost its font behavior'
            )
        return
    if name == 'relative':
        motions = list(row.iter(_qn(PML_NS, 'animMotion')))
        if len(motions) != 1:
            raise RuntimeError('motion-path preset lost its p:animMotion node')
        motions[0].set('pathEditMode', 'relative' if value else 'fixed')
        return
    if name == 'size':
        scales = list(row.iter(_qn(PML_NS, 'animScale')))
        if len(scales) != 1:
            raise RuntimeError(
                'emphasis_grow_shrink preset lost its p:animScale node'
        )
        amount = str(round(float(value) * 1000))
        targets = scales[0].findall(_qn(PML_NS, 'to'))
        if len(targets) > 1:
            raise RuntimeError(
                'emphasis_grow_shrink preset lost its scale value'
            )
        target = (
            targets[0]
            if targets
            else ET.SubElement(scales[0], _qn(PML_NS, 'to'))
        )
        target.set('x', amount)
        target.set('y', amount)
        return
    raise AssertionError(f'unhandled continuous animation option: {name}')


def _animation_row_for_options(
    animation: str,
    effect_options: Mapping[str, object],
) -> ET.Element:
    """Return the authored variant row with continuous options applied."""
    spec = NATIVE_ANIMATIONS[animation]
    option_specs = spec['effectOptions']
    row_xml = spec['rowXml']
    for name, value in effect_options.items():
        option_spec = option_specs[name]
        if option_spec['type'] != 'enum':
            continue
        key = str(value)
        row_xml = option_spec['values'][key]
    row = ET.fromstring(row_xml)
    for name, value in effect_options.items():
        if option_specs[name]['type'] == 'enum':
            continue
        _set_effect_option_value(row, animation, name, value)
    return row


def _interpolated_behavior_nodes(row: ET.Element) -> tuple[ET.Element, ...]:
    """Return behavior nodes that can carry PowerPoint bounce metadata."""
    return tuple(
        node
        for node in row.iter()
        if _local_name(node.tag) in _INTERPOLATED_BEHAVIOR_TAGS
    )


def animation_effect_supports_bounce_end(
    effect: object,
    effect_options: object = None,
) -> bool:
    """Return whether one concrete effect has an interpolated behavior."""
    animation, options = normalize_animation_effect_request(
        effect,
        effect_options,
        allow_none=False,
        allow_modes=False,
    )
    if animation is None:
        raise AssertionError('concrete animation normalization returned none')
    row = _animation_row_for_options(animation, options)
    return bool(_interpolated_behavior_nodes(row))


def _apply_timing_options(row: ET.Element, target: AnimationTarget) -> None:
    if target.repeat_count is not None:
        row.set('repeatCount', str(round(target.repeat_count * 1000)))
        row.attrib.pop('repeatDur', None)
    if target.repeat_duration_ms is not None:
        row.set('repeatDur', str(target.repeat_duration_ms))
        row.attrib.pop('repeatCount', None)
    if target.auto_reverse is not None:
        if target.auto_reverse:
            row.set('autoRev', '1')
        else:
            row.attrib.pop('autoRev', None)
    if target.rewind is not None:
        row.set('fill', 'remove' if target.rewind else 'hold')
    if target.accelerate is not None:
        if target.accelerate:
            row.set('accel', str(round(target.accelerate * 100000)))
        else:
            row.attrib.pop('accel', None)
    if target.decelerate is not None:
        if target.decelerate:
            row.set('decel', str(round(target.decelerate * 100000)))
        else:
            row.attrib.pop('decel', None)
    if target.bounce_end is not None:
        bounce_nodes = _interpolated_behavior_nodes(row)
        if target.bounce_end and not animation_effect_supports_bounce_end(
            target.effect,
            target.effect_options,
        ):
            raise ValueError(
                f'animation effect {target.effect!r} has no behavior that '
                'supports bounce_end'
            )
        if target.bounce_end:
            row.set(
                _qn(_P14_NS, 'presetBounceEnd'),
                str(round(target.bounce_end * 100000)),
            )
        else:
            row.attrib.pop(_qn(_P14_NS, 'presetBounceEnd'), None)
        for node in bounce_nodes:
            if target.bounce_end:
                node.set(
                    _qn(_P14_NS, 'bounceEnd'),
                    str(round(target.bounce_end * 100000)),
                )
            else:
                node.attrib.pop(_qn(_P14_NS, 'bounceEnd'), None)
    if target.restart is not None:
        row.set(
            'restart',
            {
                'always': 'always',
                'when-not-active': 'whenNotActive',
                'never': 'never',
            }[target.restart],
        )


def _append_after_effect(
    row: ET.Element,
    target: AnimationTarget,
    row_id: int,
) -> None:
    if target.after_effect == 'none':
        return
    sub_timing = row.find(_qn(PML_NS, 'subTnLst'))
    if sub_timing is None:
        sub_timing = ET.SubElement(row, _qn(PML_NS, 'subTnLst'))
    if target.after_effect == 'dim':
        animation = ET.SubElement(
            sub_timing,
            _qn(PML_NS, 'animClr'),
            {'clrSpc': 'rgb', 'dir': 'cw'},
        )
        behavior = ET.SubElement(
            animation,
            _qn(PML_NS, 'cBhvr'),
            {'override': 'childStyle'},
        )
        ET.SubElement(
            behavior,
            _qn(PML_NS, 'cTn'),
            {
                'dur': '1',
                'fill': 'hold',
                'display': '0',
                'masterRel': 'nextClick',
                'afterEffect': '1',
            },
        )
        target_element = ET.SubElement(behavior, _qn(PML_NS, 'tgtEl'))
        ET.SubElement(
            target_element,
            _qn(PML_NS, 'spTgt'),
            {'spid': str(target.shape_id)},
        )
        names = ET.SubElement(behavior, _qn(PML_NS, 'attrNameLst'))
        ET.SubElement(names, _qn(PML_NS, 'attrName')).text = 'ppt_c'
        destination = ET.SubElement(animation, _qn(PML_NS, 'to'))
        destination.append(_color_element(str(target.after_effect_color)))
        return

    setting = ET.SubElement(sub_timing, _qn(PML_NS, 'set'))
    behavior = ET.SubElement(
        setting,
        _qn(PML_NS, 'cBhvr'),
        {'override': 'childStyle'},
    )
    ctn_attributes = {
        'dur': '1',
        'fill': 'hold',
        'display': '0',
        'masterRel': (
            'sameClick'
            if target.after_effect == 'hide'
            else 'nextClick'
        ),
        'afterEffect': '1',
    }
    ctn = ET.SubElement(behavior, _qn(PML_NS, 'cTn'), ctn_attributes)
    if target.after_effect == 'hide':
        conditions = ET.SubElement(ctn, _qn(PML_NS, 'stCondLst'))
        condition = ET.SubElement(
            conditions,
            _qn(PML_NS, 'cond'),
            {'evt': 'end', 'delay': '0'},
        )
        ET.SubElement(condition, _qn(PML_NS, 'tn'), {'val': str(row_id)})
    target_element = ET.SubElement(behavior, _qn(PML_NS, 'tgtEl'))
    ET.SubElement(
        target_element,
        _qn(PML_NS, 'spTgt'),
        {'spid': str(target.shape_id)},
    )
    names = ET.SubElement(behavior, _qn(PML_NS, 'attrNameLst'))
    ET.SubElement(names, _qn(PML_NS, 'attrName')).text = 'style.visibility'
    destination = ET.SubElement(setting, _qn(PML_NS, 'to'))
    ET.SubElement(destination, _qn(PML_NS, 'strVal'), {'val': 'hidden'})


def _append_animation_sound(row: ET.Element, target: AnimationTarget) -> None:
    if target.sound_relationship_id is None:
        return
    sub_timing = row.find(_qn(PML_NS, 'subTnLst'))
    if sub_timing is None:
        sub_timing = ET.SubElement(row, _qn(PML_NS, 'subTnLst'))
    audio = ET.SubElement(sub_timing, _qn(PML_NS, 'audio'))
    media = ET.SubElement(audio, _qn(PML_NS, 'cMediaNode'))
    ctn = ET.SubElement(
        media,
        _qn(PML_NS, 'cTn'),
        {'display': '0', 'masterRel': 'sameClick'},
    )
    conditions = ET.SubElement(ctn, _qn(PML_NS, 'stCondLst'))
    condition = ET.SubElement(
        conditions,
        _qn(PML_NS, 'cond'),
        {'evt': 'begin', 'delay': '0'},
    )
    ET.SubElement(condition, _qn(PML_NS, 'tn'), {'val': str(row.get('id'))})
    end_conditions = ET.SubElement(ctn, _qn(PML_NS, 'endCondLst'))
    end_condition = ET.SubElement(
        end_conditions,
        _qn(PML_NS, 'cond'),
        {'evt': 'onStopAudio', 'delay': '0'},
    )
    target_element = ET.SubElement(end_condition, _qn(PML_NS, 'tgtEl'))
    ET.SubElement(target_element, _qn(PML_NS, 'sldTgt'))
    target_element = ET.SubElement(media, _qn(PML_NS, 'tgtEl'))
    ET.SubElement(
        target_element,
        _qn(PML_NS, 'sndTgt'),
        {
            _qn(_REL_NS, 'embed'): str(target.sound_relationship_id),
            'name': str(target.sound_name),
        },
    )


def _instantiate_animation_row(
    target: AnimationTarget,
    node_type: str,
    row_id: int,
    first_behavior_id: int,
) -> tuple[str, int]:
    """Instantiate one PowerPoint-authored preset row for a generated shape."""
    animation = target.effect
    shape_id = target.shape_id
    duration_ms = target.duration_ms
    spec = NATIVE_ANIMATIONS[animation]
    row = _animation_row_for_options(animation, target.effect_options)
    row.set('id', str(row_id))
    row.set('nodeType', node_type)
    conditions = row.find(_qn(PML_NS, 'stCondLst'))
    if conditions is None:
        raise RuntimeError(f'animation preset {animation!r} lost p:stCondLst')
    direct_conditions = conditions.findall(_qn(PML_NS, 'cond'))
    if len(direct_conditions) != 1:
        raise RuntimeError(
            f'animation preset {animation!r} must have one start condition'
        )
    direct_conditions[0].attrib.clear()
    direct_conditions[0].set(
        'delay',
        str(target.delay_ms),
    )

    if spec['durationScalable']:
        base_duration_ms = int(spec['defaultDurationMs'])
        _scale_animation_row_duration(
            row,
            base_duration_ms=base_duration_ms,
            requested_duration_ms=duration_ms,
        )

    _apply_timing_options(row, target)
    _append_after_effect(row, target, row_id)
    _append_animation_sound(row, target)

    next_id = first_behavior_id
    for ctn in row.iter(_qn(PML_NS, 'cTn')):
        if ctn is row:
            continue
        ctn.set('id', str(next_id))
        next_id += 1
    for target in row.iter(_qn(PML_NS, 'spTgt')):
        target.set('spid', str(shape_id))
    return ET.tostring(row, encoding='unicode'), next_id


def _build_animation_row_xml(
    target: AnimationTarget,
    trigger: str,
    row_id: int,
    first_behavior_id: int,
) -> tuple[str, int]:
    """Build one canonical PowerPoint-authored animation-pane row."""
    node_type = _TRIGGER_NODE_TYPES[trigger]
    return _instantiate_animation_row(
        target,
        node_type,
        row_id,
        first_behavior_id,
    )


def _main_target_offsets(targets: Sequence[AnimationTarget]) -> list[int]:
    """Return each regular row's start offset within its click group."""
    offsets: list[int] = []
    previous_start_ms = 0
    previous_duration_ms = 0
    has_previous = False
    for target in targets:
        if target.trigger == 'on-click':
            start_ms = target.delay_ms
        elif target.trigger == 'with-previous':
            start_ms = (
                previous_start_ms if has_previous else 0
            ) + target.delay_ms
        else:
            start_ms = (
                previous_start_ms + previous_duration_ms
                if has_previous
                else 0
            ) + target.delay_ms
        if start_ms > MAX_OOXML_MILLISECONDS:
            raise ValueError(
                'animation sequence offset exceeds the OOXML millisecond '
                f'limit at target {len(offsets) + 1}: {start_ms}'
            )
        offsets.append(start_ms)
        previous_start_ms = start_ms
        previous_duration_ms = target.playback_duration_ms
        has_previous = True
    return offsets


def _build_mixed_main_steps(
    targets: Sequence[AnimationTarget],
    next_id: int,
) -> tuple[str, int]:
    """Build one mainSeq containing mixed per-row PowerPoint Start modes."""
    offsets = _main_target_offsets(targets)
    groups: list[list[tuple[AnimationTarget, int]]] = []
    for target, offset_ms in zip(targets, offsets):
        if not groups or target.trigger == 'on-click':
            groups.append([])
        groups[-1].append((target, offset_ms))

    rendered_groups: list[str] = []
    for group in groups:
        group_id = next_id
        next_id += 1
        first_target = group[0][0]
        if first_target.trigger == 'on-click':
            group_conditions = '<p:cond delay="indefinite"/>'
        else:
            group_conditions = (
                '<p:cond delay="indefinite"/>'
                '<p:cond evt="onBegin" delay="0"><p:tn val="2"/></p:cond>'
            )
        rendered_rows: list[str] = []
        for target, offset_ms in group:
            wrapper_id = next_id
            row_id = next_id + 1
            row_xml, next_id = _build_animation_row_xml(
                target,
                target.trigger,
                row_id,
                next_id + 2,
            )
            wrapper_offset_ms = offset_ms - target.delay_ms
            rendered_rows.append(f'''<p:par>
                  <p:cTn id="{wrapper_id}" fill="hold">
                    <p:stCondLst><p:cond delay="{wrapper_offset_ms}"/></p:stCondLst>
                    <p:childTnLst><p:par>{row_xml}</p:par></p:childTnLst>
                  </p:cTn>
                </p:par>''')
        rows_xml = '\n                '.join(rendered_rows)
        rendered_groups.append(f'''<p:par>
                <p:cTn id="{group_id}" fill="hold">
                  <p:stCondLst>{group_conditions}</p:stCondLst>
                  <p:childTnLst>
                    {rows_xml}
                  </p:childTnLst>
                </p:cTn>
              </p:par>''')
    return '\n              '.join(rendered_groups), next_id


def create_sequence_timing_xml(
    targets: list,
    duration: float = 0.3,
    trigger: str = 'after-previous',
) -> str:
    """Generate a multi-target object-animation sequence.

    Args:
        targets: list of (shape_id, delay_ms, animation_name) or
            (shape_id, delay_ms, animation_name, duration_seconds) tuples, in
            the order they should play. ``delay_ms`` is the gap before
            this element starts relative to its Start mode. Mapping targets
            may set an independent ``trigger``; otherwise they inherit the
            function-level ``trigger``. A target with ``trigger_shape_id``
            must use ``on-click`` and runs in an interactive sequence.
        duration: per-element animation duration in seconds. Instantaneous
            native presets retain their PowerPoint-authored duration.
        trigger: PowerPoint-standard Start mode for each element.
            ``'after-previous'`` — first element fires on slide entry,
            rest chain after the previous one with ``delay_ms`` spacing
            (default).
            ``'on-click'`` — one presenter click per element.
            ``'with-previous'`` — all elements start together on slide
            entry.

    Returns:
        A ``<p:timing>`` element string. Returns an empty string when
        ``targets`` is empty.
    """
    trigger = normalize_animation_trigger(trigger)
    default_dur_ms = _seconds_to_ms(
        duration,
        'animation duration',
        allow_zero=False,
    )
    if targets is None or isinstance(targets, (str, bytes)):
        raise ValueError('animation targets must be a sequence of target tuples')
    if not targets:
        return ''
    normalized_targets = [
        _normalize_target(target, default_dur_ms, trigger)
        for target in targets
    ]
    next_id = 3
    main_targets = [
        target
        for target in normalized_targets
        if target.trigger_shape_id is None
    ]
    interactive_targets = [
        target
        for target in normalized_targets
        if target.trigger_shape_id is not None
    ]

    main_triggers = {target.trigger for target in main_targets}
    main_trigger = (
        next(iter(main_triggers))
        if len(main_triggers) == 1
        else None
    )
    needs_per_target_layout = (
        main_trigger is None
        or (
            main_trigger == 'with-previous'
            and any(target.delay_ms for target in main_targets)
        )
    )

    if needs_per_target_layout and main_targets:
        all_steps, next_id = _build_mixed_main_steps(main_targets, next_id)
    elif main_trigger == 'on-click':
        # Each element is an independent click-driven par directly under
        # mainSeq. Three-level nesting per element: outer cTn holds for
        # the click via delay="indefinite", innermost cTn owns the
        # clickEffect + animation children. Each click advances the seq.
        steps = []
        for target in main_targets:
            wrapper_id = next_id
            inner_id = next_id + 1
            leaf_id = next_id + 2
            row_xml, next_id = _build_animation_row_xml(
                target,
                main_trigger,
                leaf_id,
                next_id + 3,
            )
            steps.append(f'''<p:par>
  <p:cTn id="{wrapper_id}" fill="hold">
    <p:stCondLst><p:cond delay="indefinite"/></p:stCondLst>
    <p:childTnLst>
      <p:par>
        <p:cTn id="{inner_id}" fill="hold">
          <p:stCondLst><p:cond delay="0"/></p:stCondLst>
          <p:childTnLst>
            <p:par>
              {row_xml}
            </p:par>
          </p:childTnLst>
        </p:cTn>
      </p:par>
    </p:childTnLst>
  </p:cTn>
</p:par>''')
        all_steps = '\n              '.join(steps)
    else:
        # with-previous / after-previous: wrap the entire cascade in ONE
        # par so the sequence has a real trigger anchor under mainSeq.
        #
        # Native PowerPoint after-previous export uses two timing layers:
        # each row owns its TriggerDelayTime, while its wrapper owns the
        # previous row's absolute end. Their sum is the absolute start offset.
        outer_id = next_id
        next_id += 1
        inner_steps = []
        with_wrapper_id = None
        if main_trigger == 'with-previous':
            with_wrapper_id = next_id
            next_id += 1
        elapsed_ms = 0
        for target_index, target in enumerate(main_targets, 1):
            if main_trigger == 'with-previous':
                leaf_id = next_id
                row_xml, next_id = _build_animation_row_xml(
                    target,
                    main_trigger,
                    leaf_id,
                    next_id + 1,
                )
                inner_steps.append(f'''<p:par>
                  {row_xml}
                </p:par>''')
            else:
                if elapsed_ms > MAX_OOXML_MILLISECONDS:
                    raise ValueError(
                        'animation sequence offset exceeds the OOXML '
                        f'millisecond limit at target {target_index}: {elapsed_ms}'
                    )
                wrapper_id = next_id
                leaf_id = next_id + 1
                row_xml, next_id = _build_animation_row_xml(
                    target,
                    main_trigger,
                    leaf_id,
                    next_id + 2,
                )
                inner_steps.append(f'''<p:par>
                  <p:cTn id="{wrapper_id}" fill="hold">
                    <p:stCondLst><p:cond delay="{elapsed_ms}"/></p:stCondLst>
                    <p:childTnLst>
                      <p:par>
                        {row_xml}
                      </p:par>
                    </p:childTnLst>
                  </p:cTn>
                </p:par>''')
                elapsed_ms += target.delay_ms + target.playback_duration_ms

        inner_xml = '\n                '.join(inner_steps)
        if main_trigger == 'with-previous':
            # Match PowerPoint's native "Start: With Previous" export:
            # one delay=0 wrapper begins on slide entry, and all withEffect
            # rows live under that wrapper so they truly start in parallel.
            inner_xml = f'''<p:par>
                      <p:cTn id="{with_wrapper_id}" fill="hold">
                        <p:stCondLst><p:cond delay="0"/></p:stCondLst>
                        <p:childTnLst>
                          {inner_xml}
                        </p:childTnLst>
                      </p:cTn>
                    </p:par>'''
        if main_trigger in ('with-previous', 'after-previous'):
            # Match PowerPoint's native slide-entry export: the wrapper waits
            # for mainSeq to begin, then child nodes resolve their Start modes.
            outer_start_conditions = (
                '<p:cond delay="indefinite"/>'
                '<p:cond evt="onBegin" delay="0"><p:tn val="2"/></p:cond>'
            )
        else:
            outer_start_conditions = '<p:cond delay="0"/>'
        all_steps = f'''<p:par>
                <p:cTn id="{outer_id}" fill="hold">
                  <p:stCondLst>{outer_start_conditions}</p:stCondLst>
                  <p:childTnLst>
                    {inner_xml}
                  </p:childTnLst>
                </p:cTn>
              </p:par>'''

    if main_targets:
        main_sequence_xml = f'''<p:seq concurrent="1" nextAc="seek">
              <p:cTn id="2" dur="indefinite" nodeType="mainSeq">
                <p:childTnLst>
              {all_steps}
                </p:childTnLst>
              </p:cTn>
              <p:prevCondLst><p:cond evt="onPrev" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:prevCondLst>
              <p:nextCondLst><p:cond evt="onNext" delay="0"><p:tgtEl><p:sldTgt/></p:tgtEl></p:cond></p:nextCondLst>
            </p:seq>'''
    else:
        main_sequence_xml = ''
        next_id = 2

    interactive_sequences: list[str] = []
    for target in interactive_targets:
        sequence_id = next_id
        wrapper_id = next_id + 1
        inner_id = next_id + 2
        row_id = next_id + 3
        row_xml, next_id = _build_animation_row_xml(
            target,
            'on-click',
            row_id,
            next_id + 4,
        )
        trigger_shape_id = target.trigger_shape_id
        interactive_sequences.append(f'''<p:seq concurrent="1" nextAc="seek">
              <p:cTn id="{sequence_id}" restart="whenNotActive" fill="hold" evtFilter="cancelBubble" nodeType="interactiveSeq">
                <p:stCondLst><p:cond evt="onClick" delay="0"><p:tgtEl><p:spTgt spid="{trigger_shape_id}"/></p:tgtEl></p:cond></p:stCondLst>
                <p:endSync evt="end" delay="0"><p:rtn val="all"/></p:endSync>
                <p:childTnLst>
                  <p:par>
                    <p:cTn id="{wrapper_id}" fill="hold">
                      <p:stCondLst><p:cond delay="0"/></p:stCondLst>
                      <p:childTnLst>
                        <p:par>
                          <p:cTn id="{inner_id}" fill="hold">
                            <p:stCondLst><p:cond delay="0"/></p:stCondLst>
                            <p:childTnLst><p:par>{row_xml}</p:par></p:childTnLst>
                          </p:cTn>
                        </p:par>
                      </p:childTnLst>
                    </p:cTn>
                  </p:par>
                </p:childTnLst>
              </p:cTn>
              <p:nextCondLst><p:cond evt="onClick" delay="0"><p:tgtEl><p:spTgt spid="{trigger_shape_id}"/></p:tgtEl></p:cond></p:nextCondLst>
            </p:seq>''')
    sequence_xml = '\n            '.join(
        [
            value
            for value in (
                main_sequence_xml,
                *interactive_sequences,
            )
            if value
        ]
    )

    timing_xml = f'''  <p:timing>
    <p:tnLst>
      <p:par>
        <p:cTn id="1" dur="indefinite" restart="never" nodeType="tmRoot">
          <p:childTnLst>
            {sequence_xml}
          </p:childTnLst>
        </p:cTn>
      </p:par>
    </p:tnLst>
  </p:timing>'''
    if not any(target.bounce_end for target in normalized_targets):
        return timing_xml

    fallback_xml = re.sub(
        r'\s+p14:(?:presetBounceEnd|bounceEnd)="\d+"',
        '',
        timing_xml,
    )
    fallback_xml = fallback_xml.replace(
        f' xmlns:p14="{_P14_NS}"',
        '',
    )
    return f'''  <mc:AlternateContent xmlns:mc="{_MC_NS}">
    <mc:Choice xmlns:p14="{_P14_NS}" Requires="p14">
      {timing_xml}
    </mc:Choice>
    <mc:Fallback>
      {fallback_xml}
    </mc:Fallback>
  </mc:AlternateContent>'''


def pick_animation_effect(
    mode: str,
    idx: int,
    offset: int = 0,
    group_id: str | None = None,
    *,
    rng: random.Random | None = None,
) -> str:
    """Resolve a per-element effect name from a mode string.

    - A specific animation name returns itself (no variation).
    - 'auto': map ``group_id`` to an effect. Image-like ids
      (hero / figure- / image / img- / kpi) cycle through ``_IMAGE_POOL``
      (``entrance_zoom`` / ``entrance_dissolve`` / ``entrance_circle`` /
      ``entrance_box`` / ``entrance_diamond`` / ``entrance_wheel``) by
      ``idx + offset``
      so multiple images vary across the deck. Other semantic matches in
      ``_SEMANTIC_PATTERNS`` return a single stable effect
      (chart→``entrance_wipe``, card-/step-/pillar-→``entrance_fly``,
      title/takeaway→``entrance_fade``). When the id matches no pattern, cycle
      through ``_AUTO_POOL``.
    - 'mixed' (compatible mode name): first element fixed to
      ``entrance_fade``; the rest cycle through ``_MIXED_POOL`` plus
      ``offset`` so titles stay calm while content varies across slides.
    - 'random': uniform seeded choice from the same canonical preset pool.
    Unknown modes fail explicitly; no effect is silently substituted.
    """
    mode = normalize_animation_effect(
        mode,
        allow_none=False,
        allow_modes=True,
    )
    if isinstance(idx, bool) or not isinstance(idx, int) or idx < 0:
        raise ValueError(f'animation index must be a non-negative integer: {idx!r}')
    if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
        raise ValueError(
            f'animation offset must be a non-negative integer: {offset!r}'
        )
    if mode in NATIVE_ANIMATIONS:
        return mode
    if mode == 'auto':
        semantic = _semantic_effect(group_id, idx, offset)
        if semantic is not None:
            return semantic
        return _AUTO_POOL[(idx + offset) % len(_AUTO_POOL)]
    if mode == 'mixed':
        if idx == 0:
            return 'entrance_fade'
        return _MIXED_POOL[(idx - 1 + offset) % len(_MIXED_POOL)]
    if mode == 'random':
        chooser = rng if rng is not None else random
        return chooser.choice(_MIXED_POOL)
    raise AssertionError(f'unhandled animation mode: {mode}')


def _int_attribute(
    element: ET.Element,
    name: str,
    label: str,
    errors: list[str],
    *,
    minimum: int = 0,
    maximum: int | None = None,
) -> int | None:
    value = element.get(name)
    if value is None or not re.fullmatch(r'\d+', value):
        errors.append(f'{label} must be an integer; found {value!r}')
        return None
    number = int(value)
    if number < minimum:
        errors.append(f'{label} must be at least {minimum}; found {number}')
        return None
    if maximum is not None and number > maximum:
        errors.append(f'{label} must be at most {maximum}; found {number}')
        return None
    return number


def _direct_conditions(ctn: ET.Element) -> list[ET.Element]:
    condition_list = ctn.find(_qn(PML_NS, 'stCondLst'))
    if condition_list is None:
        return []
    return [
        child for child in list(condition_list)
        if child.tag == _qn(PML_NS, 'cond')
    ]


def _shape_index(
    slide_root: ET.Element,
) -> tuple[dict[int, tuple[str, bool]], list[str]]:
    parent_map = {
        child: parent
        for parent in slide_root.iter()
        for child in list(parent)
    }
    index: dict[int, tuple[str, bool]] = {}
    errors: list[str] = []
    shape_tags = {'sp', 'grpSp', 'pic', 'graphicFrame', 'cxnSp', 'contentPart'}
    for non_visual in slide_root.iter(_qn(PML_NS, 'cNvPr')):
        shape_id = _int_attribute(
            non_visual,
            'id',
            'p:cNvPr@id',
            errors,
            minimum=1,
            maximum=MAX_OOXML_UNSIGNED_INT,
        )
        if shape_id is None:
            continue
        owner = parent_map.get(non_visual)
        while owner is not None and _local_name(owner.tag) not in shape_tags:
            owner = parent_map.get(owner)
        kind = _local_name(owner.tag) if owner is not None else 'unknown'
        has_text = bool(
            owner is not None
            and kind == 'sp'
            and any(
                _local_name(element.tag) == 't' and (element.text or '').strip()
                for element in owner.iter()
            )
        )
        if shape_id in index:
            errors.append(f'duplicate p:cNvPr@id {shape_id}')
        else:
            index[shape_id] = (kind, has_text)
    return index, errors


def _row_shape_id(row: ET.Element, errors: list[str]) -> int | None:
    shape_ids: list[int] = []
    for target in row.iter(_qn(PML_NS, 'spTgt')):
        value = _int_attribute(
            target,
            'spid',
            'animation p:spTgt@spid',
            errors,
            minimum=1,
            maximum=MAX_OOXML_UNSIGNED_INT,
        )
        if value is not None:
            shape_ids.append(value)
    unique = sorted(set(shape_ids))
    if len(unique) != 1:
        errors.append(
            'one object-animation row must resolve to exactly one shape id; '
            f'found {unique or "none"}'
        )
        return None
    return unique[0]


def _row_filter(
    row: ET.Element,
    preset_class: str,
    errors: list[str],
) -> str | None:
    effects = list(row.iter(_qn(PML_NS, 'animEffect')))
    if len(effects) > 1:
        errors.append(
            f'object-animation row contains {len(effects)} p:animEffect nodes'
        )
    if not effects:
        return None
    effect = effects[0]
    expected_transition = {'entr': 'in', 'exit': 'out'}.get(preset_class)
    if expected_transition is not None and effect.get('transition') != expected_transition:
        errors.append(
            f'{preset_class} p:animEffect must set '
            f'transition="{expected_transition}"'
        )
    return effect.get('filter')


def _resolve_row_effect(
    row: ET.Element,
    filter_name: str | None,
    errors: list[str],
) -> tuple[tuple[str, ...], str | None, int | None, int | None]:
    preset_class = row.get('presetClass')
    if preset_class not in set(_PRESET_CLASS_BY_CATEGORY.values()):
        errors.append(
            f'unsupported p:cTn@presetClass {preset_class!r}; expected '
            + ', '.join(sorted(set(_PRESET_CLASS_BY_CATEGORY.values())))
        )
        return (), preset_class, None, None
    preset_id = _int_attribute(
        row,
        'presetID',
        'object-animation p:cTn@presetID',
        errors,
        maximum=MAX_OOXML_UNSIGNED_INT,
    )
    preset_subtype = _int_attribute(
        row,
        'presetSubtype',
        'object-animation p:cTn@presetSubtype',
        errors,
        maximum=MAX_OOXML_UNSIGNED_INT,
    )
    if preset_id is None or preset_subtype is None:
        return (), preset_class, preset_id, preset_subtype
    matches = [
        key
        for key, info in NATIVE_ANIMATIONS.items()
        if info['presetClass'] == preset_class
        if int(info['presetID']) == preset_id
    ]
    return tuple(matches), preset_class, preset_id, preset_subtype


def _behavior_duration_ms(
    row: ET.Element,
    errors: list[str],
) -> int | None:
    duration = _row_effective_duration_ms(row)
    if duration is not None and duration > MAX_OOXML_MILLISECONDS:
        errors.append(
            'object-animation behavior duration exceeds the OOXML '
            f'millisecond limit: {duration}'
        )
    return duration


def _read_animation_color(
    row: ET.Element,
    errors: list[str],
    label: str,
) -> str | None:
    colors = [
        element
        for element in row.iter()
        if element.tag in {
            _qn(_DML_NS, 'srgbClr'),
            _qn(_DML_NS, 'schemeClr'),
        }
    ]
    if not colors:
        errors.append(f'{label} is missing its color value')
        return None
    rendered = []
    for color in colors:
        if color.tag == _qn(_DML_NS, 'srgbClr'):
            value = color.get('val')
            rendered.append(f'#{value.upper()}' if value else '')
        else:
            value = color.get('val')
            rendered.append(f'theme:{value}' if value else '')
    unique = tuple(dict.fromkeys(value for value in rendered if value))
    if len(unique) != 1:
        errors.append(f'{label} contains inconsistent color values: {unique!r}')
        return None
    return unique[0] if unique else None


def _read_effect_options(
    row: ET.Element,
    effect: str | None,
    filter_name: str | None,
    errors: list[str],
) -> dict[str, object]:
    if effect is None:
        return {}
    option_specs = NATIVE_ANIMATIONS[effect]['effectOptions']
    values: dict[str, object] = {}
    for name, spec in option_specs.items():
        option_type = spec['type']
        if option_type == 'enum':
            matches = []
            for value, variant_xml in spec['values'].items():
                variant = ET.fromstring(variant_xml)
                variant_filter = _row_filter(variant, variant.get('presetClass', ''), [])
                if variant.get('presetSubtype') != row.get('presetSubtype'):
                    continue
                if variant_filter != filter_name:
                    continue
                matches.append(value)
            if len(matches) != 1:
                errors.append(
                    f'animation effect {effect!r} option {name!r} could not be '
                    f'read from presetSubtype={row.get("presetSubtype")!r}, '
                    f'filter={filter_name!r}'
                )
                continue
            value: object = matches[0]
            if name == 'amount' and re.fullmatch(r'\d+', str(value)):
                value = int(str(value))
            values[name] = value
        elif name == 'amount' and effect == 'emphasis_spin':
            rotations = list(row.iter(_qn(PML_NS, 'animRot')))
            raw = rotations[0].get('by') if len(rotations) == 1 else None
            if raw is None or not re.fullmatch(r'-?\d+', raw):
                errors.append('emphasis_spin row has an invalid rotation amount')
            else:
                values[name] = int(raw) / 60000
        elif name == 'amount' and effect == 'emphasis_transparency':
            opacity_values = []
            for node in row.iter(_qn(PML_NS, 'set')):
                attributes = {
                    (attribute.text or '').strip()
                    for attribute in node.iter(_qn(PML_NS, 'attrName'))
                }
                if 'style.opacity' in attributes:
                    opacity_values.extend(
                        value.get('val')
                        for value in node.iter(_qn(PML_NS, 'strVal'))
                    )
            if len(opacity_values) != 1:
                errors.append('emphasis_transparency row has an invalid opacity')
            else:
                try:
                    values[name] = 1 - float(str(opacity_values[0]))
                except ValueError:
                    errors.append(
                        'emphasis_transparency row has a non-numeric opacity'
                    )
        elif name == 'color':
            color = _read_animation_color(
                row,
                errors,
                f'animation effect {effect!r} color option',
            )
            if color is not None:
                values[name] = color
        elif name == 'font_name':
            fonts = []
            for node in row.iter(_qn(PML_NS, 'set')):
                attributes = {
                    (attribute.text or '').strip()
                    for attribute in node.iter(_qn(PML_NS, 'attrName'))
                }
                if 'style.fontFamily' in attributes:
                    fonts.extend(
                        value.get('val')
                        for value in node.iter(_qn(PML_NS, 'strVal'))
                    )
            if len(fonts) != 1:
                errors.append(
                    'emphasis_change_font row must contain one font name'
                )
                continue
            try:
                values[name] = _normalize_powerpoint_font_name(
                    fonts[0],
                    'emphasis_change_font row font name',
                )
            except ValueError as exc:
                errors.append(str(exc))
        elif name == 'relative':
            motions = list(row.iter(_qn(PML_NS, 'animMotion')))
            if len(motions) != 1:
                errors.append(f'motion-path effect {effect!r} has no single path')
            else:
                values[name] = motions[0].get('pathEditMode') != 'fixed'
        elif name == 'size':
            scales = list(row.iter(_qn(PML_NS, 'animScale')))
            targets = (
                scales[0].findall(_qn(PML_NS, 'to'))
                if len(scales) == 1
                else []
            )
            raw = (
                targets[0].get('x')
                if len(targets) == 1
                else '100000' if not targets and len(scales) == 1 else None
            )
            if raw is None or not re.fullmatch(r'\d+', raw):
                errors.append('emphasis_grow_shrink row has an invalid size')
            else:
                values[name] = int(raw) / 1000
        else:
            errors.append(
                f'animation effect {effect!r} option {name!r} has no reader'
            )
    return values


def _timing_summary(
    row: ET.Element,
    duration_ms: int | None,
    errors: list[str],
) -> tuple[
    float | None,
    int | None,
    bool,
    bool,
    float,
    float,
    float,
    str,
    int | None,
]:
    raw_repeat_count = row.get('repeatCount')
    repeat_count = None
    if raw_repeat_count is not None:
        if not re.fullmatch(r'\d+', raw_repeat_count):
            errors.append(
                f'object-animation repeatCount must be numeric; found '
                f'{raw_repeat_count!r}'
            )
        else:
            repeat_count = int(raw_repeat_count) / 1000
    raw_repeat_duration = row.get('repeatDur')
    repeat_duration_ms = None
    if raw_repeat_duration is not None:
        if not re.fullmatch(r'\d+', raw_repeat_duration):
            errors.append(
                f'object-animation repeatDur must be numeric; found '
                f'{raw_repeat_duration!r}'
            )
        else:
            repeat_duration_ms = int(raw_repeat_duration)
    if repeat_count is not None and repeat_duration_ms is not None:
        errors.append('object-animation row sets both repeatCount and repeatDur')

    def ratio(attribute: str) -> float:
        raw = row.get(attribute)
        if raw is None:
            return 0.0
        if not re.fullmatch(r'\d+', raw):
            errors.append(
                f'object-animation {attribute} must be numeric; found {raw!r}'
            )
            return 0.0
        number = int(raw)
        if number > 100000:
            errors.append(
                f'object-animation {attribute} exceeds 100000; found {number}'
            )
        return number / 100000

    accelerate = ratio('accel')
    decelerate = ratio('decel')
    if accelerate + decelerate > 1:
        errors.append('object-animation accel + decel exceeds 100000')
    raw_bounce_values = {
        node.get(_qn(_P14_NS, 'bounceEnd'))
        for node in row.iter()
        if node.get(_qn(_P14_NS, 'bounceEnd')) is not None
    }
    preset_bounce = row.get(_qn(_P14_NS, 'presetBounceEnd'))
    if preset_bounce is not None:
        raw_bounce_values.add(preset_bounce)
    bounce_end = 0.0
    if len(raw_bounce_values) > 1:
        errors.append(
            'object-animation behaviors disagree on p14:bounceEnd'
        )
    elif raw_bounce_values:
        raw_bounce = next(iter(raw_bounce_values))
        if raw_bounce is None or not re.fullmatch(r'\d+', raw_bounce):
            errors.append(
                f'object-animation p14:bounceEnd must be numeric; '
                f'found {raw_bounce!r}'
            )
        else:
            bounce_value = int(raw_bounce)
            if bounce_value > 100000:
                errors.append(
                    'object-animation p14:bounceEnd exceeds 100000; '
                    f'found {bounce_value}'
                )
            bounce_end = bounce_value / 100000
    auto_reverse = row.get('autoRev') == '1'
    rewind = row.get('fill') == 'remove'
    restart = {
        None: 'never',
        'always': 'always',
        'whenNotActive': 'when-not-active',
        'never': 'never',
    }.get(row.get('restart'))
    if restart is None:
        errors.append(
            f'object-animation restart has unknown value: {row.get("restart")!r}'
        )
        restart = 'never'

    playback_duration_ms = None
    if duration_ms is not None:
        one_play = duration_ms * (2 if auto_reverse else 1)
        if repeat_duration_ms is not None:
            playback_duration_ms = repeat_duration_ms
        elif repeat_count is not None:
            playback_duration_ms = max(1, round(one_play * repeat_count))
        else:
            playback_duration_ms = one_play
    return (
        repeat_count,
        repeat_duration_ms,
        auto_reverse,
        rewind,
        accelerate,
        decelerate,
        bounce_end,
        restart,
        playback_duration_ms,
    )


def _after_effect_summary(
    row: ET.Element,
    errors: list[str],
) -> tuple[str, str | None]:
    sub_timing = row.find(_qn(PML_NS, 'subTnLst'))
    if sub_timing is None:
        return 'none', None
    after_nodes = [
        node
        for node in list(sub_timing)
        if any(
            ctn.get('afterEffect') == '1'
            for ctn in node.iter(_qn(PML_NS, 'cTn'))
        )
    ]
    if not after_nodes:
        return 'none', None
    if len(after_nodes) != 1:
        errors.append(
            f'object-animation row contains {len(after_nodes)} after effects'
        )
        return 'none', None
    node = after_nodes[0]
    if node.tag == _qn(PML_NS, 'animClr'):
        return (
            'dim',
            _read_animation_color(node, errors, 'animation dim after effect'),
        )
    if node.tag == _qn(PML_NS, 'set'):
        ctns = list(node.iter(_qn(PML_NS, 'cTn')))
        master_relation = ctns[0].get('masterRel') if ctns else None
        if master_relation == 'sameClick':
            return 'hide', None
        if master_relation == 'nextClick':
            return 'hide-on-next-click', None
    errors.append('object-animation row contains an unknown after effect')
    return 'none', None


def _sound_summary(
    row: ET.Element,
    errors: list[str],
) -> tuple[str | None, str | None]:
    sounds = list(row.iter(_qn(PML_NS, 'sndTgt')))
    if not sounds:
        return None, None
    if len(sounds) != 1:
        errors.append(f'object-animation row contains {len(sounds)} sounds')
        return None, None
    relationship_id = sounds[0].get(_qn(_REL_NS, 'embed'))
    name = sounds[0].get('name')
    if relationship_id is None or name is None:
        errors.append('object-animation sound is missing relationship id or name')
    return relationship_id, name


def _row_trigger_shape_id(
    row: ET.Element,
    parent_map: Mapping[ET.Element, ET.Element],
    errors: list[str],
) -> int | None:
    current = parent_map.get(row)
    while current is not None:
        if (
            current.tag == _qn(PML_NS, 'cTn')
            and current.get('nodeType') == 'interactiveSeq'
        ):
            shape_targets = [
                target
                for condition in _direct_conditions(current)
                if condition.get('evt') == 'onClick'
                for target in condition.iter(_qn(PML_NS, 'spTgt'))
            ]
            if len(shape_targets) != 1:
                errors.append(
                    'interactive animation sequence must have one trigger shape'
                )
                return None
            return _int_attribute(
                shape_targets[0],
                'spid',
                'interactive animation trigger shape id',
                errors,
                minimum=1,
                maximum=MAX_OOXML_UNSIGNED_INT,
            )
        current = parent_map.get(current)
    return None


def _row_offset_ms(
    row: ET.Element,
    trigger: str,
    trigger_shape_id: int | None,
    parent_map: Mapping[ET.Element, ET.Element],
    errors: list[str],
) -> int:
    leaf_conditions = _direct_conditions(row)
    leaf_delay = None
    if (
        len(leaf_conditions) == 1
        and re.fullmatch(r'\d+', leaf_conditions[0].get('delay') or '')
    ):
        leaf_delay = int(leaf_conditions[0].get('delay', '0'))
    else:
        errors.append(
            'object-animation row must have one numeric leaf start condition'
        )
    current = parent_map.get(row)
    saw_indefinite = False
    numeric_offset: int | None = None
    while current is not None:
        if current.tag == _qn(PML_NS, 'cTn'):
            conditions = _direct_conditions(current)
            if any(condition.get('delay') == 'indefinite' for condition in conditions):
                saw_indefinite = True
            if trigger in {'with-previous', 'after-previous'}:
                numeric = [
                    condition.get('delay')
                    for condition in conditions
                    if condition.get('evt') is None
                    and re.fullmatch(r'\d+', condition.get('delay') or '')
                ]
                if numeric and numeric_offset is None:
                    numeric_offset = int(numeric[0])
                    if numeric_offset > MAX_OOXML_MILLISECONDS:
                        errors.append(
                            'animation row offset exceeds the OOXML '
                            f'millisecond limit: {numeric_offset}'
                        )
        current = parent_map.get(current)

    if (
        trigger == 'on-click'
        and trigger_shape_id is None
        and not saw_indefinite
    ):
        errors.append(
            'on-click object-animation row is missing an indefinite click wrapper'
        )
    if trigger in {'with-previous', 'after-previous'} and not saw_indefinite:
        errors.append(f'{trigger} sequence is missing its sequence anchor')
    if trigger in {'with-previous', 'after-previous'} and numeric_offset is None:
        errors.append(
            f'{trigger} object-animation row is missing its numeric offset wrapper'
        )
    if trigger in {'with-previous', 'after-previous'}:
        absolute_offset = (numeric_offset or 0) + (leaf_delay or 0)
        if absolute_offset > MAX_OOXML_MILLISECONDS:
            errors.append(
                'animation row absolute offset exceeds the OOXML '
                f'millisecond limit: {absolute_offset}'
            )
        return absolute_offset
    if trigger_shape_id is not None:
        return leaf_delay or 0
    return leaf_delay or 0


def _row_matches_powerpoint_behavior(
    row: ET.Element,
    *,
    shape_id: int,
    effect: str,
    effect_options: Mapping[str, object],
    trigger: str,
    duration_ms: int,
    repeat_count: float | None,
    repeat_duration_ms: int | None,
    auto_reverse: bool,
    rewind: bool,
    accelerate: float,
    decelerate: float,
    bounce_end: float,
    restart: str,
    after_effect: str,
    after_effect_color: str | None,
    sound_relationship_id: str | None,
    sound_name: str | None,
) -> bool:
    """Match one read-back row to its reconstructed native behavior tree."""
    spec = NATIVE_ANIMATIONS[effect]
    target = AnimationTarget(
        shape_id=shape_id,
        delay_ms=0,
        effect=effect,
        duration_ms=duration_ms,
        effect_options=effect_options,
        trigger=trigger,
        repeat_count=repeat_count,
        repeat_duration_ms=repeat_duration_ms,
        auto_reverse=True if auto_reverse else None,
        rewind=True if rewind else None,
        accelerate=accelerate or None,
        decelerate=decelerate or None,
        bounce_end=bounce_end or None,
        restart=restart if row.get('restart') is not None else None,
        after_effect=after_effect,
        after_effect_color=after_effect_color,
        sound_relationship_id=sound_relationship_id,
        sound_name=sound_name,
    )
    option_candidates = [dict(effect_options)]
    option_candidates.extend(
        {
            name: value
            for name, value in effect_options.items()
            if name != omitted
        }
        for omitted in effect_options
    )
    option_candidates.append({})
    seen: set[tuple[tuple[str, object], ...]] = set()
    for candidate in option_candidates:
        candidate_key = tuple(sorted(candidate.items()))
        if candidate_key in seen:
            continue
        seen.add(candidate_key)
        expected = _animation_row_for_options(effect, candidate)
        if spec['durationScalable']:
            _scale_animation_row_duration(
                expected,
                base_duration_ms=int(spec['defaultDurationMs']),
                requested_duration_ms=duration_ms,
            )
        _apply_timing_options(expected, target)
        row_id = row.get('id')
        expected.set('id', row_id if row_id and row_id.isdigit() else '1')
        _append_after_effect(
            expected,
            target,
            int(expected.get('id', '1')),
        )
        _append_animation_sound(expected, target)
        if _animation_spec_matches_row(
            row,
            {'rowXml': ET.tostring(expected, encoding='unicode')},
        ):
            return True
    return False


def _animation_rows(
    slide_root: ET.Element,
    errors: list[str],
    *,
    require_behavior_signatures: bool = False,
) -> list[AnimationRowSummary]:
    parent_map = {
        child: parent
        for parent in slide_root.iter()
        for child in list(parent)
    }
    rows: list[AnimationRowSummary] = []
    for row in slide_root.iter(_qn(PML_NS, 'cTn')):
        preset_class = row.get('presetClass')
        if preset_class not in set(_PRESET_CLASS_BY_CATEGORY.values()):
            continue
        node_type = row.get('nodeType')
        trigger = _NODE_TYPE_TRIGGERS.get(node_type or '')
        if trigger is None:
            errors.append(
                f'unsupported object-animation nodeType {node_type!r}; expected '
                f'{", ".join(_NODE_TYPE_TRIGGERS)}'
            )
            continue
        shape_id = _row_shape_id(row, errors)
        filter_name = _row_filter(row, preset_class, errors)
        supported_effects, resolved_class, preset_id, preset_subtype = (
            _resolve_row_effect(
            row,
            filter_name,
            errors,
        )
        )
        duration_ms = _behavior_duration_ms(row, errors)
        trigger_shape_id = _row_trigger_shape_id(row, parent_map, errors)
        offset_ms = _row_offset_ms(
            row,
            trigger,
            trigger_shape_id,
            parent_map,
            errors,
        )
        resolved_effect = supported_effects[0] if supported_effects else None
        effect_options = _read_effect_options(
            row,
            resolved_effect,
            filter_name,
            errors,
        )
        (
            repeat_count,
            repeat_duration_ms,
            auto_reverse,
            rewind,
            accelerate,
            decelerate,
            bounce_end,
            restart,
            playback_duration_ms,
        ) = _timing_summary(row, duration_ms, errors)
        after_effect, after_effect_color = _after_effect_summary(row, errors)
        sound_relationship_id, sound_name = _sound_summary(row, errors)
        if (
            shape_id is None
            or resolved_class is None
            or preset_id is None
            or preset_subtype is None
        ):
            continue
        if (
            require_behavior_signatures
            and resolved_effect is not None
            and duration_ms is not None
            and not _row_matches_powerpoint_behavior(
                row,
                shape_id=shape_id,
                effect=resolved_effect,
                effect_options=effect_options,
                trigger=trigger,
                duration_ms=duration_ms,
                repeat_count=repeat_count,
                repeat_duration_ms=repeat_duration_ms,
                auto_reverse=auto_reverse,
                rewind=rewind,
                accelerate=accelerate,
                decelerate=decelerate,
                bounce_end=bounce_end,
                restart=restart,
                after_effect=after_effect,
                after_effect_color=after_effect_color,
                sound_relationship_id=sound_relationship_id,
                sound_name=sound_name,
            )
        ):
            errors.append(
                'object-animation PowerPoint-authored behavior tree changed '
                f'for shape {shape_id}'
            )
        rows.append(
            AnimationRowSummary(
                shape_id=shape_id,
                effect=resolved_effect,
                supported_effects=supported_effects,
                preset_class=resolved_class,
                trigger=trigger,
                duration_ms=duration_ms,
                offset_ms=offset_ms,
                preset_id=preset_id,
                preset_subtype=preset_subtype,
                filter_name=filter_name,
                effect_options=effect_options,
                trigger_shape_id=trigger_shape_id,
                repeat_count=repeat_count,
                repeat_duration_ms=repeat_duration_ms,
                auto_reverse=auto_reverse,
                rewind=rewind,
                accelerate=accelerate,
                decelerate=decelerate,
                bounce_end=bounce_end,
                restart=restart,
                after_effect=after_effect,
                after_effect_color=after_effect_color,
                sound_relationship_id=sound_relationship_id,
                sound_name=sound_name,
                playback_duration_ms=playback_duration_ms,
            )
        )
    return rows


def _select_supported_timing_branch(slide_root: ET.Element) -> ET.Element:
    """Project p14 AlternateContent timing onto one effective slide tree."""
    projected = copy.deepcopy(slide_root)
    alternate_tag = _qn(_MC_NS, 'AlternateContent')
    choice_tag = _qn(_MC_NS, 'Choice')
    fallback_tag = _qn(_MC_NS, 'Fallback')
    for index, child in list(enumerate(list(projected))):
        if child.tag != alternate_tag:
            continue
        branches = [
            branch
            for branch in list(child)
            if branch.tag in {choice_tag, fallback_tag}
        ]
        selected_timing = None
        for branch in branches:
            if (
                branch.tag == choice_tag
                and 'p14' in (branch.get('Requires') or '').split()
            ):
                selected_timing = branch.find(_qn(PML_NS, 'timing'))
                if selected_timing is not None:
                    break
        if selected_timing is None:
            for branch in branches:
                selected_timing = branch.find(_qn(PML_NS, 'timing'))
                if selected_timing is not None:
                    break
        if selected_timing is None:
            continue
        projected.remove(child)
        projected.insert(index, copy.deepcopy(selected_timing))
    return projected


def validate_slide_animation_structure(
    slide_root: ET.Element,
    *,
    require_supported_effects: bool = False,
) -> list[str]:
    """Return root timing, target, and generated-object structure errors."""
    errors: list[str] = []
    if slide_root.tag != _qn(PML_NS, 'sld'):
        return ['animation validation requires a PresentationML p:sld root']
    slide_root = _select_supported_timing_branch(slide_root)

    direct_timings = [
        child for child in list(slide_root)
        if child.tag == _qn(PML_NS, 'timing')
    ]
    all_timings = list(slide_root.iter(_qn(PML_NS, 'timing')))
    nested_count = len(all_timings) - len(direct_timings)
    if nested_count:
        errors.append(
            f'slide contains {nested_count} nested p:timing element(s); '
            'timing must be a direct child of p:sld'
        )
    if len(direct_timings) > 1:
        errors.append(
            f'slide has {len(direct_timings)} root p:timing elements; expected at most 1'
        )
    if not direct_timings:
        return errors

    timing = direct_timings[0]
    root_children = list(slide_root)
    timing_index = root_children.index(timing)
    for required_before in ('cSld', 'clrMapOvr', 'transition'):
        sibling = next(
            (
                child for child in root_children
                if child.tag == _qn(PML_NS, required_before)
            ),
            None,
        )
        if sibling is not None and root_children.index(sibling) > timing_index:
            errors.append(f'p:{required_before} must precede p:timing')
    extension_list = next(
        (
            child for child in root_children
            if child.tag == _qn(PML_NS, 'extLst')
        ),
        None,
    )
    if extension_list is not None and root_children.index(extension_list) < timing_index:
        errors.append('root p:extLst must follow p:timing')

    timing_children = list(timing)
    timing_name_order = [_local_name(child.tag) for child in timing_children]
    if 'bldLst' in timing_name_order and 'tnLst' in timing_name_order:
        if timing_name_order.index('bldLst') < timing_name_order.index('tnLst'):
            errors.append('p:tnLst must precede p:bldLst')

    parent_map = {
        child: parent
        for parent in timing.iter()
        for child in list(parent)
    }
    ctn_ids: list[int] = []
    for ctn in timing.iter(_qn(PML_NS, 'cTn')):
        if ctn.get('id') is None:
            ancestor = parent_map.get(ctn)
            while ancestor is not None and ancestor is not timing:
                if ancestor.tag == _qn(PML_NS, 'subTnLst'):
                    break
                ancestor = parent_map.get(ancestor)
            if (
                ancestor is not None
                and ancestor.tag == _qn(PML_NS, 'subTnLst')
            ):
                continue
        value = _int_attribute(
            ctn,
            'id',
            'p:cTn@id',
            errors,
            maximum=MAX_OOXML_UNSIGNED_INT,
        )
        if value is not None:
            ctn_ids.append(value)
    duplicates = sorted(
        value for value in set(ctn_ids) if ctn_ids.count(value) > 1
    )
    if duplicates:
        errors.append(
            'duplicate p:cTn@id values: ' + ', '.join(map(str, duplicates))
        )

    roots = [
        node for node in timing.iter(_qn(PML_NS, 'cTn'))
        if node.get('nodeType') == 'tmRoot'
    ]
    if len(roots) != 1:
        errors.append(
            f'p:timing must contain exactly one tmRoot time node; found {len(roots)}'
        )

    shape_index, shape_errors = _shape_index(slide_root)
    errors.extend(shape_errors)
    for target in timing.iter(_qn(PML_NS, 'spTgt')):
        shape_id = _int_attribute(
            target,
            'spid',
            'p:spTgt@spid',
            errors,
            minimum=1,
            maximum=MAX_OOXML_UNSIGNED_INT,
        )
        if shape_id is not None and shape_id not in shape_index:
            errors.append(f'p:spTgt references missing shape id {shape_id}')

    build_keys: list[tuple[int, int]] = []
    for build in timing.iter(_qn(PML_NS, 'bldP')):
        shape_id = _int_attribute(
            build,
            'spid',
            'p:bldP@spid',
            errors,
            minimum=1,
            maximum=MAX_OOXML_UNSIGNED_INT,
        )
        group_id = _int_attribute(
            build,
            'grpId',
            'p:bldP@grpId',
            errors,
            maximum=MAX_OOXML_UNSIGNED_INT,
        )
        if shape_id is None or group_id is None:
            continue
        build_keys.append((shape_id, group_id))
        kind, has_text = shape_index.get(shape_id, ('missing', False))
        if kind == 'missing':
            errors.append(f'p:bldP references missing shape id {shape_id}')
        elif require_supported_effects and (kind != 'sp' or not has_text):
            errors.append(
                f'p:bldP shape id {shape_id} must reference a text-bearing p:sp; '
                f'found {kind}'
            )
    if len(build_keys) != len(set(build_keys)):
        errors.append('p:bldP (spid, grpId) pairs must be unique')

    animation_nodes = [
        node for node in timing.iter(_qn(PML_NS, 'cTn'))
        if node.get('presetClass') in set(_PRESET_CLASS_BY_CATEGORY.values())
    ]
    if require_supported_effects:
        rows = _animation_rows(
            slide_root,
            errors,
            require_behavior_signatures=True,
        )
        if not rows and animation_nodes:
            errors.append('generated object-animation rows could not be read back')
    else:
        rows = []
    if rows:
        regular_rows = [
            row for row in rows if row.trigger_shape_id is None
        ]
        interactive_rows = [
            row for row in rows if row.trigger_shape_id is not None
        ]
        main_sequences = [
            node for node in timing.iter(_qn(PML_NS, 'cTn'))
            if node.get('nodeType') == 'mainSeq'
        ]
        expected_main_sequences = 1 if regular_rows else 0
        if len(main_sequences) != expected_main_sequences:
            errors.append(
                'generated object-animation rows require '
                f'{expected_main_sequences} mainSeq time node(s); found '
                f'{len(main_sequences)}'
            )
        interactive_sequences = [
            node for node in timing.iter(_qn(PML_NS, 'cTn'))
            if node.get('nodeType') == 'interactiveSeq'
        ]
        if len(interactive_sequences) != len(interactive_rows):
            errors.append(
                'each generated trigger-shape animation must have one '
                'interactiveSeq time node'
            )
        for row in rows:
            if (
                row.trigger_shape_id is not None
                and row.trigger != 'on-click'
            ):
                errors.append(
                    'trigger-shape animation must use the on-click Start mode '
                    f'for shape {row.shape_id}'
                )
            if row.trigger_shape_id == row.shape_id:
                errors.append(
                    'animation target and trigger shape must differ for shape '
                    f'{row.shape_id}'
                )
            if not row.supported_effects:
                errors.append(
                    'unsupported object-animation effect tuple for shape '
                    f'{row.shape_id}: presetClass={row.preset_class}, '
                    f'presetID={row.preset_id}, '
                    f'presetSubtype={row.preset_subtype}, '
                    f'filter={row.filter_name!r}'
                )
    return errors


def read_slide_animation_sequence(
    slide_xml: str | bytes,
    *,
    require_supported_effects: bool = False,
) -> AnimationSequenceSummary:
    """Read and validate the logical object-animation sequence from slide XML."""
    data = slide_xml.encode('utf-8') if isinstance(slide_xml, str) else slide_xml
    try:
        root = ET.fromstring(data)
    except ET.ParseError as exc:
        raise ValueError(f'invalid slide XML: {exc}') from exc
    root = _select_supported_timing_branch(root)
    errors = validate_slide_animation_structure(
        root,
        require_supported_effects=require_supported_effects,
    )
    row_errors: list[str] = []
    rows = _animation_rows(
        root,
        row_errors,
        require_behavior_signatures=require_supported_effects,
    )
    for error in row_errors:
        if error not in errors:
            errors.append(error)
    if errors:
        raise ValueError('; '.join(errors))
    direct_timings = [
        child for child in list(root)
        if child.tag == _qn(PML_NS, 'timing')
    ]
    audio_targets: list[int] = []
    for audio in root.iter(_qn(PML_NS, 'audio')):
        for target in audio.iter(_qn(PML_NS, 'spTgt')):
            value = target.get('spid')
            if value and value.isdigit():
                audio_targets.append(int(value))
    regular_triggers = {
        row.trigger for row in rows if row.trigger_shape_id is None
    }
    trigger = (
        next(iter(regular_triggers))
        if len(regular_triggers) == 1
        else ('on-click' if rows and not regular_triggers else None)
    )
    return AnimationSequenceSummary(
        timing_count=len(direct_timings),
        trigger=trigger,
        rows=tuple(rows),
        audio_target_ids=tuple(audio_targets),
    )


def validate_generated_animation_xml(
    slide_xml: str | bytes,
    targets: Sequence[Sequence[object] | Mapping[str, object]],
    *,
    duration: float = 0.3,
    trigger: str = 'after-previous',
) -> AnimationSequenceSummary:
    """Read back one generated sequence and require exact requested semantics."""
    trigger = normalize_animation_trigger(trigger)
    default_duration_ms = _seconds_to_ms(
        duration,
        'animation duration',
        allow_zero=False,
    )
    normalized_expected = tuple(
        _normalize_target(target, default_duration_ms, trigger)
        for target in targets
    )
    # PowerPoint stores ordinary rows in mainSeq and shape-triggered rows in
    # separate interactiveSeq containers. Their relative cross-sequence order
    # has no playback meaning, so read-back follows the native container order.
    expected = tuple(
        target
        for target in normalized_expected
        if target.trigger_shape_id is None
    ) + tuple(
        target
        for target in normalized_expected
        if target.trigger_shape_id is not None
    )
    summary = read_slide_animation_sequence(
        slide_xml,
        require_supported_effects=True,
    )
    data = slide_xml.encode('utf-8') if isinstance(slide_xml, str) else slide_xml
    actual_root = _select_supported_timing_branch(ET.fromstring(data))
    actual_row_elements = [
        row
        for row in actual_root.iter(_qn(PML_NS, 'cTn'))
        if row.get('presetClass') in set(_PRESET_CLASS_BY_CATEGORY.values())
    ]
    errors: list[str] = []
    if len(summary.rows) != len(expected):
        errors.append(
            f'animation read-back row count is {len(summary.rows)}; '
            f'expected {len(expected)}'
        )
    expected_main_targets = tuple(
        target for target in expected if target.trigger_shape_id is None
    )
    expected_main_triggers = {
        target.trigger for target in expected_main_targets
    }
    expected_sequence_trigger = (
        (
            next(iter(expected_main_triggers))
            if len(expected_main_triggers) == 1
            else None
        )
        if expected_main_targets
        else ('on-click' if expected else None)
    )
    if expected and summary.trigger != expected_sequence_trigger:
        errors.append(
            f'animation read-back trigger is {summary.trigger!r}; '
            f'expected {expected_sequence_trigger!r}'
        )

    try:
        main_offsets = iter(_main_target_offsets(expected_main_targets))
    except ValueError as exc:
        errors.append(str(exc))
        main_offsets = iter(())
    expected_offsets = [
        (
            target.delay_ms
            if target.trigger_shape_id is not None
            else next(main_offsets, 0)
        )
        for target in expected
    ]

    for index, (actual, target) in enumerate(zip(summary.rows, expected), 1):
        spec = NATIVE_ANIMATIONS[target.effect]
        expected_row = _animation_row_for_options(
            target.effect,
            target.effect_options,
        )
        if spec['durationScalable']:
            _scale_animation_row_duration(
                expected_row,
                base_duration_ms=int(spec['defaultDurationMs']),
                requested_duration_ms=target.duration_ms,
            )
        _apply_timing_options(expected_row, target)
        option_errors: list[str] = []
        expected_filter = _row_filter(
            expected_row,
            str(spec['presetClass']),
            option_errors,
        )
        expected_options = _read_effect_options(
            expected_row,
            target.effect,
            expected_filter,
            option_errors,
        )
        (
            expected_repeat_count,
            expected_repeat_duration_ms,
            expected_auto_reverse,
            expected_rewind,
            expected_accelerate,
            expected_decelerate,
            expected_bounce_end,
            expected_restart,
            expected_playback_duration_ms,
        ) = _timing_summary(
            expected_row,
            (
                target.duration_ms
                if spec['durationScalable']
                else spec['defaultDurationMs']
            ),
            option_errors,
        )
        if option_errors:
            errors.append(
                f'animation row {index} expected-option model failed: '
                + '; '.join(option_errors)
            )
        if index <= len(actual_row_elements):
            actual_row_element = actual_row_elements[index - 1]
            expected_behavior_row = copy.deepcopy(expected_row)
            actual_row_id = actual_row_element.get('id')
            expected_behavior_row.set(
                'id',
                actual_row_id
                if actual_row_id and actual_row_id.isdigit()
                else '1',
            )
            _append_after_effect(
                expected_behavior_row,
                target,
                int(expected_behavior_row.get('id', '1')),
            )
            _append_animation_sound(expected_behavior_row, target)
            behavior_spec = {
                'rowXml': ET.tostring(
                    expected_behavior_row,
                    encoding='unicode',
                )
            }
            if not _animation_spec_matches_row(
                actual_row_element,
                behavior_spec,
            ):
                errors.append(
                    f'animation row {index} PowerPoint-authored behavior '
                    'tree changed'
                )
        if actual.shape_id != target.shape_id:
            errors.append(
                f'animation row {index} targets shape {actual.shape_id}; '
                f'expected {target.shape_id}'
            )
        expected_row_trigger = (
            target.trigger
        )
        if actual.trigger != expected_row_trigger:
            errors.append(
                f'animation row {index} trigger is {actual.trigger!r}; '
                f'expected {expected_row_trigger!r}'
            )
        if actual.trigger_shape_id != target.trigger_shape_id:
            errors.append(
                f'animation row {index} trigger shape is '
                f'{actual.trigger_shape_id!r}; expected '
                f'{target.trigger_shape_id!r}'
            )
        if target.effect not in actual.supported_effects:
            errors.append(
                f'animation row {index} resolved effects '
                f'{actual.supported_effects!r}; expected {target.effect!r}'
            )
        if actual.preset_class != spec['presetClass']:
            errors.append(f'animation row {index} presetClass changed')
        if actual.preset_id != int(spec['presetID']):
            errors.append(f'animation row {index} presetID changed')
        if actual.preset_subtype != int(expected_row.get('presetSubtype', '-1')):
            errors.append(f'animation row {index} presetSubtype changed')
        if actual.filter_name != expected_filter:
            errors.append(f'animation row {index} filter changed')
        if dict(actual.effect_options) != expected_options:
            errors.append(
                f'animation row {index} effect_options are '
                f'{dict(actual.effect_options)!r}; expected {expected_options!r}'
            )
        expected_duration = (
            target.duration_ms
            if spec['durationScalable']
            else spec['defaultDurationMs']
        )
        if actual.duration_ms != expected_duration:
            errors.append(
                f'animation row {index} duration is {actual.duration_ms}ms; '
                f'expected {expected_duration}ms'
            )
        if actual.offset_ms != expected_offsets[index - 1]:
            errors.append(
                f'animation row {index} offset is {actual.offset_ms}ms; '
                f'expected {expected_offsets[index - 1]}ms'
            )
        timing_pairs = (
            ('repeat_count', actual.repeat_count, expected_repeat_count),
            (
                'repeat_duration_ms',
                actual.repeat_duration_ms,
                expected_repeat_duration_ms,
            ),
            ('auto_reverse', actual.auto_reverse, expected_auto_reverse),
            ('rewind', actual.rewind, expected_rewind),
            ('accelerate', actual.accelerate, expected_accelerate),
            ('decelerate', actual.decelerate, expected_decelerate),
            ('bounce_end', actual.bounce_end, expected_bounce_end),
            ('restart', actual.restart, expected_restart),
            (
                'playback_duration_ms',
                actual.playback_duration_ms,
                expected_playback_duration_ms,
            ),
        )
        for field, actual_value, expected_value in timing_pairs:
            if actual_value != expected_value:
                errors.append(
                    f'animation row {index} {field} is {actual_value!r}; '
                    f'expected {expected_value!r}'
                )
        if actual.after_effect != target.after_effect:
            errors.append(
                f'animation row {index} after_effect is '
                f'{actual.after_effect!r}; expected {target.after_effect!r}'
            )
        if actual.after_effect_color != target.after_effect_color:
            errors.append(
                f'animation row {index} after_effect_color is '
                f'{actual.after_effect_color!r}; '
                f'expected {target.after_effect_color!r}'
            )
        if actual.sound_relationship_id != target.sound_relationship_id:
            errors.append(
                f'animation row {index} sound relationship is '
                f'{actual.sound_relationship_id!r}; '
                f'expected {target.sound_relationship_id!r}'
            )
        if actual.sound_name != target.sound_name:
            errors.append(
                f'animation row {index} sound name is '
                f'{actual.sound_name!r}; expected {target.sound_name!r}'
            )
    if errors:
        raise ValueError('; '.join(errors))
    resolved_rows = tuple(
        replace(actual, effect=target.effect)
        for actual, target in zip(summary.rows, expected)
    )
    return replace(summary, rows=resolved_rows)


def validate_pptx_animation_package(
    pptx_path: str | Path,
    *,
    require_supported_effects: bool = False,
) -> None:
    """Validate timing placement and shape references for every slide part."""
    path = Path(pptx_path)
    errors: list[str] = []
    try:
        with zipfile.ZipFile(path) as package:
            names = sorted(
                name
                for name in package.namelist()
                if re.fullmatch(r'ppt/slides/slide\d+\.xml', name)
            )
            for name in names:
                slide_data = package.read(name)
                try:
                    root = ET.fromstring(slide_data)
                except ET.ParseError as exc:
                    errors.append(f'{name}: invalid XML: {exc}')
                    continue
                for error in validate_slide_animation_structure(
                    root,
                    require_supported_effects=require_supported_effects,
                ):
                    errors.append(f'{name}: {error}')
                try:
                    summary = read_slide_animation_sequence(slide_data)
                except ValueError:
                    continue
                sound_ids = {
                    row.sound_relationship_id
                    for row in summary.rows
                    if row.sound_relationship_id is not None
                }
                if not sound_ids:
                    continue
                slide_leaf = posixpath.basename(name)
                rels_name = (
                    f'ppt/slides/_rels/{slide_leaf}.rels'
                )
                if rels_name not in package.namelist():
                    errors.append(
                        f'{name}: animation sound relationships are missing'
                    )
                    continue
                try:
                    relationships = ET.fromstring(package.read(rels_name))
                except ET.ParseError as exc:
                    errors.append(f'{rels_name}: invalid XML: {exc}')
                    continue
                by_id = {
                    rel.get('Id'): rel
                    for rel in relationships.findall(
                        _qn(_PACKAGE_REL_NS, 'Relationship')
                    )
                }
                for relationship_id in sorted(sound_ids):
                    relationship = by_id.get(relationship_id)
                    if relationship is None:
                        errors.append(
                            f'{name}: animation sound relationship '
                            f'{relationship_id} is missing'
                        )
                        continue
                    if relationship.get('Type') != _AUDIO_REL_TYPE:
                        errors.append(
                            f'{name}: animation sound relationship '
                            f'{relationship_id} is not an audio relationship'
                        )
                        continue
                    target = relationship.get('Target')
                    if not target:
                        errors.append(
                            f'{name}: animation sound relationship '
                            f'{relationship_id} has no target'
                        )
                        continue
                    target_part = posixpath.normpath(
                        posixpath.join(posixpath.dirname(name), target)
                    )
                    if target_part not in package.namelist():
                        errors.append(
                            f'{name}: animation sound target is missing: '
                            f'{target_part}'
                        )
    except (OSError, zipfile.BadZipFile) as exc:
        raise ValueError(f'unable to read PPTX package {path}: {exc}') from exc
    if errors:
        raise ValueError('; '.join(errors))


def object_animation_fingerprint(slide_xml: str | bytes) -> str | None:
    """Return a prefix/whitespace-independent fingerprint of object animation.

    Narration audio is intentionally excluded.  Direct-PPTX routes use this
    fingerprint before and after their allowed edits to prove that they did not
    take ownership of or rewrite existing object animations.
    """
    data = slide_xml.encode('utf-8') if isinstance(slide_xml, str) else slide_xml
    try:
        root = ET.fromstring(data)
    except ET.ParseError as exc:
        raise ValueError(f'invalid slide XML: {exc}') from exc
    root = _select_supported_timing_branch(root)
    timings = [
        child for child in list(root)
        if child.tag == _qn(PML_NS, 'timing')
    ]
    if len(timings) > 1:
        raise ValueError(
            f'slide has {len(timings)} root p:timing elements; expected at most 1'
        )
    if not timings:
        return None
    timing = timings[0]
    behavior_tags = {
        _qn(PML_NS, name)
        for name in (
            'anim',
            'animClr',
            'animEffect',
            'animMotion',
            'animRot',
            'animScale',
            'cmd',
            'set',
        )
    }
    has_object_animation = any(
        element.tag in behavior_tags
        or (
            element.tag == _qn(PML_NS, 'cTn')
            and element.get('presetClass') is not None
        )
        for element in timing.iter()
    )
    if not has_object_animation:
        return None

    def without_audio(element: ET.Element) -> tuple[object, ...] | None:
        if element.tag == _qn(PML_NS, 'audio'):
            return None
        children = tuple(
            value
            for child in list(element)
            if (value := without_audio(child)) is not None
        )
        return (
            element.tag,
            tuple(sorted(element.attrib.items())),
            (element.text or '').strip(),
            children,
        )

    canonical = without_audio(timing)
    return hashlib.sha256(repr(canonical).encode('utf-8')).hexdigest()


def entrance_animation_fingerprint(slide_xml: str | bytes) -> str | None:
    """Compatibility alias for :func:`object_animation_fingerprint`."""
    return object_animation_fingerprint(slide_xml)


def get_available_transitions() -> list:
    """Get native transition keys followed by compatibility inputs."""
    return [*NATIVE_TRANSITION_KEYS, *LEGACY_TRANSITION_KEYS]


def get_available_animations() -> list:
    """Get canonical object-animation keys followed by compatibility inputs."""
    return list(ANIMATIONS.keys())


def get_transition_help() -> str:
    """Get categorized native transitions plus legacy compatibility inputs."""
    lines = ["Available transition effects:"]
    for category in TRANSITION_CATEGORIES:
        lines.append(f"  PowerPoint-native {category} effects:")
        for key in NATIVE_TRANSITION_KEYS:
            info = NATIVE_TRANSITIONS[key]
            if info["category"] == category:
                lines.append(f"    {key}: {info['name']}")
    lines.append("  Legacy compatibility inputs (never selected for new output):")
    for key in LEGACY_TRANSITION_KEYS:
        canonical = TRANSITION_ALIASES[key]
        implied = TRANSITION_ALIAS_OPTIONS.get(key)
        option_suffix = f", implies {implied}" if implied else ""
        lines.append(
            f"    {key}: compatibility alias for {canonical} "
            f"({NATIVE_TRANSITIONS[canonical]['name']}{option_suffix})"
        )
    return '\n'.join(lines)


def get_animation_help() -> str:
    """Get categorized help text for every object-animation effect."""
    lines = ['Available object animations:']
    for category in ANIMATION_CATEGORIES:
        lines.append(f'  PowerPoint-native {category} effects:')
        for key in NATIVE_ANIMATION_KEYS:
            info = NATIVE_ANIMATIONS[key]
            if info['category'] == category:
                lines.append(f"    {key}: {info['name']}")
    lines.append('  Legacy compatibility inputs (never selected for new output):')
    for key in LEGACY_ANIMATION_KEYS:
        canonical = ANIMATION_ALIASES[key]
        implied = ANIMATION_ALIAS_OPTIONS.get(key)
        option_suffix = (
            f', implies {implied}'
            if implied
            else ''
        )
        lines.append(
            f"    {key}: compatibility alias for {canonical} "
            f"({NATIVE_ANIMATIONS[canonical]['name']}{option_suffix})"
        )
    return '\n'.join(lines)


def describe_animation_effect(effect: object) -> dict[str, Any]:
    """Return the author-facing option contract for one animation effect."""
    canonical = normalize_animation_effect(
        effect,
        allow_none=False,
        allow_modes=False,
    )
    assert canonical is not None
    implied_options = (
        dict(ANIMATION_ALIAS_OPTIONS.get(effect, {}))
        if isinstance(effect, str)
        else {}
    )
    option_contract: dict[str, Any] = {}
    for name, raw_spec in NATIVE_ANIMATIONS[canonical]['effectOptions'].items():
        spec = {
            key: value
            for key, value in raw_spec.items()
            if key != 'values'
        }
        if raw_spec.get('type') == 'enum':
            spec['values'] = list(raw_spec['values'])
        option_contract[name] = spec
    return {
        'input': effect,
        'effect': canonical,
        'compatibility_alias': (
            effect if isinstance(effect, str) and effect in ANIMATION_ALIASES else None
        ),
        'implied_effect_options': implied_options,
        'effect_options': option_contract,
        'timing': {
            'duration': (
                'positive seconds; legacy group effect or effects[] row'
            ),
            'delay': (
                'non-negative seconds; legacy group effect or effects[] row'
            ),
            'stagger': 'non-negative seconds; animation scope only',
            'trigger': list(ANIMATION_TRIGGERS),
            'trigger_scope': (
                'animation default for a legacy group effect; each effects[] '
                'row may override it'
            ),
            'trigger_shape': (
                'other top-level SVG group id; legacy group effect or '
                'effects[] row; maps to PowerPoint "On Click of" and requires '
                'trigger on-click'
            ),
            'repeat_count': 'positive number; mutually exclusive with repeat_duration',
            'repeat_duration': 'positive seconds; mutually exclusive with repeat_count',
            'auto_reverse': 'boolean',
            'rewind': 'boolean',
            'accelerate': 'number from 0 to 1',
            'decelerate': 'number from 0 to 1',
            'bounce_end': (
                'number from 0 to 1; requires an interpolated behavior and '
                'is mutually exclusive with decelerate'
            ),
            'restart': list(ANIMATION_RESTARTS),
        },
        'after_effect': list(ANIMATION_AFTER_EFFECTS),
        'sound': 'project-relative or absolute .m4a, .mp3, or .wav path',
        'derived_not_configured': {
            'speed': 'derived from duration',
            'smooth_start': 'derived from accelerate',
            'smooth_end': 'derived from decelerate',
        },
    }


def main() -> None:
    """Run the CLI entry point."""
    parser = argparse.ArgumentParser(
        description=__doc__,
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    parser.add_argument(
        "--demo",
        action="store_true",
        help="print sample XML for a fade transition and entrance_fade animation",
    )
    parser.add_argument(
        '--list',
        action='store_true',
        help='list available transitions and object animations',
    )
    parser.add_argument(
        '--describe',
        metavar='EFFECT',
        help='print the complete parameter contract for one object animation',
    )
    parser.add_argument(
        '--describe-transition',
        metavar='EFFECT',
        help='print the PowerPoint Effect Options for one page transition',
    )
    args = parser.parse_args()

    if args.describe:
        try:
            print(
                json.dumps(
                    describe_animation_effect(args.describe),
                    ensure_ascii=False,
                    indent=2,
                )
            )
        except ValueError as exc:
            parser.error(str(exc))
        return

    if args.describe_transition:
        try:
            print(
                json.dumps(
                    describe_transition_effect(args.describe_transition),
                    ensure_ascii=False,
                    indent=2,
                )
            )
        except ValueError as exc:
            parser.error(str(exc))
        return

    if args.list:
        print(get_transition_help())
        print()
        print(get_animation_help())
        return

    if args.demo:
        print("=== Transition Effect XML Example (fade, 500ms) ===")
        print(create_transition_xml('fade', 0.5))
        print()
        print("=== Entrance Animation XML Example (entrance_fade) ===")
        print(create_timing_xml('entrance_fade', 1.0))
        return

    parser.print_help()


if __name__ == '__main__':
    main()
