"""CLI entry point for svg_to_pptx."""

from __future__ import annotations

import sys
import json
import shutil
import argparse
from datetime import datetime
from pathlib import Path

if __package__ in {None, ''}:
    import types

    package = types.ModuleType('svg_to_pptx')
    package.__path__ = [str(Path(__file__).resolve().parent)]  # type: ignore[attr-defined]
    sys.modules.setdefault('svg_to_pptx', package)
    __package__ = 'svg_to_pptx'

from .pptx_dimensions import CANVAS_FORMATS, get_project_info, get_viewbox_dimensions
from .pptx_discovery import find_svg_files, find_notes_files
from .pptx_builder import create_pptx_with_native_svg
from .pptx_narration import NARRATION_EXTENSIONS, find_narration_files, probe_audio_duration
from .pptx_slide_xml import TRANSITIONS
from .animation_config import load_animation_config, validate_animation_config

try:
    from pptx_animations import ANIMATIONS as _ANIMATIONS
except ImportError:
    _ANIMATIONS = {}


def _as_dict(value: object) -> dict:
    return value if isinstance(value, dict) else {}


def _recorded_narration_on_click_slides(
    ref_files: list[Path],
    animation_config: dict | None,
    animation: str | None,
    animation_trigger: str,
    animation_cli_overrides: dict[str, bool],
) -> list[str]:
    """Return slides whose effective recorded-video animation trigger is on-click."""
    slides_cfg = _as_dict(_as_dict(animation_config).get('slides'))
    blocked: list[str] = []
    for svg_path in ref_files:
        slide_cfg = _as_dict(slides_cfg.get(svg_path.stem))
        anim_cfg = _as_dict(slide_cfg.get('animation'))

        slide_animation = animation
        if not animation_cli_overrides.get('animation') and 'effect' in anim_cfg:
            cfg_effect = str(anim_cfg.get('effect'))
            slide_animation = None if cfg_effect == 'none' else cfg_effect
        if slide_animation is None:
            continue

        slide_trigger = animation_trigger
        if not animation_cli_overrides.get('animation_trigger') and anim_cfg.get('trigger'):
            slide_trigger = str(anim_cfg.get('trigger'))
        if slide_trigger == 'on-click':
            blocked.append(svg_path.stem)
    return blocked


def _deck_locks_icons_but_authors_none(project_path: Path, svg_files: list[Path]) -> bool:
    """Detect the export-boundary icon violation.

    Returns True when ``spec_lock.md`` locks an icon library + non-empty
    inventory but the source SVGs carry ZERO ``<use data-icon>`` placeholders —
    i.e. the deck would export flat / icon-less despite the strategist intending
    icons. Returns False otherwise (including on any internal error: detection
    must never itself break the export path).

    The caller turns a True into a fatal abort (unless ``--allow-iconless``).
    This mirrors svg_quality_checker's deck-level icon gate, but at the export
    boundary it is the LAST line of defense: the quality gate can be reordered
    before export or have its non-zero exit swallowed by ``| head``, whereas a
    refusal to write the pptx cannot be piped away.

    Detection always reads ``svg_output/`` (the authored source of truth)
    when it exists, regardless of which directory the export consumes:
    finalize EXPANDS ``<use data-icon>`` placeholders in svg_final, so
    checking an ``-s final`` file set produced a false "zero icons" alarm —
    which in a real run handed the model a legitimate-looking reason to pass
    ``--allow-iconless`` on a deck whose icons were perfectly fine.
    """
    try:
        import re

        lock_path = project_path / 'spec_lock.md'
        if not lock_path.exists():
            return False
        try:
            from update_spec import parse_lock
            icons = (parse_lock(lock_path) or {}).get('icons') or {}
        except Exception:
            return False
        library = (icons.get('library') or '').strip().lower()
        inventory = (icons.get('inventory') or '').strip().lower()
        _empty = ('', 'none', '(none)', '-', 'n/a')
        if library in _empty or inventory in _empty:
            return False
        source_dir = project_path / 'svg_output'
        sources = sorted(source_dir.glob('*.svg')) if source_dir.is_dir() else svg_files
        total = 0
        for p in sources:
            try:
                total += len(re.findall(r'<use\b[^>]*\bdata-icon\s*=', p.read_text(encoding='utf-8')))
            except Exception:
                continue
        return total == 0
    except Exception:
        return False


def _quality_errors(project_path: Path) -> list[str]:
    """Export-boundary structural quality gate.

    Runs svg_quality_checker's per-file checks on ``svg_output/`` right before
    export and returns every hard error (forbidden features, malformed XML,
    missing image files, icon-on-text / off-canvas geometry, spec_lock
    violations). The stage-4 quality check is documented as mandatory, but a
    real 25-page run simply never invoked it — embedding the same checks at
    the export boundary makes "forgot / skipped the checker" impossible.

    Only applies to spec_lock'd projects with an svg_output/ (same scope as
    the other gates). Fails open on import/internal errors — the checker
    lives one directory above this package and is optional at runtime; a
    broken checker must not break exports. Deck-level aggregates (icon
    totals, visual richness) are NOT re-derived here; icons already have
    their own export gate and richness is advisory.
    """
    try:
        if not (project_path / 'spec_lock.md').exists():
            return []
        source_dir = project_path / 'svg_output'
        if not source_dir.is_dir():
            return []
        try:
            import sys as _sys
            _scripts_dir = str(Path(__file__).resolve().parent.parent)
            if _scripts_dir not in _sys.path:
                _sys.path.insert(0, _scripts_dir)
            from svg_quality_checker import SVGQualityChecker
        except Exception:
            return []
        checker = SVGQualityChecker()
        problems: list[str] = []
        for svg in sorted(source_dir.glob('*.svg')):
            result = checker.check_file(str(svg))
            for err in result.get('errors', []):
                problems.append(f"{svg.stem}: {err}")
        return problems
    except Exception:
        return []


def main(argv: list[str] | None = None) -> int:
    """CLI entry point for the SVG to PPTX conversion tool."""
    transition_choices = (
        ['none'] + (list(TRANSITIONS.keys()) if TRANSITIONS
                    else ['fade', 'push', 'wipe', 'split', 'strips', 'cover', 'random'])
    )

    animation_choices = (
        ['none'] + (list(_ANIMATIONS.keys()) if _ANIMATIONS
                    else ['fade', 'fly', 'zoom', 'appear'])
        + ['auto', 'mixed', 'random']
    )

    parser = argparse.ArgumentParser(
        description='PPT Master - SVG to PPTX Tool (Office Compatibility Mode)',
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog=f'''
Examples:
    %(prog)s examples/ppt169_demo -s final               # Default: native pptx -> exports/, svg_output -> .build/backup/latest/
    %(prog)s examples/ppt169_demo --svg-snapshot         # Also emit SVG-rendered snapshot pptx alongside native in exports/
    %(prog)s examples/ppt169_demo --only legacy          # Only SVG image version (skips native)
    %(prog)s examples/ppt169_demo -o out.pptx            # Explicit path (no backup/)

    # Disable transition / change transition effect
    %(prog)s examples/ppt169_demo -t none
    %(prog)s examples/ppt169_demo -t push --transition-duration 1.0

SVG source directory (-s):
    output   - svg_output (original version)
    final    - svg_final (post-processed, recommended)
    <any>    - Specify a subdirectory name directly

Transition effects (-t/--transition):
    {', '.join(transition_choices)}

Per-element entrance animation (-a/--animation, native shapes mode):
    {', '.join(animation_choices)}
    Notes: applied to top-level <g id="..."> SVG groups in z-order. Default is
           "auto" (map effect from group id: chart→wipe, card-/step-/pillar-→fly,
           title/takeaway→fade; image-like ids hero/figure-/image/img-/kpi cycle
           zoom/dissolve/circle/box/diamond/wheel so multiple images vary across
           the deck; unmatched ids cycle fade/wipe/fly/zoom). Start mode set by
           --animation-trigger, matching PowerPoint's Start dropdown:
             on-click              one presenter click per group
             with-previous         all groups start together on slide entry
             after-previous (default)  cascade on slide entry;
                                       gap = --animation-stagger seconds
           mixed (legacy) cycles a larger 16-effect pool by group order;
           random samples from the same legacy pool. Use "-a none" to disable.

Compatibility mode (enabled by default):
    - Automatically generates PNG fallback images, SVG embedded as extension
    - Compatible with all Office versions (including Office LTSC 2021)
    - Newer Office still displays SVG (editable), older versions display PNG
    - PNG fallback needs a renderer from the sandbox image; if missing, native
      export still works — do not install renderers in-session
    - Use --no-compat to disable (only Office 2019+ supported)

Speaker notes (enabled by default):
    - Automatically reads Markdown notes files from the notes/ directory
    - Supports two naming conventions:
      1. Match by filename (recommended): 01_cover.md corresponds to 01_cover.svg
      2. Match by index: slide01.md corresponds to the 1st SVG (backward compatible)
    - Use --no-notes to disable

Recorded narration:
    %(prog)s examples/ppt169_demo -s final --recorded-narration audio
    - Keeps speaker notes when enabled
    - Prepares PowerPoint recorded timings and narrations
    - Requires one m4a/mp3/wav file per slide
    - Embeds per-slide audio matched by SVG filename / slide number
    - Sets slide auto-advance from audio duration so video export can use
      "recorded timings and narrations"
    - Rejects on-click object animations; use after-previous or with-previous
    %(prog)s examples/ppt169_demo --narration-audio-dir audio
    - Lower-level audio embedding: embeds matched files but allows partial matches
    - Use only when you do not need a complete recorded-timings export
''',
    )

    parser.add_argument('project_path', type=str, help='Project directory path')
    parser.add_argument('-o', '--output', type=str, default=None, help='Output file path')
    parser.add_argument('-s', '--source', type=str, default=None,
                        help='SVG source directory. Default: native reads '
                             'svg_output/ (high-fidelity, preserves icons / '
                             'preserveAspectRatio / rx-ry); legacy reads '
                             'svg_final/ (PPT-internal SVG parser fallback). '
                             'Pass output/final/<name> to force one source.')
    parser.add_argument('-f', '--format', type=str,
                        choices=list(CANVAS_FORMATS.keys()), default=None,
                        help='Specify canvas format')
    parser.add_argument('-q', '--quiet', action='store_true', help='Quiet mode')

    parser.add_argument('--no-compat', action='store_true',
                        help='Disable Office compatibility mode (pure SVG only, requires Office 2019+)')

    parser.add_argument('--allow-iconless', action='store_true', default=False,
                        help='Allow export even when spec_lock locks an icon inventory but '
                             'the SVGs author zero <use data-icon> (default: refuse — the deck '
                             'would render flat / icon-less). Use only for a stale lock or an '
                             'intentionally icon-less deck.')

    mode_group = parser.add_mutually_exclusive_group()
    mode_group.add_argument('--only', type=str, choices=['native', 'legacy'], default=None,
                            help='Only generate one version: native (editable shapes) or legacy (SVG image)')
    mode_group.add_argument('--native', action='store_true', default=False,
                            help='(Deprecated, now default) Convert SVG to native DrawingML shapes')
    merge_group = parser.add_mutually_exclusive_group()
    merge_group.add_argument('--merge-paragraphs', action='store_true', dest='merge_paragraphs',
                             help='Compatibility no-op: mergeable paragraph blocks are merged '
                                  'by default.')
    merge_group.add_argument('--no-merge', action='store_false', dest='merge_paragraphs',
                             help='Disable paragraph merging. Every dy-stacked line becomes '
                                  'its own text frame for strict SVG line-layout fidelity.')
    parser.set_defaults(merge_paragraphs=True)
    parser.add_argument('--conversion-trace', action='store_true', default=False,
                        help='Write a JSON diagnostics report next to the native PPTX '
                             '(<output>.trace.json). Records per-slide SVG element '
                             'conversion decisions for debugging.')
    parser.add_argument('--svg-snapshot', action='store_true', default=False,
                        help='Also emit the SVG-rendered snapshot pptx alongside the native pptx in exports/ '
                             '(named <project>_<ts>_svg.pptx). Off by default — the native pptx is the '
                             'canonical output; live preview already provides the SVG visual reference. '
                             'Note: the svg_output/ source snapshot is always written to .build/backup/latest/ '
                             'regardless of this flag.')

    def non_negative_float(value: str) -> float:
        try:
            number = float(value)
        except ValueError as exc:
            raise argparse.ArgumentTypeError(f"must be a number: {value}") from exc
        if number < 0:
            raise argparse.ArgumentTypeError("must be non-negative")
        return number

    parser.add_argument('-t', '--transition', type=str, choices=transition_choices, default=None,
                        help='Page transition effect (default: fade, use "none" to disable)')
    parser.add_argument('--transition-duration', type=non_negative_float, default=None,
                        help='Transition duration in seconds (default: 0.4)')
    parser.add_argument('--auto-advance', type=non_negative_float, default=None,
                        help='Auto-advance interval in seconds (default: manual advance)')

    parser.add_argument('-a', '--animation', type=str, choices=animation_choices,
                        default=None,
                        help='Per-element entrance animation (native shapes mode '
                             'only). Default "none" (no auto element builds; page '
                             'transitions still apply). Pick a single effect, "auto" '
                             '(map effect from group id — image-like ids cycle a '
                             'richer pool for visual variation, fallback cycles fade/'
                             'wipe/fly/zoom), "mixed" (legacy 16-effect pool), or '
                             '"random".')
    parser.add_argument('--animation-duration', type=non_negative_float, default=None,
                        help='Per-element entrance duration in seconds (default: 0.4)')
    parser.add_argument('--animation-trigger', type=str,
                        choices=['on-click', 'with-previous', 'after-previous'],
                        default=None,
                        help='Per-element Start mode (matches PowerPoint Start dropdown): '
                             '"on-click" (one click per element), '
                             '"with-previous" (all start together on slide entry), '
                             '"after-previous" (default, cascade after the previous element).')
    parser.add_argument('--animation-stagger', type=non_negative_float, default=None,
                        help='Delay between elements in --animation-trigger=after-previous '
                             '(seconds, default 0.5). Ignored in other modes.')
    parser.add_argument('--animation-config', type=str, default=None,
                        help='Optional per-slide/per-object animation config. '
                             'Default: <project>/animations.json when present.')

    parser.add_argument('--no-notes', action='store_true',
                        help='Disable speaker notes embedding (enabled by default)')
    parser.add_argument('--narration-audio-dir', type=str, default=None,
                        help='Low-level audio embedding from this directory; allows partial matches')
    parser.add_argument('--use-narration-timings', action='store_true',
                        help='Set slide auto-advance timings from narration audio durations')
    parser.add_argument('--recorded-narration', type=str, default=None,
                        help='Prepare PowerPoint recorded timings and narrations from a complete audio directory')
    parser.add_argument('--narration-padding', type=float, default=0.5,
                        help='Seconds to add after each narration before auto-advance (default: 0.5)')

    parser.add_argument('--cache-dir', type=str, default=None,
                        help='Cache directory for SVG→PNG renders (default: '
                             '<project>/.cache/svg_png). Cache key uses SVG content '
                             'hash + size + renderer; safe across renderer switches. '
                             'Removed automatically after a successful export.')
    parser.add_argument('--no-cache', action='store_true',
                        help='Disable the SVG→PNG cache for this run (still parallel).')
    parser.add_argument('--keep-cache', action='store_true',
                        help='Keep the SVG→PNG cache directory after export '
                             '(default: removed on success to keep project clean).')
    parser.add_argument('--workers', type=int, default=None,
                        help='Parallel workers for SVG→PNG pre-rendering. '
                             'Default: min(cpu, pages, 8). Set 1 for sequential.')

    args = parser.parse_args(argv)

    project_path = Path(args.project_path)
    if not project_path.exists():
        print(f"Error: Path does not exist: {project_path}")
        return 1

    try:
        project_info = get_project_info(str(project_path))
        project_name = project_info.get('name', project_path.name)
        detected_format = project_info.get('format')
    except Exception:
        project_name = project_path.name
        detected_format = None

    canvas_format = args.format
    if canvas_format is None and detected_format and detected_format != 'unknown':
        canvas_format = detected_format

    # Determine which versions to generate.
    # Default is native-only; SVG snapshot is opt-in via --svg-snapshot.
    # --only native / --only legacy still force a single version explicitly.
    only_mode = args.only
    if only_mode == 'native':
        gen_native, gen_legacy = True, False
    elif only_mode == 'legacy':
        gen_native, gen_legacy = False, True
    else:
        gen_native = True
        gen_legacy = args.svg_snapshot

    # Pipeline split: native pptx gets the high-fidelity svg_output/ source
    # (icons, preserveAspectRatio, rounded-rect rx/ry are all preserved by the
    # converter); legacy pptx still needs svg_final/ because PowerPoint's
    # internal SVG parser cannot handle <use data-icon> or honour
    # preserveAspectRatio. An explicit -s overrides both branches so callers
    # can keep the previous single-source behaviour for unusual workflows.
    explicit_source = args.source is not None
    native_source = args.source if explicit_source else 'output'
    legacy_source = args.source if explicit_source else 'final'

    native_files: list[Path] = []
    legacy_files: list[Path] = []
    native_source_dir = ''
    legacy_source_dir = ''

    if gen_native:
        native_files, native_source_dir = find_svg_files(project_path, native_source)
    if gen_legacy:
        legacy_files, legacy_source_dir = find_svg_files(project_path, legacy_source)

    # Reference list for cross-product lookups (notes / narration matching).
    # native_files and legacy_files share filenames because svg_final/ is
    # copytree'd from svg_output/, so either list works for matching.
    ref_files = native_files or legacy_files
    if not ref_files:
        print("Error: No SVG files found")
        return 1

    # Export-boundary icon gate: a locked icon inventory with ZERO authored
    # <use data-icon> means the deck exports flat / icon-less. This is the last
    # line of defense (the quality gate can be reordered before export or its
    # non-zero exit swallowed by `| head`), so it is FATAL by default — refuse to
    # produce a pptx that the strategist's own spec_lock says is wrong.
    # --allow-iconless is the explicit escape hatch (stale lock / intentional).
    if _deck_locks_icons_but_authors_none(project_path, ref_files):
        if args.allow_iconless:
            print(
                "[WARN] spec_lock locks an icon library + inventory but the source SVGs "
                "contain ZERO <use data-icon> — exporting flat / icon-less anyway "
                "(--allow-iconless).",
                file=sys.stderr,
            )
        else:
            print(
                "[ERROR] spec_lock locks an icon library + inventory, but the source SVGs "
                "contain ZERO <use data-icon> — this deck would export flat / icon-less.\n"
                "        Add inventory icons to content pages (KPI / list / process /\n"
                "        comparison layouts especially), then re-run. If the lock is stale\n"
                "        or icons are intentionally absent, pass --allow-iconless.",
                file=sys.stderr,
            )
            return 1

    import os as _os
    force_export = _os.environ.get('ZCBOT_PPT_FORCE_EXPORT') == '1'

    # Export-boundary structural quality gate: the stage-4 checker's per-file
    # hard errors, re-run here so "never ran the checker" cannot ship a deck.
    # No CLI flag waives this — these are real defects to fix, not judgments.
    quality_problems = [] if force_export else _quality_errors(project_path)
    if quality_problems:
        print(
            f"[ERROR] quality check failed with {len(quality_problems)} error(s) — "
            "refusing to export:",
            file=sys.stderr,
        )
        for p in quality_problems[:20]:
            print(f"        - {p}", file=sys.stderr)
        if len(quality_problems) > 20:
            print(f"        ... and {len(quality_problems) - 20} more", file=sys.stderr)
        print(
            "        Fix the listed pages in svg_output/ (run svg_quality_checker.py "
            "<project_dir>\n"
            "        for the full report incl. warnings), then re-run finalize + "
            "export.",
            file=sys.stderr,
        )
        return 1

    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")

    backup_dir: Path | None = None
    legacy_path: Path | None = None
    if args.output:
        output_base = Path(args.output)
        native_path = output_base
        if gen_legacy:
            stem = output_base.stem
            legacy_path = output_base.parent / f"{stem}_svg{output_base.suffix}"
    else:
        exports_dir = project_path / "exports"
        exports_dir.mkdir(parents=True, exist_ok=True)
        native_path = exports_dir / f"{project_name}_{timestamp}.pptx"
        # svg_output/ snapshot goes under the hidden .build/backup/latest/ in
        # default-flow mode (no -o). Latest-only — no timestamp pile-up; the
        # persistent svg_output/ is the real source, this is just a re-export
        # convenience copy. Literal ".build" keeps this package decoupled from
        # project_utils; keep in sync with BUILD_DIR_NAME.
        backup_dir = project_path / ".build" / "backup" / "latest"
        if gen_legacy:
            legacy_path = exports_dir / f"{project_name}_{timestamp}_svg.pptx"

    native_path.parent.mkdir(parents=True, exist_ok=True)
    if legacy_path is not None:
        legacy_path.parent.mkdir(parents=True, exist_ok=True)

    verbose = not args.quiet

    # Honor the actual SVG pixels over a stale project-recorded format. The
    # canvas_format read from project init can disagree with what the Executor
    # actually drew — e.g. a mirror template imported at 2560×1440 while the
    # project was initialized as ppt169 (1280×720). When the first SVG's real
    # viewBox doesn't match the recorded format's dimensions, drop the format
    # so the builder sizes the slide by pixels (custom_pixels path). Standard
    # decks match exactly, so this only changes behavior on the conflict case.
    # An explicit --format always wins and is never second-guessed.
    if args.format is None and canvas_format:
        fmt_info = CANVAS_FORMATS.get(canvas_format)
        actual_dims = get_viewbox_dimensions(ref_files[0])
        if fmt_info and actual_dims:
            fmt_dims = (fmt_info.get('width'), fmt_info.get('height'))
            if fmt_dims != actual_dims:
                if verbose:
                    print(
                        f"  Recorded format '{canvas_format}' "
                        f"({fmt_dims[0]}×{fmt_dims[1]}) differs from SVG viewBox "
                        f"({actual_dims[0]}×{actual_dims[1]}); exporting by SVG pixels"
                    )
                canvas_format = None

    enable_notes = not args.no_notes
    notes: dict[str, str] = {}
    if enable_notes:
        notes = find_notes_files(project_path, ref_files)

    narration_audio: dict[str, Path] = {}
    narration_audio_dir_arg = args.recorded_narration or args.narration_audio_dir
    use_narration_timings = args.use_narration_timings or bool(args.recorded_narration)
    if narration_audio_dir_arg:
        narration_audio_dir = Path(narration_audio_dir_arg)
        if not narration_audio_dir.is_absolute():
            narration_audio_dir = project_path / narration_audio_dir
        if args.recorded_narration and not narration_audio_dir.is_dir():
            print(
                f"Error: Recorded narration directory does not exist: {narration_audio_dir}",
                file=sys.stderr,
            )
            return 1
        narration_audio = find_narration_files(narration_audio_dir, ref_files)
        if verbose:
            print(f"  Narration audio directory: {narration_audio_dir}")
            print(f"  Narration audio matched: {len(narration_audio)}/{len(ref_files)} slide(s)")
        if args.recorded_narration:
            missing = [path.stem for path in ref_files if path.stem not in narration_audio]
            if missing:
                print(
                    "Error: Recorded narration requires one supported audio file per slide. "
                    f"Matched {len(narration_audio)}/{len(ref_files)} slide(s). "
                    f"Supported extensions: {', '.join(NARRATION_EXTENSIONS)}",
                    file=sys.stderr,
                )
                for stem in missing[:20]:
                    print(f"  Missing audio for: {stem}", file=sys.stderr)
                if len(missing) > 20:
                    print(f"  ... and {len(missing) - 20} more", file=sys.stderr)
                return 1
            unreadable = [
                f"{stem}: {audio_path}"
                for stem, audio_path in sorted(narration_audio.items())
                if probe_audio_duration(audio_path) is None
            ]
            if unreadable:
                print(
                    "Error: Recorded narration requires readable audio durations. "
                    "Install ffprobe/ffmpeg or replace the listed audio files.",
                    file=sys.stderr,
                )
                for item in unreadable[:20]:
                    print(f"  {item}", file=sys.stderr)
                if len(unreadable) > 20:
                    print(f"  ... and {len(unreadable) - 20} more", file=sys.stderr)
                return 1
        elif narration_audio_dir_arg and verbose:
            missing = [path.stem for path in ref_files if path.stem not in narration_audio]
            if missing:
                print(
                    f"  [warn] Narration audio matched {len(narration_audio)}/{len(ref_files)} slide(s); "
                    "unmatched slides will export without audio."
                )

    if args.animation_config:
        config_path = Path(args.animation_config)
        if not config_path.is_absolute():
            config_path = project_path / config_path
        if not config_path.exists():
            print(f"Error: Animation config does not exist: {config_path}")
            return 1

    try:
        animation_config = load_animation_config(project_path, args.animation_config)
    except Exception as exc:
        print(f"Error: Failed to load animation config: {exc}")
        return 1
    if animation_config and verbose:
        config_label = args.animation_config or str(project_path / 'animations.json')
        print(f"  Animation config: {config_label}")
        for warning in validate_animation_config(project_path, animation_config):
            print(f"  [warn] {warning}")

    defaults = animation_config.get('defaults', {}) if animation_config else {}
    transition_defaults = defaults.get('transition', {}) if isinstance(defaults, dict) else {}
    animation_defaults = defaults.get('animation', {}) if isinstance(defaults, dict) else {}

    transition_arg = args.transition
    transition_effect = (
        transition_arg
        if transition_arg is not None
        else transition_defaults.get('effect', 'fade')
    )
    transition = None if transition_effect == 'none' else transition_effect
    transition_duration = (
        args.transition_duration
        if args.transition_duration is not None
        else float(transition_defaults.get('duration', 0.4))
    )

    animation_arg = args.animation
    animation_effect = (
        animation_arg
        if animation_arg is not None
        # Per-element entrance is opt-in by default: auto-firing element builds
        # read as the "AI deck" tell and were unsolicited. Page transitions stay
        # on (see transition default above). Re-enable with -a auto / animations.json.
        else animation_defaults.get('effect', 'none')
    )
    animation = None if animation_effect == 'none' else animation_effect
    animation_duration = (
        args.animation_duration
        if args.animation_duration is not None
        else float(animation_defaults.get('duration', 0.4))
    )
    animation_stagger = (
        args.animation_stagger
        if args.animation_stagger is not None
        else float(animation_defaults.get('stagger', 0.5))
    )
    animation_trigger = (
        args.animation_trigger
        if args.animation_trigger is not None
        else animation_defaults.get('trigger', 'after-previous')
    )

    animation_cli_overrides = {
        'transition': args.transition is not None,
        'transition_duration': args.transition_duration is not None,
        'auto_advance': args.auto_advance is not None,
        'animation': args.animation is not None,
        'animation_duration': args.animation_duration is not None,
        'animation_stagger': args.animation_stagger is not None,
        'animation_trigger': args.animation_trigger is not None,
    }

    if args.recorded_narration and gen_native:
        on_click_slides = _recorded_narration_on_click_slides(
            ref_files,
            animation_config,
            animation,
            animation_trigger,
            animation_cli_overrides,
        )
        if on_click_slides:
            print(
                "Error: --recorded-narration cannot be used with on-click object animations. "
                "Use --animation-trigger after-previous or --animation-trigger with-previous.",
                file=sys.stderr,
            )
            for slide in on_click_slides[:20]:
                print(f"  on-click trigger: {slide}", file=sys.stderr)
            if len(on_click_slides) > 20:
                print(f"  ... and {len(on_click_slides) - 20} more", file=sys.stderr)
            return 1

    if args.no_cache:
        cache_dir: Path | None = None
    elif args.cache_dir:
        cache_dir = Path(args.cache_dir)
        if not cache_dir.is_absolute():
            cache_dir = project_path / cache_dir
    else:
        cache_dir = project_path / '.cache' / 'svg_png'

    # svg_files is per-product (native vs legacy may now read different
    # directories); everything else is shared.
    # Optional per-project document properties. Absent file → factual fields
    # are still stamped at export; only the authored fields stay blank.
    doc_metadata = None
    metadata_path = project_path / 'metadata.json'
    if metadata_path.is_file():
        try:
            loaded = json.loads(metadata_path.read_text(encoding='utf-8'))
        except (json.JSONDecodeError, OSError) as exc:
            print(f"  [warn] metadata.json ignored ({exc})", file=sys.stderr)
        else:
            if isinstance(loaded, dict):
                doc_metadata = loaded
                if verbose:
                    print(f"  Document properties: metadata.json ({len(loaded)} field(s))")
            else:
                print("  [warn] metadata.json ignored (top level is not an object)", file=sys.stderr)

    shared_kwargs = dict(
        canvas_format=canvas_format,
        doc_metadata=doc_metadata,
        verbose=verbose,
        transition=transition,
        transition_duration=transition_duration,
        auto_advance=args.auto_advance,
        use_compat_mode=not args.no_compat,
        notes=notes,
        enable_notes=enable_notes,
        animation=animation,
        animation_duration=animation_duration,
        animation_stagger=animation_stagger,
        animation_trigger=animation_trigger,
        animation_config=animation_config,
        animation_cli_overrides=animation_cli_overrides,
        narration_audio=narration_audio,
        use_narration_timings=use_narration_timings,
        narration_padding=args.narration_padding,
        cache_dir=cache_dir,
        workers=args.workers,
        merge_paragraphs=args.merge_paragraphs,
    )

    success = True

    # --- Native shapes version (primary) ---
    if gen_native:
        if verbose:
            print("PPT Master - SVG to PPTX Tool")
            print("=" * 50)
            print(f"  Project path: {project_path}")
            print(f"  SVG directory: {native_source_dir}")
            print(f"  Output file: {native_path}")
            print()

        ok = create_pptx_with_native_svg(
            output_path=native_path,
            use_native_shapes=True,
            svg_files=native_files,
            conversion_trace_path=(
                native_path.with_name(native_path.name + '.trace.json')
                if args.conversion_trace else None
            ),
            **shared_kwargs,
        )
        success = success and ok

    # --- SVG image reference version ---
    if gen_legacy:
        if verbose:
            if gen_native:
                print()
                print("-" * 50)
            print("PPT Master - SVG to PPTX Tool (SVG Reference)")
            print("=" * 50)
            print(f"  Project path: {project_path}")
            print(f"  SVG directory: {legacy_source_dir}")
            print(f"  Output file: {legacy_path}")
            print()

        ok = create_pptx_with_native_svg(
            output_path=legacy_path,
            use_native_shapes=False,
            svg_files=legacy_files,
            **shared_kwargs,
        )
        success = success and ok

    # svg_output/ snapshot — runs once per export in default-flow mode,
    # decoupled from --svg-snapshot. Preserves the AI-generated SVG sources
    # under .build/backup/latest/svg_output/ for later inspection / re-export.
    # Latest-only: wipe any prior snapshot so backups don't pile up.
    if success and backup_dir is not None:
        svg_output_src = project_path / "svg_output"
        if svg_output_src.is_dir():
            svg_output_dst = backup_dir / "svg_output"
            try:
                if backup_dir.exists():
                    shutil.rmtree(backup_dir)
                backup_dir.mkdir(parents=True, exist_ok=True)
                shutil.copytree(svg_output_src, svg_output_dst)
                if verbose:
                    print(f"  svg_output backup: {svg_output_dst}")
            except Exception as exc:
                if verbose:
                    print(f"  [warn] svg_output backup skipped: {exc}")
        elif verbose:
            print(f"  [info] svg_output/ not found, backup skipped")

    if success and cache_dir is not None and cache_dir.is_dir() and not args.keep_cache:
        try:
            shutil.rmtree(cache_dir)
            cache_parent = cache_dir.parent
            if cache_parent.is_dir() and cache_parent.name == '.cache' and not any(cache_parent.iterdir()):
                cache_parent.rmdir()
        except Exception as exc:
            if verbose:
                print(f"  [warn] cache cleanup skipped: {exc}")

    return 0 if success else 1


if __name__ == '__main__':
    raise SystemExit(main())
