<?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\VideoDialogue;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use RuntimeException;
use Throwable;

/**
 * Renders video creatives with OpenAI's Sora model via the raw Videos API
 * (laravel/ai has no Sora/video-generation support to wrap, same situation
 * as Veo — so this calls api.openai.com directly).
 *
 * Retained only for historical tests. Production routing rejects Sora durations
 * since the Videos API shut down. Sora's base clip could go up to 12s and be stretched with a single
 * /videos/extensions call — see clampDurationPlan(). Audio decoding, previously
 * unreliable, is now confirmed working and reachable from the wizard's duration presets.
 *
 * Deliberately uses the smallest extension segment that reaches the target
 * duration, to keep render time down; the model itself (config('creative.openai_sora.model'))
 * defaults to "sora-2-pro" for higher-quality output.
 *
 * Caveat: like Veo, Sora has no concept of a persistent "avatar identity" or
 * of speaking in one of our own synthesized `Voice` catalogue entries.
 * `$avatar`/`$voice` are folded into the prompt as descriptive hints only.
 */
class OpenAiSoraVideoAvatarProvider implements VideoAvatarProvider
{
    use BuildsProductionSafePrompts;

    /** Per OpenAI's Videos API reference: POST /videos only accepts these base clip lengths. */
    private const SUPPORTED_BASE_SECONDS = [4, 8, 12];

    /** Per OpenAI's Videos API reference: POST /videos/extensions accepts these segment lengths. */
    private const SUPPORTED_EXTENSION_SECONDS = [4, 8, 12, 16, 20];

    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.openai.key');

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

        $baseUrl = rtrim(config('ai.providers.openai.url'), '/');
        $model = config('creative.openai_sora.model');
        // Sora's Videos API only supports landscape/portrait sizes, not square/4:5 — the wizard
        // only offers 9:16/16:9 for video today, but this is a public contract and ratio is
        // client-controlled, so anything not explicitly 16:9 renders portrait rather than
        // silently defaulting to landscape.
        $size = $ratio === '16:9' ? '1280x720' : '720x1280';
        [$baseSeconds, $extensionSeconds] = $this->clampDurationPlan($duration);
        VideoDialogue::assertFits($this->buildSpokenLine($script, $duration), $duration);
        $prompt = $this->buildPrompt($avatar, $voice, $script, $storyboard, $baseSeconds + ($extensionSeconds ?? 0), $style, $context);

        $onProgress && $onProgress(10, 'Submitting opening scene');
        $videoId = $this->startGeneration($baseUrl, $apiKey, $model, $prompt, $size, $baseSeconds);
        $baseEnd = $extensionSeconds === null ? 90 : 58;
        $this->pollUntilDone($baseUrl, $apiKey, $videoId, $onProgress, 12, $baseEnd, 'Creating the opening scene');

        // The base clip is already generated and billed at this point. If the extension step
        // below fails, fall back to storing this base clip rather than throwing it away —
        // it's already been paid for.
        $baseVideoId = $videoId;
        $totalSeconds = $baseSeconds;

        if ($extensionSeconds !== null) {
            try {
                $videoId = $this->requestExtension($baseUrl, $apiKey, $videoId, $prompt, $extensionSeconds);
                $this->pollUntilDone($baseUrl, $apiKey, $videoId, $onProgress, 60, 92, 'Extending your video');
                $totalSeconds += $extensionSeconds;
            } catch (Throwable $e) {
                logger()->error('Sora extension failed after the base clip was already billed; falling back to the base clip.', [
                    'base_video_id' => $baseVideoId,
                    'exception_type' => $e::class,
                ]);

                $videoId = $baseVideoId;
                $totalSeconds = $baseSeconds;
            }
        }

        $onProgress && $onProgress(95, 'Downloading finished video');
        $path = $this->downloadAndStore($baseUrl, $apiKey, $videoId);

        return new VideoRenderResult(
            assetPath: $path,
            meta: ['provider' => 'openai-sora', 'model' => $model, 'video_id' => $videoId, 'duration' => $totalSeconds, 'prompt_hash' => hash('sha256', $prompt)],
        );
    }

    /**
     * Sora's create endpoint only accepts 4/8/12s base clips. Pick the largest
     * supported base that doesn't exceed the request (minimizes whether/how much
     * extension is needed), then — if there's a remainder — a single extension
     * covering at least that remainder (never less, so the delivered clip is never
     * shorter than requested), preferring the smallest such extension to keep
     * cost/time down. Returns [baseSeconds, extensionSeconds|null]; the caller's
     * baseSeconds + extensionSeconds is the *actual* delivered duration and may
     * be longer than the request — store that, not the request, as the creative's duration.
     *
     * @return array{0: int, 1: int|null}
     */
    private function clampDurationPlan(int $duration): array
    {
        $baseSeconds = collect(self::SUPPORTED_BASE_SECONDS)
            ->filter(fn (int $s) => $s <= $duration)
            ->last() ?? self::SUPPORTED_BASE_SECONDS[0];

        $remainder = $duration - $baseSeconds;

        if ($remainder <= 0) {
            return [$baseSeconds, null];
        }

        $extensionSeconds = collect(self::SUPPORTED_EXTENSION_SECONDS)
            ->filter(fn (int $s) => $s >= $remainder)
            ->first() ?? collect(self::SUPPORTED_EXTENSION_SECONDS)->last();

        return [$baseSeconds, $extensionSeconds];
    }

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

        // 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);

        // 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 = method_exists($avatar, 'visualDescription')
            ? $avatar->visualDescription()
            : ($avatar->gender ?? 'presenter');

        return trim(sprintf(
            "%s Presenter: %s (%s), %s accent, speaking in a %s tone, addressing the camera directly with steady eye contact and natural micro-expressions.%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- %s: \"%s\"\n\n".
            "Storyboard:\n%s\n\n%s\n\n%s",
            $sceneDescription,
            $avatar->name,
            $presenterDesc,
            $avatar->accent ?? 'neutral',
            $voice->tone ?? 'friendly',
            $contextBlock,
            $avatar->name,
            $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 - 1);
        $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, string $size, int $baseSeconds): string
    {
        $payload = [
            'model' => $model,
            'prompt' => $prompt,
            'size' => $size,
            'seconds' => (string) $baseSeconds,
        ];

        Log::channel('ai_video')->info('Sora generation request', ['model' => $model, 'seconds' => $baseSeconds, 'prompt_hash' => hash('sha256', $prompt)]);

        $response = Http::withToken($apiKey)
            ->timeout(30)
            ->post("{$baseUrl}/videos", $payload);

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

        if ($response->failed()) {
            Log::channel('ai_video')->error('Sora generation request failed', ['status' => $response->status()]);

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

        $videoId = $response->json('id');

        if (blank($videoId)) {
            throw new RuntimeException('Sora generation did not return a video id.');
        }

        return $videoId;
    }

    private function requestExtension(string $baseUrl, string $apiKey, string $videoId, string $prompt, int $extensionSeconds): string
    {
        $payload = [
            'video' => ['id' => $videoId],
            'prompt' => $prompt,
            'seconds' => (string) $extensionSeconds,
        ];

        Log::channel('ai_video')->info('Sora extension request', ['video_id' => $videoId, 'seconds' => $extensionSeconds, 'prompt_hash' => hash('sha256', $prompt)]);

        $response = Http::withToken($apiKey)
            ->timeout(30)
            ->post("{$baseUrl}/videos/extensions", $payload);

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

        if ($response->failed()) {
            Log::channel('ai_video')->error('Sora extension request failed', ['status' => $response->status()]);

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

        $newVideoId = $response->json('id');

        if (blank($newVideoId)) {
            throw new RuntimeException('Sora extension did not return a video id.');
        }

        return $newVideoId;
    }

    private function pollUntilDone(string $baseUrl, string $apiKey, string $videoId, ?callable $onProgress = null, int $progressStart = 10, int $progressEnd = 90, string $stageLabel = 'Generating video'): void
    {
        $interval = (int) config('creative.openai_sora.poll_interval_seconds', 10);
        $maxAttempts = (int) config('creative.openai_sora.max_poll_attempts', 30);

        for ($attempt = 0; $attempt < $maxAttempts; $attempt++) {
            $estimated = $progressStart + (int) floor(($progressEnd - $progressStart) * min(0.9, ($attempt + 1) / $maxAttempts));
            $onProgress && $onProgress($estimated, $stageLabel);

            try {
                $response = Http::withToken($apiKey)
                    ->timeout(30)
                    ->get("{$baseUrl}/videos/{$videoId}");
            } catch (ConnectionException $e) {
                // A single flaky status-check request (e.g. cURL timeout) doesn't mean the render
                // failed — retry rather than aborting the whole (already-billed) generation over it.
                logger()->warning('Sora status poll request failed transiently, retrying', ['video_id' => $videoId, 'attempt' => $attempt, 'exception_type' => $e::class]);

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

                    continue;
                }

                throw new RuntimeException("Sora video {$videoId} polling kept failing to connect after {$maxAttempts} attempts.", previous: $e);
            }

            Log::channel('ai_video')->info('Sora poll response', [
                'attempt' => $attempt + 1,
                'video_id' => $videoId,
                'status' => $response->status(),
                'generation_status' => $response->json('status'),
            ]);

            // Newly created/extended jobs can briefly 404 before becoming queryable (propagation lag) — treat that as "not ready yet" and retry, not a hard failure.
            if ($response->status() === 404) {
                if ($attempt < $maxAttempts - 1) {
                    sleep($interval);

                    continue;
                }

                throw new RuntimeException("Sora video {$videoId} was never found after {$maxAttempts} polling attempts.");
            }

            if ($response->failed()) {
                Log::channel('ai_video')->error('Sora video polling failed', ['status' => $response->status()]);

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

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

            if ($status === 'completed') {
                $onProgress && $onProgress($progressEnd, $stageLabel);

                return;
            }

            if ($status === 'failed') {
                logger()->error('Sora video generation failed', ['video_id' => $videoId, 'error_code' => $response->json('error.code')]);

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

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

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

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

        try {
            $response = Http::withToken($apiKey)->timeout(60)->sink($tmpPath)->get("{$baseUrl}/videos/{$videoId}/content");

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

            $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);
            }
        }
    }
}
