<?php

namespace App\Services\AI;

use App\Contracts\VideoAvatarProvider;
use App\DTOs\VideoRenderResult;
use App\Exceptions\ProviderNotConfiguredException;
use App\Models\Avatar;
use App\Models\Voice;
use App\Services\AI\Concerns\BuildsProductionSafePrompts;
use App\Support\StyleCatalogue;
use App\Support\VeoPresenterImage;
use App\Support\VideoDialogue;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;

/**
 * Renders video creatives with Google's Veo model via the raw Gemini API
 * (laravel/ai has no Veo/video-generation support to wrap, unlike its
 * Image/Audio facades — so this calls generativelanguage.googleapis.com directly).
 *
 * The selected presenter portrait is sent as a reference image. Veo still generates
 * the final dialogue audio itself, so the chosen Gemini TTS voice is a style hint
 * rather than an exact voice track in the finished video.
 */
class GeminiVeoVideoAvatarProvider implements VideoAvatarProvider
{
    use BuildsProductionSafePrompts;

    /** Veo only accepts these exact values; anything else is rejected by the API. */
    private const SUPPORTED_DURATIONS = [4, 6, 8];

    /** Words that look capitalized in offer copy but are not people or brand names. */
    private const GENERIC_COMMERCE_WORDS = [
        'online', 'sale', 'free', 'store', 'pickup', 'shop', 'with', 'and', 'the',
        'for', 'of', 'a', 'an', 'to', 'from', 'discount', 'discounted', 'designer', 'fashion',
    ];

    public function render(Avatar $avatar, Voice $voice, array $script, array $storyboard, int $duration, string $ratio, ?string $style = null, ?callable $onProgress = null, ?array $context = null): VideoRenderResult
    {
        $apiKey = config('ai.providers.gemini.key');

        if (blank($apiKey)) {
            throw new ProviderNotConfiguredException(
                'GEMINI_API_KEY is not set — cannot render video with the Gemini Veo provider.'
            );
        }

        $baseUrl = rtrim(config('ai.providers.gemini.url'), '/');
        $model = config('creative.gemini_veo.model');
        $durationSeconds = $this->clampDuration($duration);
        $spokenLine = $this->fitSpokenLine($this->buildSpokenLine($script, $durationSeconds), $durationSeconds);
        VideoDialogue::assertFits($spokenLine, $durationSeconds);
        $captionLine = $spokenLine;
        $aspectRatio = $ratio === '9:16' ? '9:16' : '16:9';
        $prompt = $this->buildPrompt($avatar, $voice, $script, $storyboard, $durationSeconds, $style, spokenLine: $spokenLine, context: $context, describePresenterInText: true);

        try {
            [$path, $operationName, $usedPrompt] = $this->generateAndStore(
                $baseUrl,
                $apiKey,
                $model,
                $prompt,
                $durationSeconds,
                $aspectRatio,
                $onProgress,
                $avatar,
            );
        } catch (VeoSafetyFilteredException $e) {
            if (! $this->isLikenessFilter($e->filterReason)) {
                throw $e;
            }

            Log::channel('ai_video')->warning('Veo render was filtered for real names or likenesses, retrying without named people or brands', [
                'reason_category' => 'likeness',
            ]);

            $phrases = $this->likenessTriggerPhrases($avatar, $context);
            $retryScript = $this->replacePhrasesInArray($script, $phrases);
            $retryLine = $this->fitSpokenLine($this->buildSpokenLine($retryScript, $durationSeconds), $durationSeconds);
            VideoDialogue::assertFits($retryLine, $durationSeconds);
            $captionLine = $retryLine;
            $retryPrompt = $this->buildPrompt(
                $avatar,
                $voice,
                $retryScript,
                $this->replacePhrasesInArray($storyboard, $phrases),
                $durationSeconds,
                $style,
                spokenLine: $retryLine,
                stageInstruction: 'The presenter is an original fictional commercial actor. Do not depict any real person, public figure, or celebrity likeness.',
                context: $this->contextWithoutLikenessPhrases($context, $phrases),
                describePresenterInText: true,
            );

            [$path, $operationName, $usedPrompt] = $this->generateAndStore(
                $baseUrl,
                $apiKey,
                $model,
                $retryPrompt,
                $durationSeconds,
                $aspectRatio,
                $onProgress,
                $avatar,
            );
        }

        return new VideoRenderResult(
            assetPath: $path,
            meta: ['provider' => 'gemini-veo', 'model' => $model, 'operation' => $operationName, 'duration' => $durationSeconds, 'spoken_line' => $captionLine, 'prompt_hash' => hash('sha256', $usedPrompt)],
        );
    }

    private function fitSpokenLine(string $spokenLine, int $duration): string
    {
        $fitted = VideoDialogue::limitToBudget($spokenLine, $duration);

        if ($fitted !== trim($spokenLine)) {
            Log::channel('ai_video')->info('Veo dialogue trimmed to the duration budget', [
                'requested_duration' => $duration,
                'original_words' => VideoDialogue::wordCount($spokenLine),
                'budget' => VideoDialogue::wordBudget($duration),
            ]);
        }

        return $fitted;
    }

    /** Veo 3.1 only generates 4/6/8-second clips (no 15/30/60s option) — pick the closest supported value. */
    private function clampDuration(int $duration): int
    {
        return collect(self::SUPPORTED_DURATIONS)->sortBy(fn (int $d) => abs($d - $duration))->first();
    }

    public function buildPromptPublic(Avatar $avatar, Voice $voice, array $script, array $storyboard, int $duration, ?string $style = null, ?string $spokenLine = null, ?string $stageInstruction = null, ?array $context = null, bool $describePresenterInText = false): string
    {
        return $this->buildPrompt($avatar, $voice, $script, $storyboard, $duration, $style, $spokenLine, $stageInstruction, $context, $describePresenterInText);
    }

    public function buildSpokenLinePublic(array $script, int $duration): string
    {
        return $this->buildSpokenLine($script, $duration);
    }

    private function buildPrompt(Avatar $avatar, Voice $voice, array $script, array $storyboard, int $duration, ?string $style = null, ?string $spokenLine = null, ?string $stageInstruction = null, ?array $context = null, bool $describePresenterInText = false): string
    {
        $sceneLines = collect($storyboard)
            ->map(fn (array $scene, int $i) => sprintf('Scene %d: %s', $i + 1, $this->normalizeSpokenText($scene['scene'] ?? '')))
            ->implode("\n");

        // Veo (like Sora) only voices lines placed in an explicit "Dialogue:" block with
        // quoted speech — dumping labeled script fields into the prose produces a silent video.
        // The spoken line must also fit the clip's actual length: cramming the whole
        // script into one line for a short clip makes the model skip/mumble the audio
        // entirely rather than speed it up unnaturally, so it's budgeted by duration.
        $spokenLine ??= $this->buildSpokenLine($script, $duration);
        $stageInstruction = filled($stageInstruction) ? "\n\n{$stageInstruction}" : '';

        // UGC swaps the polished-ad framing for an organic, phone-shot testimonial look —
        // same avatar/voice/script/storyboard, just different cinematography instructions.
        // Any other catalogue pick (see StyleCatalogue) feeds its mood/lighting "desc" into the
        // studio-polished framing instead of being silently dropped.
        $stylePrompt = StyleCatalogue::videoPromptFor($style);
        $sceneDescription = $stylePrompt !== null
            ? "A high-quality talking-presenter advertisement video. Creative direction: {$stylePrompt}"
            : 'A high-quality performance advertisement with a stable, front-facing medium close-up during dialogue, polished lighting, controlled camera movement, and product inserts only between spoken phrases.';

        $contextBlock = '';
        if (! empty($context)) {
            $contextLines = [];
            if (! empty($context['project_name'])) {
                $contextLines[] = "Brand/Project: {$context['project_name']}";
            }
            if (! empty($context['brief']['product_name'])) {
                $contextLines[] = "Featured Product: {$context['brief']['product_name']}";
            }
            if (! empty($context['brief']['visual_description'])) {
                $contextLines[] = "Featured Product Physical Appearance: {$context['brief']['visual_description']}";
            }
            if (! empty($context['brief']['features']) && is_array($context['brief']['features'])) {
                $contextLines[] = 'Key Features: '.implode('; ', $context['brief']['features']);
            }
            if (! empty($context['brief']['benefits']) && is_array($context['brief']['benefits'])) {
                $contextLines[] = 'Core Benefits: '.implode('; ', $context['brief']['benefits']);
            }
            if (! empty($context['brief']['messaging'])) {
                $contextLines[] = "Campaign Context: {$context['brief']['messaging']}";
            }
            if (! empty($contextLines)) {
                $contextBlock = "\n\nCommercial Context & Product Integration:\n- "
                    .implode("\n- ", $contextLines)
                    ."\nEnsure any product inserts, packaging, demonstration cutaways, or held items strictly match this exact product design, silhouette, color scheme, materials, and form factor with realistic physical textures, authentic lighting, and believable real-world interaction.";
            }
        }

        $presenterDesc = ! $describePresenterInText && VeoPresenterImage::fromAvatar($avatar) !== null
            ? 'the fictional person in the supplied presenter image; preserve their face, hair, complexion, and outfit consistently'
            : $avatar->visualDescription();

        return trim(sprintf(
            "%s The presenter is an original fictional digital character and does not represent or imitate any real person. Presenter: %s, %s accent, speaking with %s delivery, addressing the camera directly with steady eye contact and natural micro-expressions. Do not depict any real celebrity, public figure, or identifiable real-world person.%s%s\n\n".
            "Dialogue (speak this exact line verbatim, no ad-libs, no paraphrasing, no cut off before or during the call to action, no trailing gibberish or unfinished words after the last word):\n- Presenter: \"%s\"\n\n".
            "Storyboard:\n%s\n\n%s\n\n%s",
            $sceneDescription,
            $presenterDesc,
            $voice->accent ?: ($avatar->accent ?? 'neutral'),
            $voice->tone ?? 'friendly',
            $stageInstruction,
            $contextBlock,
            $spokenLine,
            $sceneLines,
            $this->productionGuardrails(),
            $this->audioAndTextGuardrails(),
        ));
    }

    /**
     * Budgets the spoken dialogue to match natural speech pace for the clip's duration,
     * ensuring the opening Hook and closing CTA are never truncated or severed.
     */
    private function buildSpokenLine(array $script, int $duration): string
    {
        $generatedSpokenLine = $this->normalizeSpokenText(trim((string) ($script['spoken_line'] ?? '')));
        if ($generatedSpokenLine !== '') {
            return $generatedSpokenLine;
        }

        $speakableDuration = max(1, $duration);
        $wordsPerSecond = (float) config('creative.spoken_words_per_second', 2.2);
        $wordBudget = max(6, (int) floor($speakableDuration * $wordsPerSecond));

        $hook = $this->normalizeSpokenText(trim((string) ($script['hook'] ?? '')));
        $problem = $this->normalizeSpokenText(trim((string) ($script['problem'] ?? '')));
        $product = $this->normalizeSpokenText(trim((string) ($script['product'] ?? '')));
        $benefits = $this->normalizeSpokenText(trim((string) ($script['benefits'] ?? '')));
        $cta = $this->normalizeSpokenText(trim((string) ($script['cta'] ?? '')));

        $sections = array_values(array_filter([
            'hook' => $hook,
            'problem' => $problem,
            'product' => $product,
            'benefits' => $benefits,
            'cta' => $cta,
        ]));

        if (empty($sections)) {
            return '';
        }

        $countWords = static function (string $text): int {
            $parts = preg_split('/\s+/', trim($text), flags: PREG_SPLIT_NO_EMPTY);

            return $parts ? count($parts) : 0;
        };

        $totalWords = $countWords(implode(' ', $sections));

        // When all sections fit within the budget, return them all in order
        if ($totalWords <= $wordBudget) {
            return implode(' ', $sections);
        }

        // When budget is constrained, ensure Hook and CTA are never truncated or omitted
        $selected = [];
        if ($hook !== '') {
            $selected['hook'] = $hook;
        }
        if ($cta !== '') {
            $selected['cta'] = $cta;
        }

        $usedWords = $countWords(implode(' ', $selected));

        if ($usedWords <= $wordBudget) {
            $remaining = $wordBudget - $usedWords;
            $candidates = [
                'product' => $product,
                'benefits' => $benefits,
                'problem' => $problem,
            ];

            foreach ($candidates as $key => $candidate) {
                if ($candidate === '') {
                    continue;
                }
                $candWords = $countWords($candidate);
                if ($candWords <= $remaining) {
                    $selected[$key] = $candidate;
                    $remaining -= $candWords;
                }
            }
        }

        $order = ['hook', 'problem', 'product', 'benefits', 'cta'];
        $ordered = [];
        foreach ($order as $key) {
            if (isset($selected[$key])) {
                $ordered[] = $selected[$key];
            }
        }

        if (! empty($ordered)) {
            $result = implode(' ', $ordered);
            $words = preg_split('/\s+/', trim($result), flags: PREG_SPLIT_NO_EMPTY) ?: [];
            if (count($words) > $wordBudget && count($words) > 6) {
                $words = array_slice($words, 0, $wordBudget);
                $result = rtrim(implode(' ', $words), ',;:-');
                if (! preg_match('/[.!?]$/', $result)) {
                    $result .= '.';
                }
            }

            return $result;
        }

        return $hook ?: $cta;
    }

    private function startGeneration(string $baseUrl, string $apiKey, string $model, string $prompt, int $durationSeconds, string $aspectRatio, Avatar $avatar): string
    {
        // veo-3.1-generate-preview rejects referenceImages.inlineData with HTTP 400.
        // 8, 15, and 30 second clips use Vertex, which sends the presenter photo itself.
        if (VeoPresenterImage::fromAvatar($avatar) !== null) {
            Log::channel('ai_video')->info('Veo presenter image omitted because this model rejects inlineData', [
                'model' => $model,
                'duration' => $durationSeconds,
            ]);
        }

        $instance = ['prompt' => $prompt];

        $payload = [
            'instances' => [$instance],
            'parameters' => [
                'durationSeconds' => $durationSeconds,
                'aspectRatio' => $aspectRatio,
                // Veo 3.1 generates audio natively and rejects this param entirely
                // (400 INVALID_ARGUMENT) — do not re-add it.
            ],
        ];

        Log::channel('ai_video')->info('Veo generation request', ['model' => $model, 'duration' => $durationSeconds, 'aspect_ratio' => $aspectRatio, 'prompt_hash' => hash('sha256', $prompt)]);

        $response = Http::withHeaders(['x-goog-api-key' => $apiKey])
            ->timeout(30)
            ->post("{$baseUrl}/models/{$model}:predictLongRunning", $payload);

        Log::channel('ai_video')->info('Veo generation response', ['status' => $response->status()]);

        if ($response->failed()) {
            Log::channel('ai_video')->error('Veo generation request failed', [
                'status' => $response->status(),
                'error_code' => $response->json('error.code'),
                'reason' => $this->loggableReason($response->json('error.message')),
            ]);

            throw new RuntimeException('Veo generation request failed. See logs for the vendor response.');
        }

        $operationName = $response->json('name');

        if (blank($operationName)) {
            throw new RuntimeException('Veo generation did not return an operation name.');
        }

        return $operationName;
    }

    private function pollUntilDone(string $baseUrl, string $apiKey, string $operationName, ?callable $onProgress = null): string
    {
        $interval = (int) config('creative.gemini_veo.poll_interval_seconds', 10);
        $maxAttempts = (int) config('creative.gemini_veo.max_poll_attempts', 30);

        for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
            $onProgress && $onProgress(
                min(90, 15 + (int) floor(75 * ($attempt + 1) / $maxAttempts)),
                'Generating video and speech',
            );

            $response = Http::withHeaders(['x-goog-api-key' => $apiKey])
                ->timeout(30)
                ->get("{$baseUrl}/{$operationName}");

            Log::channel('ai_video')->info('Veo poll response', [
                'attempt' => $attempt + 1,
                'operation' => $operationName,
                'status' => $response->status(),
                'done' => $response->json('done'),
            ]);

            if ($response->failed()) {
                Log::channel('ai_video')->error('Veo operation polling failed', [
                    'status' => $response->status(),
                    'operation' => $operationName,
                    'error_code' => $response->json('error.code'),
                    'reason' => $this->loggableReason($response->json('error.message')),
                ]);

                throw new RuntimeException('Veo operation polling failed. See logs for the vendor response.');
            }

            if ($response->json('done') === true) {
                $onProgress && $onProgress(92, 'Finalizing video');

                if ($response->json('error')) {
                    Log::channel('ai_video')->error('Veo generation failed', [
                        'operation' => $operationName,
                        'error_code' => $response->json('error.code'),
                        'reason' => $this->loggableReason($response->json('error.message')),
                    ]);

                    throw new RuntimeException('Veo generation failed. See logs for the vendor error.');
                }

                $uri = $response->json('response.generateVideoResponse.generatedSamples.0.video.uri');

                if (filled($uri)) {
                    return $uri;
                }

                $filteredCount = (int) $response->json('response.generateVideoResponse.raiMediaFilteredCount', 0);
                $filteredReasons = $response->json('response.generateVideoResponse.raiMediaFilteredReasons', []);

                if ($filteredCount > 0 || ! empty($filteredReasons)) {
                    $reasonText = $this->formatFilterReasons($filteredReasons);
                    Log::channel('ai_video')->warning('Veo safety-filtered the video', [
                        'operation' => $operationName,
                        'filtered_count' => $filteredCount,
                        'reason' => $this->loggableReason($reasonText),
                    ]);

                    throw new VeoSafetyFilteredException($reasonText !== '' ? $reasonText : 'Veo safety-filtered the video.');
                }

                Log::channel('ai_video')->error('Veo operation completed without a video URI', ['operation' => $operationName]);

                throw new RuntimeException('Veo operation completed without a video URI.');
            }

            if ($attempt < $maxAttempts - 1) {
                sleep($interval);
            }
        }

        throw new RuntimeException("Veo video generation timed out after {$maxAttempts} polling attempts.");
    }

    /**
     * @return array{0: string, 1: string, 2: string}
     */
    private function generateAndStore(string $baseUrl, string $apiKey, string $model, string $prompt, int $durationSeconds, string $aspectRatio, ?callable $onProgress, Avatar $avatar): array
    {
        $onProgress && $onProgress(10, 'Submitting video render');
        $operationName = $this->startGeneration($baseUrl, $apiKey, $model, $prompt, $durationSeconds, $aspectRatio, $avatar);
        $videoUri = $this->pollUntilDone($baseUrl, $apiKey, $operationName, $onProgress);
        $onProgress && $onProgress(94, 'Downloading finished video');

        return [$this->downloadAndStore($videoUri, $apiKey), $operationName, $prompt];
    }

    private function isLikenessFilter(string $reason): bool
    {
        $reason = strtolower($reason);

        return str_contains($reason, 'real people')
            || str_contains($reason, 'likeness')
            || str_contains($reason, 'celebrity');
    }

    /**
     * @param  array<string, mixed>|null  $context
     * @return list<string>
     */
    private function likenessTriggerPhrases(Avatar $avatar, ?array $context): array
    {
        $phrases = [];

        if (filled($avatar->name)) {
            $phrases[] = (string) $avatar->name;
        }

        $projectName = is_array($context) ? ($context['project_name'] ?? null) : null;
        if (is_string($projectName) && filled($projectName)) {
            $phrases[] = $projectName;
        }

        $brief = is_array($context) && is_array($context['brief'] ?? null) ? $context['brief'] : [];
        $productName = $brief['product_name'] ?? null;
        if (is_string($productName) && filled($productName)) {
            $leading = $this->leadingProperNounPhrase($productName);
            if ($leading !== null) {
                $phrases[] = $leading;
            }
        }

        foreach (array_merge(
            is_array($brief['features'] ?? null) ? $brief['features'] : [],
            is_array($brief['benefits'] ?? null) ? $brief['benefits'] : [],
            [is_string($brief['messaging'] ?? null) ? $brief['messaging'] : ''],
        ) as $blob) {
            if (! is_string($blob) || $blob === '') {
                continue;
            }

            $phrases = array_merge($phrases, $this->brandsAfterListCue($blob));
        }

        $phrases = array_values(array_unique(array_filter(
            $phrases,
            fn (string $phrase): bool => mb_strlen($phrase) >= 3,
        )));
        usort($phrases, fn (string $a, string $b): int => mb_strlen($b) <=> mb_strlen($a));

        return $phrases;
    }

    private function leadingProperNounPhrase(string $text): ?string
    {
        $words = preg_split('/\s+/', trim($text), flags: PREG_SPLIT_NO_EMPTY) ?: [];
        $taken = [];

        foreach ($words as $word) {
            $plain = strtolower((string) preg_replace('/[^a-z0-9]/i', '', $word));
            if ($plain === '' || in_array($plain, self::GENERIC_COMMERCE_WORDS, true)) {
                break;
            }
            if (! preg_match('/^[A-Z]/', $word)) {
                break;
            }
            $taken[] = $word;
        }

        if ($taken === []) {
            return null;
        }

        return implode(' ', $taken);
    }

    /**
     * @return list<string>
     */
    private function brandsAfterListCue(string $text): array
    {
        if (! preg_match('/(?:including|such as)\s+(.+)$/iu', $text, $match)) {
            return [];
        }

        $parts = preg_split('/\s*,\s*|\s+and\s+/i', $match[1]) ?: [];
        $brands = [];

        foreach ($parts as $part) {
            $part = trim($part, " \t.;:");
            $wordCount = count(preg_split('/\s+/', $part, flags: PREG_SPLIT_NO_EMPTY) ?: []);
            if ($part !== '' && $wordCount <= 3 && mb_strlen($part) <= 40) {
                $brands[] = $part;
            }
        }

        return $brands;
    }

    /**
     * @param  array<string, mixed>|null  $context
     * @param  list<string>  $phrases
     * @return array<string, mixed>|null
     */
    private function contextWithoutLikenessPhrases(?array $context, array $phrases): ?array
    {
        if ($context === null) {
            return null;
        }

        $visualDescription = is_array($context['brief'] ?? null)
            ? ($context['brief']['visual_description'] ?? null)
            : null;
        $mapped = $this->replacePhrasesInArray($context, $phrases);

        if (is_string($visualDescription) && $visualDescription !== '') {
            $mapped['brief'] ??= [];
            $mapped['brief']['visual_description'] = $visualDescription;
        }

        return $mapped;
    }

    /**
     * @param  array<array-key, mixed>  $value
     * @param  list<string>  $phrases
     * @return array<array-key, mixed>
     */
    private function replacePhrasesInArray(array $value, array $phrases): array
    {
        foreach ($value as $key => $item) {
            if (is_string($item)) {
                $value[$key] = $this->stripLikenessPhrases($item, $phrases);
            } elseif (is_array($item)) {
                $value[$key] = $this->replacePhrasesInArray($item, $phrases);
            }
        }

        return $value;
    }

    /**
     * @param  list<string>  $phrases
     */
    private function stripLikenessPhrases(string $text, array $phrases): string
    {
        foreach ($phrases as $phrase) {
            $text = preg_replace('/\b'.preg_quote($phrase, '/').'\b/iu', 'this collection', $text) ?? $text;
        }

        $text = preg_replace('/(?:this collection\s*,\s*)+(?:and\s+)?this collection/i', 'popular brands', $text) ?? $text;

        return trim((string) preg_replace('/\s+/', ' ', $text));
    }

    private function formatFilterReasons(mixed $reasons): string
    {
        $items = is_array($reasons) ? $reasons : [$reasons];

        return implode(' ', array_filter(array_map(
            static fn (mixed $reason): string => is_string($reason) ? $reason : (string) json_encode($reason),
            $items,
        )));
    }

    private function loggableReason(mixed $reason): ?string
    {
        if (! is_string($reason)) {
            return null;
        }

        $normalized = trim(preg_replace('/\s+/u', ' ', $reason) ?? $reason);

        return $normalized === '' ? null : mb_substr($normalized, 0, 1000);
    }

    private function downloadAndStore(string $videoUri, string $apiKey): string
    {
        // Stream straight to a temp file instead of buffering the whole clip in memory via ->body().
        $tmpPath = tempnam(sys_get_temp_dir(), 'veo-');

        try {
            $response = Http::withHeaders(['x-goog-api-key' => $apiKey])->timeout(60)->sink($tmpPath)->get($videoUri);

            if ($response->failed()) {
                throw new RuntimeException('Failed to download the generated video from Gemini.');
            }

            $filename = 'creatives/videos/'.Str::ulid().'.mp4';
            $stream = fopen($tmpPath, 'r');
            $stored = Storage::disk('public')->put($filename, $stream);
            if (is_resource($stream)) {
                fclose($stream);
            }

            if ($stored === false) {
                throw new RuntimeException("Failed to store the rendered video at {$filename}.");
            }

            return $filename;
        } finally {
            if ($tmpPath && file_exists($tmpPath)) {
                @unlink($tmpPath);
            }
        }
    }
}
