<?php

namespace App\Services\AI;

use App\Contracts\VideoAvatarProvider;
use App\DTOs\VideoRenderResult;
use App\Exceptions\HiggsfieldRenderFailedException;
use App\Exceptions\HiggsfieldRenderPendingException;
use App\Exceptions\ProviderNotConfiguredException;
use App\Exceptions\VideoPromptFilteredException;
use App\Models\Avatar;
use App\Models\Voice;
use App\Services\AI\Concerns\BuildsProductionSafePrompts;
use App\Support\StyleCatalogue;
use App\Support\VideoDialogue;
use Illuminate\Http\Client\Response;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use InvalidArgumentException;
use RuntimeException;

/**
 * Renders a video through the Higgsfield text-to-video API.
 * The model comes from config('creative.higgsfield_model'): wan-3.0 or seedance-2.5.
 * This adapter is selected only when the creative's engine is higgsfield-seedance.
 * It does not call Gemini or Vertex, and it is not chosen by video duration.
 */
class HiggsfieldSeedanceVideoAvatarProvider implements VideoAvatarProvider
{
    use BuildsProductionSafePrompts;

    public const ENGINE = 'higgsfield-seedance';

    /** @var list<string> */
    private const ASPECT_RATIOS = ['16:9', '4:3', '1:1', '3:4', '9:16', '21:9'];

    /** @var list<string> */
    private const TERMINAL_STATUSES = ['completed', 'failed', 'nsfw', 'canceled'];

    /** @var null|callable(array{request_id: string, status_url: string, model: string, duration: int, spoken_line: string, prompt_hash: string}): void */
    private $onSubmitted = null;

    /**
     * @param  callable(array{request_id: string, status_url: string, model: string, duration: int, spoken_line: string, prompt_hash: string}): void  $callback
     */
    public function whenSubmitted(callable $callback): void
    {
        $this->onSubmitted = $callback;
    }

    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
    {
        [$keyId, $keySecret] = $this->credentials();
        $durationSeconds = $this->durationSeconds($duration);
        $aspectRatio = in_array($ratio, self::ASPECT_RATIOS, true) ? $ratio : '16:9';
        $spokenLine = $this->fittedSpokenLine($script, $durationSeconds);
        $model = $this->selectedModel();
        $prompt = $model['model'] === 'alibaba/wan-3.0'
            ? $this->buildPromptForWan($avatar, $voice, $storyboard, $style, $context, $spokenLine, $durationSeconds, $aspectRatio)
            : $this->buildPrompt($avatar, $voice, $storyboard, $style, $context, $spokenLine, $durationSeconds, $aspectRatio);
        $onProgress && $onProgress(10, 'Submitting video render');
        $submission = $this->submit($keyId, $keySecret, $prompt, $durationSeconds, $aspectRatio, $model);
        $meta = [
            'provider' => self::ENGINE,
            'engine' => self::ENGINE,
            'model' => $model['model'],
            'request_id' => $submission['request_id'],
            'status_url' => $submission['status_url'],
            'duration' => $durationSeconds,
            'spoken_line' => $spokenLine,
            'prompt_hash' => hash('sha256', $prompt),
        ];

        if ($this->onSubmitted !== null) {
            ($this->onSubmitted)($meta);
        }

        return $this->finish($submission['status_url'], $submission['request_id'], $keyId, $keySecret, $meta, $onProgress);
    }

    /**
     * Continue an already submitted Higgsfield request. Does not create a new render.
     *
     * @param  array<string, mixed>  $meta
     */
    public function resume(string $statusUrl, string $requestId, array $meta, ?callable $onProgress = null): VideoRenderResult
    {
        [$keyId, $keySecret] = $this->credentials();

        return $this->finish($statusUrl, $requestId, $keyId, $keySecret, [
            ...$meta,
            'provider' => self::ENGINE,
            'engine' => self::ENGINE,
            'request_id' => $requestId,
            'status_url' => $statusUrl,
        ], $onProgress);
    }

    /**
     * @param  array<string, mixed>  $meta
     */
    private function finish(string $statusUrl, string $requestId, string $keyId, string $keySecret, array $meta, ?callable $onProgress): VideoRenderResult
    {
        $videoUrl = $this->pollUntilDone($statusUrl, $requestId, $keyId, $keySecret, $onProgress);
        $onProgress && $onProgress(94, 'Downloading finished video');

        return new VideoRenderResult(
            assetPath: $this->downloadAndStore($videoUrl),
            meta: $meta,
        );
    }

    /**
     * @return array{0: string, 1: string}
     */
    private function credentials(): array
    {
        $keyId = config('creative.higgsfield_seedance.key_id');
        $keySecret = config('creative.higgsfield_seedance.key_secret');

        if (blank($keyId) || blank($keySecret)) {
            throw new ProviderNotConfiguredException(
                'HF_API_KEY_ID and HF_API_KEY_SECRET are not set — cannot render video with Higgsfield.'
            );
        }

        return [(string) $keyId, (string) $keySecret];
    }

    private function durationSeconds(int $duration): int
    {
        if ($duration < 4 || $duration > 30) {
            throw new InvalidArgumentException("Higgsfield accepts durations from 4 to 30 seconds; {$duration}s is outside that range.");
        }

        return $duration;
    }

    /**
     * @param  array{hook?: string, problem?: string, product?: string, benefits?: string, cta?: string, spoken_line?: string}  $script
     */
    private function fittedSpokenLine(array $script, int $duration): string
    {
        $spokenLine = $this->normalizeSpokenText(trim((string) ($script['spoken_line'] ?? '')), spellAcronyms: false);
        if ($spokenLine !== '') {
            $fitted = VideoDialogue::limitToBudget($spokenLine, $duration, self::ENGINE);
            if ($fitted !== $spokenLine) {
                Log::channel('ai_video')->info('Higgsfield dialogue trimmed to the duration budget', [
                    'requested_duration' => $duration,
                    'original_words' => VideoDialogue::wordCount($spokenLine),
                    'budget' => VideoDialogue::wordBudget($duration, self::ENGINE),
                ]);
            }
            $spokenLine = $fitted;
        }

        if ($spokenLine === '') {
            $spokenLine = $this->normalizeSpokenText(collect(['hook', 'problem', 'product', 'benefits', 'cta'])
                ->map(fn (string $key): string => trim((string) ($script[$key] ?? '')))
                ->filter()
                ->implode(' '), spellAcronyms: false);
        }

        return $spokenLine;
    }

    /**
     * @param  list<array{scene?: string}>  $storyboard
     * @param  array<string, mixed>|null  $context
     */
    private function buildPrompt(Avatar $avatar, Voice $voice, array $storyboard, ?string $style, ?array $context, string $spokenLine, int $duration, string $ratio): string
    {
        $brief = is_array($context['brief'] ?? null) ? $context['brief'] : [];
        $handheld = $this->isHandheld($brief);

        $sceneLines = collect($storyboard)
            ->map(function (array $scene, int $index) use ($handheld): string {
                $text = (string) ($scene['scene'] ?? '');
                if (! $handheld) {
                    $text = $this->withoutHolding($text);
                }

                return sprintf('Scene %d: %s', $index + 1, $this->normalizeSpokenText($text, spellAcronyms: false));
            })
            ->implode("\n");

        $accent = $voice->accent ?: ($avatar->accent ?? 'neutral');
        $tone = $voice->tone ?? 'friendly';
        $speechEnd = max(1, $duration - 1);
        $stylePrompt = StyleCatalogue::videoPromptFor($style);
        $styleLine = $stylePrompt !== null
            ? "Use this only for set, color, and wardrobe. The camera plan below takes priority: {$stylePrompt}\n\n"
            : '';

        $cameraLine = $handheld
            ? 'Camera: slow dolly-in on the presenter with subtle handheld energy. Use 4 shots with smooth whip-pan and match-cut transitions timed to the dialogue: (1) medium close-up as the presenter smiles and tilts their head, (2) the presenter tosses the product toward camera and catches it, (3) macro push-in on the product detail described below as the presenter points to it, (4) silent final hero shot of the product slowly rotating on a clean gradient backdrop.'
            : 'Camera: slow dolly-in on the presenter with subtle handheld energy. Use 4 shots with smooth whip-pan and match-cut transitions timed to the dialogue: (1) medium close-up as the presenter smiles and tilts their head, (2) the presenter stands beside the product and gestures toward it with an open palm, (3) macro push-in on the product detail described below as the presenter points to it without lifting it, (4) silent final hero shot of the product staying in its fixed position, with a slow camera dolly or orbit. The product itself does not move or rotate.';
        $brand = trim((string) ($context['project_name'] ?? ''));
        $productName = trim((string) ($brief['product_name'] ?? ''));
        $appearance = trim((string) ($brief['visual_description'] ?? ''));
        $productLine = trim(implode(' ', array_filter([
            $brand !== '' ? "Brand: {$brand}." : null,
            $productName !== '' ? $productName.'.' : null,
            $appearance !== '' ? $appearance : null,
        ])));
        if ($productLine === '') {
            $productLine = 'Feature only the product named in the dialogue. Do not invent logos, printed words, pockets, or stripes.';
        }

        return trim(sprintf(
            "A scroll-stopping, high-energy talking-presenter ad. Cinematic 4K, %s. Presenter: %s, %s accent, in a bright modern lifestyle studio with golden-hour window light, soft bokeh, and drifting dust particles. The presenter is an original fictional character and does not represent or imitate any real person.\n\n".
            '%s'.
            "%s\n\n".
            "Product: %s\n\n".
            "%s\n\n".
            "Performance: warm, animated %s delivery with natural micro-expressions, expressive hand gestures, and precise lip sync.\n\n".
            "Dialogue (speak this exact line verbatim, and nothing else):\n- Presenter: \"%s\"\n\n".
            "Timing: This is a %d-second video. The last word lands at about %d seconds. Then only the silent product shot, with the mouth closed. Soft upbeat background music under the voice. Do not speak, whisper, or add any words. No captions or added on-screen text.\n\n".
            "Storyboard:\n%s\n\n".
            'Anatomically precise, authentic natural skin texture, accurate hands, and consistent lighting. Depict only verified product features stated above.'.
            ($handheld ? '' : ' The product keeps its exact real-world size and stays realistically heavy and stationary.'),
            $this->frameLabel($ratio),
            $avatar->visualDescription(),
            $accent,
            $styleLine,
            $cameraLine,
            $productLine,
            $this->handlingRule($handheld),
            $tone,
            $spokenLine,
            $duration,
            $speechEnd,
            $sceneLines !== '' ? $sceneLines : 'Scene 1: The presenter addresses the camera, then the final second shows only the product in silence.',
        ));
    }

    /**
     * Wan speaks only the quoted line, then stays silent. Seedance keeps buildPrompt().
     *
     * @param  list<array{scene?: string}>  $storyboard
     * @param  array<string, mixed>|null  $context
     */
    private function buildPromptForWan(Avatar $avatar, Voice $voice, array $storyboard, ?string $style, ?array $context, string $spokenLine, int $duration, string $ratio): string
    {
        $brief = is_array($context['brief'] ?? null) ? $context['brief'] : [];
        $handheld = $this->isHandheld($brief);

        // Storyboard: visual notes only. Strip quoted text and greeting verbs.
        // For heavy products, also rewrite any lift/hold/toss actions.
        $sceneLines = collect($storyboard)
            ->map(function (array $scene, int $index) use ($handheld): string {
                $text = (string) ($scene['scene'] ?? '');
                $text = preg_replace('/["“”].*?["“”]/u', '', $text) ?? $text;
                $text = preg_replace('/\b(greets?|greeting|introduces?|introducing|says?|saying|welcomes?|welcoming|announces?|announcing)\b/iu', 'faces the camera', $text) ?? $text;

                if (! $handheld) {
                    $text = $this->withoutHolding($text);
                }

                return sprintf(
                    'Shot %d: %s',
                    $index + 1,
                    $this->normalizeSpokenText(trim($text), spellAcronyms: false)
                );
            })
            ->filter(fn (string $line): bool => ! preg_match('/^Shot \d+:\s*$/', $line))
            ->implode("\n");

        $accent = $voice->accent ?: ($avatar->accent ?? 'neutral');
        $tone = $voice->tone ?? 'friendly';
        $stylePrompt = StyleCatalogue::videoPromptFor($style);
        $styleLine = $stylePrompt !== null
            ? "Use this only for set, color, and wardrobe. The camera plan below takes priority: {$stylePrompt}"
            : null;

        // Speech window sized to the line.
        $words = preg_split('/\s+/u', trim($spokenLine), -1, PREG_SPLIT_NO_EMPTY) ?: [];
        $wordCount = count($words);
        $firstWord = $words[0] ?? '';
        $lastWord = $words[$wordCount - 1] ?? '';
        $estimatedSpeech = (int) ceil($wordCount / 2.3) + 1;
        $speechEnd = max(2, min($duration - 1, $estimatedSpeech));
        $silentFrom = $speechEnd;

        // Product block
        $brand = trim((string) ($context['project_name'] ?? ''));
        $productName = trim((string) ($brief['product_name'] ?? ''));
        $appearance = trim((string) ($brief['visual_description'] ?? ''));
        $productLine = trim(implode(' ', array_filter([
            $brand !== '' ? "Brand: {$brand}." : null,
            $productName !== '' ? $productName.'.' : null,
            $appearance !== '' ? $appearance : null,
        ])));
        if ($productLine === '') {
            $productLine = 'Feature only the product named in the dialogue. Do not invent logos, printed words, pockets, or stripes.';
        }

        $handlingRule = $this->handlingRule($handheld);
        if ($handheld) {
            $middleShots = 'medium close-up, then the presenter holding up the product, then a macro push-in on the product detail as the presenter points to it';
            $finalShot = 'Silent hero shot of only the product slowly rotating on a clean gradient backdrop.';
        } else {
            $middleShots = 'medium shot with the presenter beside the product, then a slow camera push-in and pan across the product as she gestures toward it with an open palm, then a macro push-in on the product detail as she points to it without lifting it';
            $finalShot = 'Silent hero shot of only the product, staying in its fixed position, with a slow camera dolly or orbit around it on a clean, softly lit backdrop. The product itself does not move or rotate.';
        }

        $sections = [
            sprintf(
                'A scroll-stopping, high-energy talking-presenter ad. Cinematic 4K, %s. Presenter: %s, %s accent, in a bright modern lifestyle studio with golden-hour window light, soft bokeh, and drifting dust particles. The presenter is an original fictional character and does not represent or imitate any real person.',
                $this->frameLabel($ratio),
                $avatar->visualDescription(),
                $accent
            ),

            $styleLine,

            "Product: {$productLine}",

            $handlingRule,

            sprintf(
                'Performance: warm, animated %s delivery with natural micro-expressions, expressive hand gestures, and precise lip sync.',
                $tone
            ),

            sprintf(
                "SPOKEN LINE (exactly %d words, first word \"%s\", last word \"%s\"):\n\"%s\"",
                $wordCount,
                $firstWord,
                $lastWord,
                $spokenLine
            ),

            implode("\n", [
                'Timeline:',
                sprintf(
                    '- 0.0s: the video opens already in progress. The presenter is looking at the camera with lips about to move, and the very first sound in the video is the word "%s". There is no greeting, no intro, no breath-in sound, and no lead-in words before it.',
                    $firstWord
                ),
                sprintf(
                    '- 0s to %ds: the presenter says the line once at a calm, natural pace, ending on the word "%s". Camera: slow dolly-in with subtle handheld energy. Shots change only between phrases, never before the first word: %s.',
                    $speechEnd,
                    $lastWord,
                    $middleShots
                ),
                sprintf(
                    '- %ds to %ds: the presenter has finished speaking, mouth closed. %s No voice at all, only faint room tone.',
                    $silentFrom,
                    $duration,
                    $finalShot
                ),
            ]),

            'Audio rules: the only words in the entire video are the spoken line. Never say hi, hey, hello, okay, so, or any greeting or filler before, during, or after it. Do not add, repeat, paraphrase, extend, or complete the line. No ad-libs, no brand slogans, no off-screen narrator or voiceover, no whispering, no singing, no humming, no background music. No captions or on-screen text.',

            $sceneLines !== ''
                ? "Visual direction only (never spoken aloud; these are camera and action notes, not dialogue):\n{$sceneLines}"
                : 'Visual direction only (never spoken aloud): the presenter addresses the camera, then the final seconds show only the product in silence.',

            'Anatomically precise, authentic natural skin texture, accurate hands, and consistent lighting. Depict only verified product features stated above.'.
                ($handheld ? '' : ' The product keeps its exact real-world size and stays realistically heavy and stationary.'),
        ];

        return trim(implode("\n\n", array_filter($sections, fn (?string $section): bool => $section !== null && $section !== '')));
    }

    private function handlingRule(bool $handheld): string
    {
        if ($handheld) {
            return 'Handling: the product is light and easy to hold, so the presenter may hold it up, present it to camera, and point to its details.';
        }

        return 'Handling: the product is heavy and large. It stays fixed in its natural position in the room (mounted on the wall, on an easel or stand, or resting on the floor or a surface, fully supported the whole time). The presenter NEVER picks it up, lifts, carries, holds it in her hands, tosses, hugs, leans her weight on it, or moves it. The presenter stands or sits beside it and shows it only with an open-palm gesture, a pointing finger, or at most a light fingertip touch, and looks between the product and the camera. All movement comes from the camera, not from the product.';
    }

    private function withoutHolding(string $text): string
    {
        return preg_replace(
            '/\b(holds?|holding|picks?|picking|lifts?|lifting|carr(?:y|ies|ying)|tosses?|tossing|throws?|throwing|catch(?:es)?|catching|hugs?|hugging|hands?|shows? off)\b(\s+(?:it|up|the|this|her|his|their|a|an))*/iu',
            'gestures toward the',
            $text
        ) ?? $text;
    }

    /**
     * Decide whether the presenter can physically hold the product.
     * An explicit flag on the brief wins; keywords are only a fallback.
     *
     * @param  array<string, mixed>  $brief
     */
    private function isHandheld(array $brief): bool
    {
        // Best source: set this when the brief is generated/edited.
        // Accepts: 'handheld' | 'display', or a boolean is_heavy flag.
        $handling = strtolower(trim((string) ($brief['handling'] ?? '')));
        if ($handling === 'display') {
            return false;
        }
        if ($handling === 'handheld') {
            return true;
        }
        if (! empty($brief['is_heavy']) || ! empty($brief['is_large'])) {
            return false;
        }

        $haystack = strtolower(trim(
            ($brief['product_name'] ?? '').' '.($brief['visual_description'] ?? '')
        ));

        // \bprint\b matches "canvas print" but not "printed t-shirt".
        $heavy = '/\b(printer|canvas|framed|frame|wall art|painting|artwork|sculpture|statue|mirror|'.
            'furniture|sofa|couch|chair|table|desk|cabinet|shelf|bookcase|bed|mattress|rug|'.
            'appliance|machine|refrigerator|fridge|oven|washer|treadmill|exercise bike|'.
            'tv|television|monitor|speaker system|bike|scooter|luggage|suitcase)\b/u';

        return ! preg_match($heavy, $haystack);
    }

    private function frameLabel(string $ratio): string
    {
        return match ($ratio) {
            '9:16', '3:4' => "vertical {$ratio}",
            '16:9', '21:9', '4:3' => "widescreen {$ratio}",
            '1:1' => 'square 1:1',
            default => $ratio,
        };
    }

    /**
     * @return array{label: string, endpoint: string, model: string, resolution: string}
     */
    private function selectedModel(): array
    {
        $models = config('creative.higgsfield_models', []);
        $key = (string) config('creative.higgsfield_model', 'wan-3.0');
        $selected = $models[$key] ?? $models['wan-3.0'] ?? null;

        if (! is_array($selected) || blank($selected['endpoint'] ?? null) || blank($selected['model'] ?? null)) {
            throw new ProviderNotConfiguredException('Higgsfield video model is not configured.');
        }

        $resolution = (string) config('creative.higgsfield_resolution');
        if (! in_array($resolution, ['480p', '720p', '1080p'], true)) {
            $resolution = (string) ($selected['resolution'] ?? '480p');
        }

        return [
            'label' => (string) ($selected['label'] ?? 'Wan'),
            'endpoint' => trim((string) $selected['endpoint'], '/'),
            'model' => (string) $selected['model'],
            'resolution' => $resolution,
        ];
    }

    /**
     * @param  array{label: string, endpoint: string, model: string, resolution: string}  $model
     * @return array{request_id: string, status_url: string}
     */
    private function submit(string $keyId, string $keySecret, string $prompt, int $durationSeconds, string $aspectRatio, array $model): array
    {
        $baseUrl = rtrim((string) config('creative.higgsfield_seedance.base_url'), '/');

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

        $response = Http::withHeaders($this->authHeaders($keyId, $keySecret))
            ->timeout(30)
            ->acceptJson()
            ->post("{$baseUrl}/{$model['endpoint']}", [
                'prompt' => $prompt,
                'duration' => $durationSeconds,
                'resolution' => $model['resolution'],
                'aspect_ratio' => $aspectRatio,
                'generate_audio' => true,
            ]);

        $this->logResponse('info', 'Higgsfield generation response', $response);

        if ($response->failed()) {
            $this->logResponse('error', 'Higgsfield generation request failed', $response);

            $this->fail('Higgsfield generation request failed. See logs for the vendor response.', $response);
        }

        $requestId = $response->json('request_id');
        $statusUrl = $response->json('status_url');

        if (blank($requestId) || blank($statusUrl)) {
            $this->logResponse('error', 'Higgsfield generation response was missing a request id or status URL', $response);

            $this->fail('Higgsfield generation did not return a request id and status URL.', $response);
        }

        return [
            'request_id' => (string) $requestId,
            'status_url' => (string) $statusUrl,
        ];
    }

    private function pollUntilDone(string $statusUrl, string $requestId, string $keyId, string $keySecret, ?callable $onProgress): string
    {
        $interval = (int) config('creative.higgsfield_seedance.poll_interval_seconds', 10);
        $maxAttempts = (int) config('creative.higgsfield_seedance.max_poll_attempts', 90);

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

            $response = Http::withHeaders($this->authHeaders($keyId, $keySecret))
                ->timeout(30)
                ->acceptJson()
                ->get($statusUrl);

            $this->logResponse('info', 'Higgsfield poll response', $response, [
                'attempt' => $attempt + 1,
                'request_status' => $response->json('status'),
            ]);

            if ($response->serverError()) {
                if ($attempt < $maxAttempts - 1) {
                    $this->pause($interval);

                    continue;
                }

                $this->logResponse('warning', 'Higgsfield status endpoint stayed unavailable; polling will continue', $response);

                throw new HiggsfieldRenderPendingException($requestId, $statusUrl);
            }

            if ($response->status() === 401 || $response->status() === 404) {
                $this->logResponse('error', 'Higgsfield status request was rejected', $response);

                $this->fail('Higgsfield status request was rejected. See logs for the vendor response.', $response, $requestId, $statusUrl);
            }

            if ($response->failed()) {
                $this->logResponse('error', 'Higgsfield polling failed', $response);

                $this->fail('Higgsfield polling failed. See logs for the vendor response.', $response, $requestId, $statusUrl);
            }

            $status = (string) $response->json('status');

            if (! in_array($status, self::TERMINAL_STATUSES, true)) {
                if ($attempt < $maxAttempts - 1) {
                    $this->pause($interval);
                }

                continue;
            }

            return $this->videoUrlFromTerminalResponse($response, $status);
        }

        Log::channel('ai_video')->info('Higgsfield request is still pending after the poll window', [
            'request_id' => $requestId,
            'status_url' => $statusUrl,
            'attempts' => $maxAttempts,
        ]);

        throw new HiggsfieldRenderPendingException($requestId, $statusUrl);
    }

    private function videoUrlFromTerminalResponse(Response $response, string $status): string
    {
        if ($status === 'nsfw') {
            $this->logResponse('warning', 'Higgsfield content moderation rejected the video', $response);

            throw new VideoPromptFilteredException('Higgsfield content moderation rejected the video.');
        }

        if ($status !== 'completed') {
            $this->logResponse('error', 'Higgsfield generation ended without a video', $response, [
                'request_status' => $status,
            ]);

            $this->fail('Higgsfield generation ended without a video. See logs for the vendor response.', $response);
        }

        $videoUrl = $response->json('video.url');

        if (blank($videoUrl)) {
            $this->logResponse('error', 'Higgsfield generation completed without a video URL', $response);

            $this->fail('Higgsfield generation completed without a video URL.', $response);
        }

        return (string) $videoUrl;
    }

    /**
     * @return array{Authorization: string, Content-Type: string}
     */
    private function authHeaders(string $keyId, string $keySecret): array
    {
        return [
            'Authorization' => "Key {$keyId}:{$keySecret}",
            'Content-Type' => 'application/json',
        ];
    }

    /**
     * @param  'info'|'warning'|'error'  $level
     * @param  array<string, mixed>  $extra
     */
    private function logResponse(string $level, string $message, Response $response, array $extra = []): void
    {
        Log::channel('ai_video')->{$level}($message, [
            ...$extra,
            'status' => $response->status(),
            'correlation_id' => $response->header('X-Correlation-ID') ?: null,
            'body' => $response->json() ?? $response->body(),
        ]);
    }

    private function fail(string $message, Response $response, ?string $requestId = null, ?string $statusUrl = null): never
    {
        $body = $response->json() ?? $response->body();
        $requestId ??= is_array($body) ? (isset($body['request_id']) ? (string) $body['request_id'] : null) : null;

        throw new HiggsfieldRenderFailedException(
            $message,
            requestId: $requestId,
            statusUrl: $statusUrl,
            httpStatus: $response->status(),
            body: $body,
            reason: $this->failureReason($response->status(), $body),
        );
    }

    private function failureReason(int $status, mixed $body): string
    {
        $text = is_array($body) ? (string) json_encode($body) : (string) $body;

        if ($status === 403 || preg_match('/credit/i', $text) === 1) {
            return 'credits';
        }

        return 'failed';
    }

    private function pause(int $interval): void
    {
        if ($interval > 0) {
            sleep($interval);
        }
    }

    private function downloadAndStore(string $videoUrl): string
    {
        $tmpPath = tempnam(sys_get_temp_dir(), 'seedance-');
        $response = Http::timeout(120)->sink($tmpPath)->get($videoUrl);

        if ($response->failed()) {
            @unlink($tmpPath);

            throw new RuntimeException('Failed to download the generated video from Higgsfield.');
        }

        $filename = 'creatives/videos/'.Str::ulid().'.mp4';
        $stream = fopen($tmpPath, 'r');
        $stored = Storage::disk(config('creative.disk', 'public'))->put($filename, $stream);
        fclose($stream);
        @unlink($tmpPath);

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

        return $filename;
    }
}
