<?php

namespace App\Services;

use App\Models\ExportProFile;
use App\Models\Service;
use App\Models\ServiceAgendaItem;
use App\Models\Setting;
use App\Models\Slide;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use ProPresenter\Parser\ProFileGenerator;
use ProPresenter\Parser\ProFileReader;
use ProPresenter\Parser\ProFileWriter;
use ProPresenter\Parser\ProPlaylistGenerator;
use ProPresenter\Parser\Zip64Fixer;
use ZipArchive;

class PlaylistExportService
{
    public function __construct(
        private readonly MacroResolutionService $macroResolutionService,
    ) {}

    /** @return array{path: string, filename: string, skipped: int, warnings: array<int, string>} */
    public function generatePlaylist(Service $service, bool $preview = false): array
    {
        $agendaItems = ServiceAgendaItem::where('service_id', $service->id)
            ->where('is_before_event', false)
            ->orderBy('sort_order')
            ->with([
                'slides' => fn ($q) => $q->whereNull('deleted_at')->orderBy('sort_order'),
                'serviceSong.song.arrangements.arrangementSections.section.slides',
                'serviceSong.song.arrangements.arrangementSections.section.label',
                'serviceSong.arrangement.arrangementSections.section.label',
            ])
            ->get();

        if ($agendaItems->isEmpty()) {
            return $this->generatePlaylistLegacy($service, $preview);
        }

        return $this->generatePlaylistFromAgenda($service, $agendaItems, $preview);
    }

    /**
     * @param  Collection<int, ServiceAgendaItem>  $agendaItems
     * @return array{path: string, filename: string, skipped: int, warnings: array<int, string>, temp_dir: string}
     */
    private function generatePlaylistFromAgenda(Service $service, Collection $agendaItems, bool $preview = false): array
    {
        $informationSlides = $this->informationSlidesFor($service);

        $announcementPatterns = Setting::get('agenda_announcement_position');
        $announcementInserted = false;

        $exportService = app(ProExportService::class);
        $tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
        mkdir($tempDir, 0755, true);

        $playlistItems = [];
        $embeddedFiles = [];
        $skippedUnmatched = 0;
        $warnings = [];

        $moderatorSlideData = $this->buildModeratorSlideData($service);
        $firstVisibleItemId = $agendaItems->firstWhere('is_before_event', false)?->id;
        $kvData = $this->keyVisualData($service);

        $realContentEmitted = false;
        // Whether the previous emission ended on a key-visual slide. Used so a
        // key-visual transition between items is not duplicated at a position a
        // name-tag sequence already frames with the key-visual.
        $lastEndedWithKv = false;

        $keywordFiles = ExportProFile::keyword()->orderBy('order')->get();

        foreach ($agendaItems as $item) {
            if ($item->id === $firstVisibleItemId && $moderatorSlideData !== null) {
                $moderatorName = trim((string) ($moderatorSlideData['text'] ?? ''));
                $moderatorPresentationName = $moderatorName !== '' ? 'Moderator - '.$moderatorName : 'Moderator';
                // The moderator name tag renders as the unified sequence: with a
                // key-visual it is a 3-cue presentation (key-visual → name tag →
                // key-visual); without one, a single name-tag slide.
                $lastEndedWithKv = $this->addNameTagSequence(
                    $moderatorPresentationName,
                    $moderatorSlideData,
                    $kvData,
                    $service,
                    $tempDir,
                    $playlistItems,
                    $embeddedFiles,
                );
            }

            if (! $announcementInserted && $announcementPatterns && $informationSlides->isNotEmpty()) {
                $patterns = array_map('trim', explode(',', $announcementPatterns));
                $matcher = app(AgendaMatcherService::class);
                if ($matcher->matchesAny($item->title, $patterns)) {
                    if ($this->shouldInsertKvTransition($realContentEmitted, $lastEndedWithKv, $kvData)) {
                        $this->addKeyVisualTransition($service, $kvData, $tempDir, $playlistItems, $embeddedFiles);
                    }
                    $this->addSlidesFromCollection(
                        $informationSlides,
                        'information',
                        'Informationen',
                        $tempDir,
                        $playlistItems,
                        $embeddedFiles,
                        $service,
                        'information',
                        'information',
                    );
                    $announcementInserted = true;
                    $realContentEmitted = true;
                    $lastEndedWithKv = false;
                }
            }

            if ($item->serviceSong) {
                $serviceSong = $item->serviceSong;

                if ($serviceSong->song_id && $serviceSong->song) {
                    $song = $serviceSong->song;

                    if ($this->countSongContentSlides($song) === 0) {
                        $skippedUnmatched++;
                        $warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien.";
                    } else {
                        if ($this->shouldInsertKvTransition($realContentEmitted, $lastEndedWithKv, $kvData)) {
                            $this->addKeyVisualTransition($service, $kvData, $tempDir, $playlistItems, $embeddedFiles);
                        }

                        $selectedArr = $serviceSong->arrangement
                            ?? ($song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first());

                        $proPath = $exportService->generateProFile($song, $service, $selectedArr);
                        $proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
                        $destPath = $tempDir.'/'.$proFilename;
                        rename($proPath, $destPath);

                        $embeddedFiles[$proFilename] = file_get_contents($destPath);
                        $this->embedBackground($service, $embeddedFiles);

                        $playlistItem = [
                            'type' => 'presentation',
                            'name' => $song->title,
                            'path' => $proFilename,
                        ];
                        if ($selectedArr?->name !== null) {
                            $playlistItem['arrangement_name'] = $selectedArr->name;
                        }
                        $playlistItems[] = $playlistItem;

                        $realContentEmitted = true;
                        $lastEndedWithKv = false;
                    }
                } else {
                    $skippedUnmatched++;
                }
            } else {
                $partType = $this->backgroundPartTypeForAgendaItem($item);
                $isSermon = $partType === 'sermon';

                if ($isSermon) {
                    // The sermon sequence starts with the preacher name-tag / key-visual
                    // intro, which already begins (and ends) on the key-visual, so NO
                    // separate key-visual transition is inserted before it.
                    $introEndedWithKv = $this->addSermonIntroPresentation(
                        $service,
                        $kvData,
                        $tempDir,
                        $playlistItems,
                        $embeddedFiles,
                    );
                    if ($introEndedWithKv) {
                        $lastEndedWithKv = true;
                    }

                    if ($item->slides->isNotEmpty()) {
                        $countBefore = count($playlistItems);
                        $label = $item->title ?: 'Folien';
                        // 'sermon' position macros (first_slide / last_slide) anchor to the
                        // sermon CONTENT slides emitted here, NOT to the key-visual/name-tag
                        // intro presentation above (which carries only the Namenseinblender
                        // macro). Rule: first_slide fires on the first actual preaching slide
                        // and last_slide on the last, so a "trigger scene at sermon start"
                        // macro lands when the content begins rather than during the intro.
                        $this->addSlidesFromCollection(
                            $item->slides,
                            'agenda_'.$item->id,
                            $label,
                            $tempDir,
                            $playlistItems,
                            $embeddedFiles,
                            $service,
                            $partType,
                            $partType,
                        );

                        if (count($playlistItems) > $countBefore) {
                            $realContentEmitted = true;
                            $lastEndedWithKv = false;
                        }
                    }
                } elseif ($item->slides->isNotEmpty()) {
                    if ($this->shouldInsertKvTransition($realContentEmitted, $lastEndedWithKv, $kvData)) {
                        $this->addKeyVisualTransition($service, $kvData, $tempDir, $playlistItems, $embeddedFiles);
                        $lastEndedWithKv = true;
                    }

                    $countBefore = count($playlistItems);
                    $label = $item->title ?: 'Folien';
                    $this->addSlidesFromCollection(
                        $item->slides,
                        'agenda_'.$item->id,
                        $label,
                        $tempDir,
                        $playlistItems,
                        $embeddedFiles,
                        $service,
                        $partType,
                        $partType,
                    );

                    if (count($playlistItems) > $countBefore) {
                        $realContentEmitted = true;
                        $lastEndedWithKv = false;
                    }
                } elseif (! $this->isNameTagAgendaItem($item)) {
                    // Content-less, non-name-tag item → plain headline. The key-visual
                    // now appears as a transition BETWEEN real content items (see
                    // addKeyVisualTransition), replacing the former one-shot fallback.
                    $this->addHeadlineItem(
                        $item,
                        $playlistItems,
                    );
                }
            }

            foreach ($keywordFiles as $kf) {
                $keyword = trim((string) $kf->keyword);
                if ($keyword === '') {
                    continue;
                }

                if (str_contains(mb_strtolower((string) $item->title), mb_strtolower($keyword))) {
                    $keywordItem = $this->embedExportProFile($kf, $embeddedFiles, $warnings);
                    if ($keywordItem !== null) {
                        $playlistItems[] = $keywordItem;
                        $realContentEmitted = true;
                        $lastEndedWithKv = false;
                    }
                }
            }
        }

        if (! $announcementInserted && $informationSlides->isNotEmpty()) {
            $prependItems = [];
            $prependFiles = [];
            $this->addSlidesFromCollection(
                $informationSlides,
                'information',
                'Informationen',
                $tempDir,
                $prependItems,
                $prependFiles,
                $service,
                'information',
                'information',
            );
            $playlistItems = array_merge($prependItems, $playlistItems);
            $embeddedFiles = array_merge($prependFiles, $embeddedFiles);
        }

        if (empty($playlistItems)) {
            $this->deleteDirectory($tempDir);
            throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
        }

        $this->injectExportProFiles($playlistItems, $embeddedFiles, $warnings);

        $this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);

        $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
        $safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title);

        if ($preview) {
            $exportStamp = now()->format('Y-m-d_H-i');
            $playlistName = 'Vorschau '.$dateFormatted.' '.$service->title.' ('.$exportStamp.')';
            $outputFilename = 'Vorschau '.$dateFormatted.' '.$safeTitle.' '.$exportStamp.'.proplaylist';
        } else {
            $playlistName = $dateFormatted.' '.$service->title;
            $outputFilename = $dateFormatted.' '.$safeTitle.'.proplaylist';
        }

        $this->addCountdownPresentation($service, $tempDir, $playlistItems, $embeddedFiles);

        $outputPath = $tempDir.'/'.$outputFilename;

        $this->writePlaylistFile($outputPath, $playlistName, $playlistItems, $embeddedFiles);

        return [
            'path' => $outputPath,
            'filename' => $outputFilename,
            'skipped' => $skippedUnmatched,
            'warnings' => $warnings,
            'temp_dir' => $tempDir,
        ];
    }

    /**
     * Legacy export for services without agenda items.
     * Hardcoded block order: info → songs → moderation → sermon.
     *
     * @return array{path: string, filename: string, skipped: int, warnings: array<int, string>, temp_dir: string}
     */
    private function generatePlaylistLegacy(Service $service, bool $preview = false): array
    {
        $service->loadMissing('serviceSongs.song.arrangements.arrangementSections.section.slides', 'serviceSongs.song.arrangements.arrangementSections.section.label');

        $matchedSongs = $service->serviceSongs()
            ->whereNotNull('song_id')
            ->orderBy('order')
            ->with(
                'song.arrangements.arrangementSections.section.slides',
                'song.arrangements.arrangementSections.section.label',
                'arrangement.arrangementSections.section.slides',
                'arrangement.arrangementSections.section.label',
            )
            ->get();

        $skippedUnmatched = $service->serviceSongs()->whereNull('song_id')->count();
        $skippedEmpty = 0;
        $warnings = [];

        $exportService = app(ProExportService::class);
        $tempDir = sys_get_temp_dir().'/playlist-export-'.uniqid();
        mkdir($tempDir, 0755, true);

        $playlistItems = [];
        $embeddedFiles = [];

        $this->addSlidePresentation(
            'information',
            'Informationen',
            $service,
            $tempDir,
            $playlistItems,
            $embeddedFiles,
        );

        foreach ($matchedSongs as $serviceSong) {
            $song = $serviceSong->song;

            if (! $song || $this->countSongContentSlides($song) === 0) {
                $skippedEmpty++;
                if ($song !== null) {
                    $warnings[] = "Lied '".$song->title."' übersprungen: keine Inhaltsfolien.";
                }

                continue;
            }

            $selectedArr = $serviceSong->arrangement
                ?? ($song->arrangements->firstWhere('is_default', true) ?? $song->arrangements->first());

            $proPath = $exportService->generateProFile($song, $service, $selectedArr);
            $proFilename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $song->title).'.pro';
            $destPath = $tempDir.'/'.$proFilename;
            rename($proPath, $destPath);

            $embeddedFiles[$proFilename] = file_get_contents($destPath);
            $this->embedBackground($service, $embeddedFiles);

            $playlistItem = [
                'type' => 'presentation',
                'name' => $song->title,
                'path' => $proFilename,
            ];
            if ($selectedArr?->name !== null) {
                $playlistItem['arrangement_name'] = $selectedArr->name;
            }
            $playlistItems[] = $playlistItem;
        }

        $this->addSlidePresentation(
            'moderation',
            'Moderation',
            $service,
            $tempDir,
            $playlistItems,
            $embeddedFiles,
        );

        $this->addSlidePresentation(
            'sermon',
            'Predigt',
            $service,
            $tempDir,
            $playlistItems,
            $embeddedFiles,
        );

        if (empty($playlistItems)) {
            $this->deleteDirectory($tempDir);
            throw new \RuntimeException('Keine Songs mit Inhalt zum Exportieren gefunden.');
        }

        $this->injectExportProFiles($playlistItems, $embeddedFiles, $warnings);

        $this->addAbspannPresentation($service, $tempDir, $playlistItems, $embeddedFiles);

        $dateFormatted = $service->date?->format('Y-m-d') ?? now()->format('Y-m-d');
        $safeTitle = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $service->title);

        if ($preview) {
            $exportStamp = now()->format('Y-m-d_H-i');
            $playlistName = 'Vorschau '.$dateFormatted.' '.$service->title.' ('.$exportStamp.')';
            $outputFilename = 'Vorschau '.$dateFormatted.' '.$safeTitle.' '.$exportStamp.'.proplaylist';
        } else {
            $playlistName = $dateFormatted.' '.$service->title;
            $outputFilename = $dateFormatted.' '.$safeTitle.'.proplaylist';
        }

        $this->addCountdownPresentation($service, $tempDir, $playlistItems, $embeddedFiles);

        $outputPath = $tempDir.'/'.$outputFilename;

        $this->writePlaylistFile($outputPath, $playlistName, $playlistItems, $embeddedFiles);

        return [
            'path' => $outputPath,
            'filename' => $outputFilename,
            'skipped' => $skippedUnmatched + $skippedEmpty,
            'warnings' => $warnings,
            'temp_dir' => $tempDir,
        ];
    }

    private function addSlidesFromCollection(
        Collection $slides,
        string $prefix,
        string $label,
        string $tempDir,
        array &$playlistItems,
        array &$embeddedFiles,
        ?Service $service = null,
        ?string $backgroundPartType = null,
        ?string $macroPartType = null,
    ): void {
        $slideDataList = [];
        $imageFiles = [];
        $background = $this->backgroundData($service);
        $backgroundAttached = false;

        foreach ($slides->values() as $index => $slide) {
            $storedPath = Storage::disk('public')->path($slide->stored_filename);

            if (! file_exists($storedPath)) {
                continue;
            }

            $imageFilename = $prefix.'_'.($index + 1).'_'.basename($slide->stored_filename);
            $destPath = $tempDir.'/'.$imageFilename;
            copy($storedPath, $destPath);

            $imageFiles[$imageFilename] = file_get_contents($destPath);

            $singleSlideData = [
                'media' => $imageFilename,
                'format' => 'JPG',
                'label' => $slide->original_filename,
                'bundleRelative' => true,
            ];

            if ($this->shouldAttachBackground($slide, $backgroundPartType, $background)) {
                $singleSlideData['background'] = $background;
                $backgroundAttached = true;
            }

            $slideDataList[] = $singleSlideData;
        }

        if ($backgroundAttached) {
            $this->embedBackground($service, $embeddedFiles);
        }

        if (empty($slideDataList)) {
            return;
        }

        if ($service !== null && $macroPartType !== null) {
            $total = count($slideDataList);
            foreach ($slideDataList as $i => &$slideData) {
                $macros = $this->macroResolutionService->macrosForSlide(
                    $service,
                    $macroPartType,
                    ['index' => $i, 'total' => $total, 'label_id' => null],
                );

                if (! empty($macros)) {
                    // ProPresenter parser currently supports one `macro` entry per slide
                    $slideData['macro'] = $macros[0];
                }
            }
            unset($slideData);
        }

        $groups = [
            [
                'name' => $label,
                'color' => [0.5, 0.5, 0.5, 1.0],
                'slides' => $slideDataList,
            ],
        ];

        $arrangements = [
            [
                'name' => 'Standard',
                'groupNames' => [$label],
            ],
        ];

        $safeLabel = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $label);
        $proFilename = $safeLabel.'.pro';
        $proPath = $tempDir.'/'.$proFilename;

        $this->writeProFile($proPath, $label, $groups, $arrangements);

        foreach ($imageFiles as $filename => $contents) {
            $embeddedFiles[$filename] = $contents;
        }

        $embeddedFiles[$proFilename] = file_get_contents($proPath);

        $playlistItems[] = [
            'type' => 'presentation',
            'name' => $label,
            'path' => $proFilename,
        ];
    }

    private function addSlidePresentation(
        string $type,
        string $label,
        Service $service,
        string $tempDir,
        array &$playlistItems,
        array &$embeddedFiles,
    ): void {
        $query = Slide::where('type', $type)
            ->whereNull('deleted_at');

        if ($type === 'information') {
            $query->where(function ($q) use ($service) {
                $q->whereNull('service_id')
                    ->orWhere('service_id', $service->id);
            });

            if ($service->date) {
                $query->where(function ($q) use ($service) {
                    $q->whereNull('expire_date')
                        ->orWhereDate('expire_date', '>=', $service->date);
                })->whereDate('uploaded_at', '<=', $service->date);
            }
        } else {
            $query->where('service_id', $service->id);
        }

        $slides = $query->orderBy('sort_order')->orderByDesc('uploaded_at')->get();

        if ($slides->isEmpty()) {
            return;
        }

        $this->addSlidesFromCollection(
            $slides,
            $type,
            $label,
            $tempDir,
            $playlistItems,
            $embeddedFiles,
            $service,
            $type,
            $type,
        );
    }

    /**
     * Whether a standalone key-visual transition should be inserted before the next
     * real-content emission: only once real content exists (no leading key-visual),
     * only when the previous emission did not already end on a key-visual, and only
     * when a key-visual is resolvable.
     */
    private function shouldInsertKvTransition(bool $realContentEmitted, bool $lastEndedWithKv, ?array $kvData): bool
    {
        return $realContentEmitted && ! $lastEndedWithKv && $kvData !== null;
    }

    /**
     * Emit a standalone key-visual transition presentation (an image-only slide with
     * the key-visual as a BACKGROUND media action) between two real content items.
     */
    private function addKeyVisualTransition(
        Service $service,
        array $kvData,
        string $tempDir,
        array &$playlistItems,
        array &$embeddedFiles,
    ): void {
        $this->embedKeyVisual($service, $embeddedFiles);

        $name = 'Keyvisual';
        $groups = [
            [
                'name' => $name,
                'color' => [0, 0, 0, 1],
                'slides' => [
                    [
                        'imageOnly' => true,
                        'background' => $kvData,
                    ],
                ],
            ],
        ];
        $arrangements = [
            [
                'name' => 'normal',
                'groupNames' => [$name],
            ],
        ];

        $proFilename = 'Keyvisual-'.uniqid().'.pro';
        $proPath = $tempDir.'/'.$proFilename;

        $this->writeProFile($proPath, $name, $groups, $arrangements);

        $embeddedFiles[$proFilename] = file_get_contents($proPath);

        $playlistItems[] = [
            'type' => 'presentation',
            'name' => $name,
            'path' => $proFilename,
        ];
    }

    /**
     * Prepend the pre-service COUNTDOWN standby presentation as the very FIRST
     * playlist item. It is a single image-only slide: the key-visual as a
     * background media layer plus a live wall-clock (hh:mm, 24h) text element in
     * the upper-left corner.
     *
     * Only emitted when the `add_countdown_slide` setting is enabled AND a
     * key-visual is resolvable; without a key-visual the clock has no backdrop
     * and the slide is skipped gracefully.
     */
    private function addCountdownPresentation(
        Service $service,
        string $tempDir,
        array &$playlistItems,
        array &$embeddedFiles,
    ): void {
        if (Setting::get('add_countdown_slide') !== '1') {
            return;
        }

        $kvData = $this->keyVisualData($service);
        if ($kvData === null) {
            return;
        }

        $this->embedKeyVisual($service, $embeddedFiles);

        $name = 'Countdown';
        $groups = [
            [
                'name' => $name,
                'color' => [0, 0, 0, 1],
                'slides' => [
                    [
                        'imageOnly' => true,
                        'background' => $kvData,
                        'clock' => [
                            'format' => 'HH:mm',
                            'bounds' => ['x' => 60, 'y' => 40, 'width' => 600, 'height' => 200],
                        ],
                    ],
                ],
            ],
        ];
        $arrangements = [
            [
                'name' => 'normal',
                'groupNames' => [$name],
            ],
        ];

        $proFilename = 'Countdown-'.uniqid().'.pro';
        $proPath = $tempDir.'/'.$proFilename;

        $this->writeProFile($proPath, $name, $groups, $arrangements);

        $embeddedFiles[$proFilename] = file_get_contents($proPath);

        array_unshift($playlistItems, [
            'type' => 'presentation',
            'name' => $name,
            'path' => $proFilename,
        ]);
    }

    private function addHeadlineItem(
        ServiceAgendaItem $item,
        array &$playlistItems,
    ): void {
        $playlistItems[] = [
            'type' => 'header',
            'name' => $item->title ?: 'Ablaufpunkt',
            'color' => [0.5, 0.5, 0.5, 1.0],
        ];
    }

    private function injectExportProFiles(array &$playlistItems, array &$embeddedFiles, array &$warnings): void
    {
        $prefixItems = $this->buildExportProItems(
            ExportProFile::prefix()->orderBy('order')->get(),
            $embeddedFiles,
            $warnings,
        );

        $postfixItems = $this->buildExportProItems(
            ExportProFile::postfix()->orderBy('order')->get(),
            $embeddedFiles,
            $warnings,
        );

        $playlistItems = array_merge($prefixItems, $playlistItems, $postfixItems);
    }

    /** @param \Illuminate\Support\Collection<int, ExportProFile> $files */
    private function buildExportProItems(\Illuminate\Support\Collection $files, array &$embedded, array &$warnings): array
    {
        $items = [];

        foreach ($files as $file) {
            $item = $this->embedExportProFile($file, $embedded, $warnings);
            if ($item !== null) {
                $items[] = $item;
            }
        }

        return $items;
    }

    /**
     * Read a single export .pro/.probundle file from the 'local' disk, embed its
     * bytes (and any .probundle media) into $embeddedFiles, and return the matching
     * playlist presentation item. Returns null (and records a German warning) when
     * the file is missing, a .probundle has no inner .pro entry, or the embedded
     * .pro parses to zero content slides.
     *
     * The file type is derived from the ORIGINAL (already-validated) client
     * filename, NOT from the stored path: Laravel's store() names uploads by the
     * guessed MIME extension, so a .probundle (a ZIP) is persisted as .zip.
     * Branching on the stored path would then miss the probundle branch and embed
     * the raw ZIP bytes as a broken .pro (ProPresenter shows an empty slide).
     *
     * The embedded .pro name uses the uppercased type as marker
     * (PREFIX/POSTFIX/KEYWORD) so prefix/postfix naming stays identical.
     *
     * @return array{type: string, name: string, path: string}|null
     */
    private function embedExportProFile(ExportProFile $file, array &$embeddedFiles, array &$warnings): ?array
    {
        if (! Storage::disk('local')->exists($file->stored_path)) {
            return null;
        }

        $ext = strtolower(pathinfo($file->original_name, PATHINFO_EXTENSION));
        $safeBase = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', pathinfo($file->original_name, PATHINFO_FILENAME));
        $embeddedProName = strtoupper($file->type).'_'.$file->order.'_'.$safeBase.'.pro';
        $displayName = pathinfo($file->original_name, PATHINFO_FILENAME);

        // Treat both .probundle and .zip as bundles: a .probundle is a ZIP, and
        // mis-stored uploads may end up with a .zip extension.
        $isBundle = $ext === 'probundle' || $ext === 'zip';
        $mediaFiles = [];

        if ($isBundle) {
            $storedAbsPath = Storage::disk('local')->path($file->stored_path);

            $extracted = $this->extractProBundle($storedAbsPath);
            if ($extracted === null) {
                $warnings[] = "Export-Datei '".$file->original_name."' übersprungen: keine Folien gefunden.";

                return null;
            }

            [$proBytes, $mediaFiles] = $extracted;
        } else {
            $proBytes = Storage::disk('local')->get($file->stored_path);
            if ($proBytes === null) {
                return null;
            }
        }

        // Fail safe: a contentless export .pro would render an empty slide in
        // ProPresenter. Skip it with a warning when it parses to zero content
        // slides. Bytes that cannot be parsed at all (unverifiable) are embedded
        // verbatim to preserve the legacy pass-through behaviour.
        if ($this->contentSlideCountFromProBytes($proBytes) === 0) {
            $warnings[] = "Export-Datei '".$file->original_name."' übersprungen: keine Folien gefunden.";

            return null;
        }

        foreach ($mediaFiles as $entryName => $entryBytes) {
            $mediaName = basename((string) $entryName);
            if (! isset($embeddedFiles[$mediaName])) {
                $embeddedFiles[$mediaName] = $entryBytes;
            }
        }

        $embeddedFiles[$embeddedProName] = $proBytes;

        return [
            'type' => 'presentation',
            'name' => $displayName,
            'path' => $embeddedProName,
        ];
    }

    /**
     * Count content slides across all groups of an embedded .pro payload. Returns
     * null when the bytes cannot be parsed as a ProPresenter presentation, so the
     * caller can distinguish "verified empty" (0) from "unverifiable" (null).
     */
    private function contentSlideCountFromProBytes(string $proBytes): ?int
    {
        if ($proBytes === '') {
            return 0;
        }

        $tempPath = tempnam(sys_get_temp_dir(), 'export-pro-verify-');
        if ($tempPath === false) {
            return null;
        }

        try {
            if (@file_put_contents($tempPath, $proBytes) === false) {
                return null;
            }

            $song = ProFileReader::read($tempPath);

            $count = 0;
            foreach ($song->getGroups() as $group) {
                $count += count($song->getSlidesForGroup($group));
            }

            return $count;
        } catch (\Throwable) {
            return null;
        } finally {
            @unlink($tempPath);
        }
    }

    /**
     * Extract the inner .pro bytes (verbatim) and media files from a .probundle archive.
     *
     * Uses raw ZipArchive extraction so the original .pro bytes are preserved exactly
     * (no protobuf round-trip). ProPresenter-authored bundles use Zip64; Zip64Fixer is
     * applied first so those archives open correctly. Normal (non-Zip64) archives pass
     * through Zip64Fixer unchanged. Returns null when the bundle has no .pro entry.
     *
     * @return array{0: string, 1: array<string, string>}|null [proBytes, mediaFiles]
     */
    private function extractProBundle(string $absPath): ?array
    {
        $rawBytes = @file_get_contents($absPath);
        if ($rawBytes === false || $rawBytes === '') {
            return null;
        }

        try {
            $fixedBytes = Zip64Fixer::fix($rawBytes);
        } catch (\Throwable) {
            // Not Zip64-fixable: fall back to the original bytes.
            $fixedBytes = $rawBytes;
        }

        $tempPath = tempnam(sys_get_temp_dir(), 'export-probundle-');
        if ($tempPath === false) {
            return null;
        }

        $zip = new ZipArchive;
        $isOpen = false;

        try {
            if (@file_put_contents($tempPath, $fixedBytes) === false) {
                return null;
            }

            if ($zip->open($tempPath) !== true) {
                return null;
            }
            $isOpen = true;

            $proFilename = null;
            for ($i = 0; $i < $zip->numFiles; $i++) {
                $name = $zip->getNameIndex($i);
                if ($name !== false && str_ends_with(strtolower($name), '.pro')) {
                    $proFilename = $name;
                    break;
                }
            }

            if ($proFilename === null) {
                return null;
            }

            $proBytes = $zip->getFromName($proFilename);
            if ($proBytes === false || $proBytes === '') {
                return null;
            }

            $mediaFiles = [];
            for ($i = 0; $i < $zip->numFiles; $i++) {
                $name = $zip->getNameIndex($i);
                if ($name === false || $name === $proFilename) {
                    continue;
                }

                $contents = $zip->getFromIndex($i);
                if ($contents === false) {
                    continue;
                }

                $mediaFiles[$name] = $contents;
            }

            return [$proBytes, $mediaFiles];
        } finally {
            if ($isOpen) {
                $zip->close();
            }
            @unlink($tempPath);
        }
    }

    protected function writeProFile(string $path, string $name, array $groups, array $arrangements, array $options = []): void
    {
        // Split generate → inject → write: convert FOREGROUND (uploaded) image media
        // actions into content fill.media ELEMENTS; BACKGROUND actions (service
        // background / key-visual) stay as background-layer media actions.
        $song = ProFileGenerator::generate($name, $groups, $arrangements, [], $options);
        app(SlideMediaElementBuilder::class)->replaceImageActionsWithElements($song);
        ProFileWriter::write($song, $path);
    }

    private function buildModeratorSlideData(Service $service): ?array
    {
        $name = app(NameTagResolver::class)->moderatorFor($service);
        if ($name === null) {
            return null;
        }

        return app(NameTagSlideBuilder::class)->buildModeratorSlide($name);
    }

    private function buildPreacherSlideData(Service $service): ?array
    {
        $name = app(NameTagResolver::class)->preacherFor($service);
        if ($name === null) {
            return null;
        }

        return app(NameTagSlideBuilder::class)->buildPreacherSlide($name);
    }

    /**
     * Emit the sermon intro as ONE presentation that begins (and ends) on the
     * key-visual:
     *   - With a preacher name tag: the unified 3-cue name-tag sequence
     *     (key-visual → name tag (10s auto-next) → key-visual), named "Prediger - <Name>".
     *   - Without a name tag but with a key-visual: a single key-visual slide named
     *     "Keyvisual-Predigt".
     *   - With neither: nothing.
     *
     * Returns true when a presentation ending on a key-visual slide was emitted.
     */
    private function addSermonIntroPresentation(Service $service, ?array $kvData, string $tempDir, array &$playlistItems, array &$embeddedFiles): bool
    {
        $nameTagData = $this->buildPreacherSlideData($service);

        if ($kvData === null && $nameTagData === null) {
            return false;
        }

        if ($nameTagData !== null) {
            $preacher = trim((string) ($nameTagData['text'] ?? ''));
            $name = $preacher !== '' ? 'Prediger - '.$preacher : 'Prediger';

            return $this->addNameTagSequence($name, $nameTagData, $kvData, $service, $tempDir, $playlistItems, $embeddedFiles);
        }

        // Key-visual present, no name tag → a single key-visual slide.
        $this->embedKeyVisual($service, $embeddedFiles);
        $name = 'Keyvisual-Predigt';
        $groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => [['imageOnly' => true, 'background' => $kvData]]]];
        $arrangements = [['name' => 'normal', 'groupNames' => [$name]]];
        $filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $name).'-'.uniqid().'.pro';
        $path = $tempDir.'/'.$filename;
        $this->writeProFile($path, $name, $groups, $arrangements);
        $embeddedFiles[$filename] = file_get_contents($path);
        $playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename];

        return true;
    }

    /**
     * Emit a name tag as ONE presentation. With a resolvable key-visual it is a
     * THREE-cue sequence:
     *   cue 1 = key-visual only (image, no text)
     *   cue 2 = name tag over the key-visual, its Namenseinblender macro, 10s auto-next
     *   cue 3 = key-visual only
     * Without a key-visual it degrades to a single name-tag text slide.
     *
     * Returns true when the presentation ends on a key-visual slide (the 3-cue form
     * was emitted); used to de-duplicate key-visual transitions between items.
     *
     * @param  array<string, mixed>  $nameTagData
     * @param  array<string, mixed>|null  $kvData
     */
    private function addNameTagSequence(
        string $presentationName,
        array $nameTagData,
        ?array $kvData,
        Service $service,
        string $tempDir,
        array &$playlistItems,
        array &$embeddedFiles,
    ): bool {
        $endsWithKv = $kvData !== null;

        if ($endsWithKv) {
            $this->embedKeyVisual($service, $embeddedFiles);

            $nameTagData['background'] = $kvData;
            $nameTagData['completion'] = ['time' => 10.0, 'target' => 'next'];

            $slides = [
                ['imageOnly' => true, 'background' => $kvData],
                $nameTagData,
                ['imageOnly' => true, 'background' => $kvData],
            ];
        } else {
            $slides = [$nameTagData];
        }

        $groups = [['name' => $presentationName, 'color' => [0, 0, 0, 1], 'slides' => $slides]];
        $arrangements = [['name' => 'normal', 'groupNames' => [$presentationName]]];
        $filename = preg_replace('/[^a-zA-Z0-9äöüÄÖÜß\-_ ]/', '', $presentationName).'-'.uniqid().'.pro';
        $path = $tempDir.'/'.$filename;
        $this->writeProFile($path, $presentationName, $groups, $arrangements);
        $embeddedFiles[$filename] = file_get_contents($path);
        $playlistItems[] = ['type' => 'presentation', 'name' => $presentationName, 'path' => $filename];

        return $endsWithKv;
    }

    /**
     * Information slides visible for the given service: type=information, not soft-deleted,
     * global or this-service, unexpired at (and uploaded on/before) the service date.
     *
     * @return Collection<int, Slide>
     */
    private function informationSlidesFor(Service $service): Collection
    {
        $query = Slide::where('type', 'information')
            ->where(fn ($q) => $q->whereNull('expire_date')->orWhereDate('expire_date', '>=', $service->date))
            ->where(fn ($q) => $q->whereNull('service_id')->orWhere('service_id', $service->id))
            ->whereNull('deleted_at');

        if ($service->date) {
            $query->whereDate('uploaded_at', '<=', $service->date);
        }

        return $query->orderBy('sort_order')->orderByDesc('uploaded_at')->get();
    }

    /**
     * Append the closing "Abspann" presentation as the very last playlist item.
     *
     * Slides: key-visual (first) followed by ALL information slides. Every cue
     * auto-advances after 10s; the last cue loops back to the first ("auto repeat
     * all"). The presentation uses a Dissolve transition and each cue carries any
     * configured 'abspann' macro. Emits nothing when there is neither a key-visual
     * nor an information slide.
     */
    private function addAbspannPresentation(Service $service, string $tempDir, array &$playlistItems, array &$embeddedFiles): void
    {
        $informationSlides = $this->informationSlidesFor($service);
        $kvData = $this->keyVisualData($service);

        if ($kvData === null && $informationSlides->isEmpty()) {
            return;
        }

        $slides = [];

        if ($kvData !== null) {
            $this->embedKeyVisual($service, $embeddedFiles);
            $slides[] = [
                'imageOnly' => true,
                'background' => $kvData,
                'completion' => ['time' => 10.0, 'target' => 'next'],
            ];
        }

        foreach ($informationSlides->values() as $index => $slide) {
            $storedPath = Storage::disk('public')->path($slide->stored_filename);

            if (! file_exists($storedPath)) {
                continue;
            }

            $imageFilename = 'abspann_'.($index + 1).'_'.basename($slide->stored_filename);
            $destPath = $tempDir.'/'.$imageFilename;
            copy($storedPath, $destPath);

            $embeddedFiles[$imageFilename] = file_get_contents($destPath);

            $slides[] = [
                'media' => $imageFilename,
                'format' => 'JPG',
                'label' => $slide->original_filename,
                'bundleRelative' => true,
                'completion' => ['time' => 10.0, 'target' => 'next'],
            ];
        }

        if (empty($slides)) {
            return;
        }

        $total = count($slides);
        foreach ($slides as $i => &$sd) {
            $macros = $this->macroResolutionService->macrosForSlide($service, 'abspann', [
                'index' => $i,
                'total' => $total,
                'label_id' => null,
            ]);
            if (! empty($macros)) {
                $sd['macro'] = $macros[0];
            }
        }
        unset($sd);

        $slides[$total - 1]['completion']['target'] = 'first';

        $name = 'Abspann';
        $groups = [['name' => $name, 'color' => [0, 0, 0, 1], 'slides' => $slides]];
        $arrangements = [['name' => 'normal', 'groupNames' => [$name]]];

        $filename = 'Abspann-'.uniqid().'.pro';
        $path = $tempDir.'/'.$filename;
        $this->writeProFile($path, $name, $groups, $arrangements, ['transition' => 'dissolve', 'transitionDuration' => 1.0]);
        $embeddedFiles[$filename] = file_get_contents($path);
        $playlistItems[] = ['type' => 'presentation', 'name' => $name, 'path' => $filename];
    }

    /**
     * Number of content slides in the song's default arrangement.
     * Content = SongSlide rows belonging to NON-locked sections (locked false/null)
     * that have a label. Locked prefix/postfix sections are ignored even if non-empty.
     */
    private function countSongContentSlides(\App\Models\Song $song): int
    {
        $song->loadMissing('arrangements.arrangementSections.section.slides', 'arrangements.arrangementSections.section.label');

        $defaultArr = $song->arrangements->firstWhere('is_default', true)
            ?? $song->arrangements->first();

        if ($defaultArr === null) {
            return 0;
        }

        $count = 0;
        $seenSectionIds = [];

        foreach ($defaultArr->arrangementSections->sortBy('order') as $arrangementSection) {
            $section = $arrangementSection->section;

            if ($section === null || $section->label === null) {
                continue;
            }

            if ($section->locked === true) {
                continue;
            }

            if (in_array($section->id, $seenSectionIds, true)) {
                continue;
            }
            $seenSectionIds[] = $section->id;

            $count += $section->slides->count();
        }

        return $count;
    }

    private function backgroundData(?Service $service): ?array
    {
        if ($service === null) {
            return null;
        }

        if ($this->backgroundSourcePath($service) === null) {
            return null;
        }

        return [
            'path' => ServiceImageResolver::BACKGROUND_EXPORT_NAME,
            'format' => 'JPG',
            'width' => 1920,
            'height' => 1080,
            'bundleRelative' => true,
        ];
    }

    private function keyVisualData(Service $service): ?array
    {
        if ($this->keyVisualSourcePath($service) === null) {
            return null;
        }

        return [
            'path' => ServiceImageResolver::KEY_VISUAL_EXPORT_NAME,
            'format' => 'JPG',
            'width' => 1920,
            'height' => 1080,
            'bundleRelative' => true,
        ];
    }

    /** Absolute filesystem path of the resolved background image, or null. */
    private function backgroundSourcePath(?Service $service): ?string
    {
        if ($service === null) {
            return null;
        }

        $background = app(ServiceImageResolver::class)->backgroundFor($service);

        if ($background === null || ! Storage::disk('public')->exists($background)) {
            return null;
        }

        return Storage::disk('public')->path($background);
    }

    /** Absolute filesystem path of the resolved key-visual image, or null. */
    private function keyVisualSourcePath(Service $service): ?string
    {
        $keyVisual = app(ServiceImageResolver::class)->keyVisualFor($service);

        if ($keyVisual === null || ! Storage::disk('public')->exists($keyVisual)) {
            return null;
        }

        return Storage::disk('public')->path($keyVisual);
    }

    /** Embed the resolved background image bytes into the archive under the fixed export name. */
    private function embedBackground(?Service $service, array &$embeddedFiles): void
    {
        $sourcePath = $this->backgroundSourcePath($service);

        if ($sourcePath === null || isset($embeddedFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME])) {
            return;
        }

        $contents = @file_get_contents($sourcePath);
        if ($contents !== false) {
            $embeddedFiles[ServiceImageResolver::BACKGROUND_EXPORT_NAME] = $contents;
        }
    }

    /** Embed the resolved key-visual image bytes into the archive under the fixed export name. */
    private function embedKeyVisual(Service $service, array &$embeddedFiles): void
    {
        $sourcePath = $this->keyVisualSourcePath($service);

        if ($sourcePath === null || isset($embeddedFiles[ServiceImageResolver::KEY_VISUAL_EXPORT_NAME])) {
            return;
        }

        $contents = @file_get_contents($sourcePath);
        if ($contents !== false) {
            $embeddedFiles[ServiceImageResolver::KEY_VISUAL_EXPORT_NAME] = $contents;
        }
    }

    private function shouldAttachBackground(Slide $slide, ?string $backgroundPartType, ?array $background): bool
    {
        if ($background === null) {
            return false;
        }

        if ($backgroundPartType !== 'sermon' && $slide->type !== 'sermon') {
            return false;
        }

        return $slide->cover_mode !== true;
    }

    private function backgroundPartTypeForAgendaItem(ServiceAgendaItem $item): string
    {
        if ($item->slides->contains(fn (Slide $slide) => $slide->type === 'sermon')) {
            return 'sermon';
        }

        $sermonPatterns = Setting::get('agenda_sermon_matching');
        if ($sermonPatterns === null) {
            return 'agenda_item';
        }

        $patterns = array_map('trim', explode(',', $sermonPatterns));

        return app(AgendaMatcherService::class)->matchesAny($item->title, $patterns)
            ? 'sermon'
            : 'agenda_item';
    }

    private function isNameTagAgendaItem(ServiceAgendaItem $item): bool
    {
        $title = mb_strtolower($item->title ?? '');
        $type = mb_strtolower($item->type ?? '');

        return str_contains($title, 'nametag')
            || str_contains($title, 'namenseinblender')
            || str_contains($type, 'nametag')
            || str_contains($type, 'namenseinblender');
    }

    protected function writePlaylistFile(string $path, string $name, array $items, array $embeddedFiles): void
    {
        ProPlaylistGenerator::generateAndWrite($path, $name, $items, $embeddedFiles);
    }

    private function deleteDirectory(string $dir): void
    {
        if (! is_dir($dir)) {
            return;
        }

        $items = scandir($dir);
        foreach ($items as $item) {
            if ($item === '.' || $item === '..') {
                continue;
            }

            $path = $dir.'/'.$item;
            is_dir($path) ? $this->deleteDirectory($path) : unlink($path);
        }
        rmdir($dir);
    }
}
