<?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\Google\GoogleServiceAccountTokenProvider;
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 a 15s talking-avatar video via Vertex AI: an 8s Veo base clip,
 * followed by one native Vertex "extend video" call that adds a further,
 * fixed 7 seconds (Google performs the continuation server-side — no ffmpeg
 * or local frame extraction is involved, unlike a naive multi-clip-stitch
 * approach). The base clip's bytes are round-tripped inline as base64
 * (Vertex's `video.bytesBase64Encoded` field), so no Cloud Storage bucket
 * is required either.
 *
 * Requires Vertex AI (aiplatform.googleapis.com), which — unlike the plain
 * Gemini Developer API used by GeminiVeoVideoAvatarProvider — only accepts
 * OAuth2 service-account credentials, never a bare API key. See
 * config('services.vertex_ai') and GoogleServiceAccountTokenProvider.
 */
class GeminiVeoVertexVideoProvider implements VideoAvatarProvider
{
    /** Reuses the exact same prompt-building logic as the Developer-API Veo provider. */
    private GeminiVeoVideoAvatarProvider $promptBuilder;

    public function __construct()
    {
        $this->promptBuilder = new GeminiVeoVideoAvatarProvider;
    }

    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
    {
        $projectId = config('services.vertex_ai.project_id');
        $credentialsPath = config('services.vertex_ai.credentials_path');

        if (blank($projectId) || ! is_file($credentialsPath)) {
            throw new ProviderNotConfiguredException(
                'Vertex AI is not configured — VERTEX_APP_ID and a valid service account key are required.'
            );
        }

        $extendCount = (int) (config('creative.vertex_extend_durations')[$duration] ?? 1);
        $model = config('creative.gemini_veo_vertex.model');
        $location = config('services.vertex_ai.location', 'us-central1');
        $baseUrl = "https://{$location}-aiplatform.googleapis.com/v1/projects/{$projectId}/locations/{$location}/publishers/google/models/{$model}";
        $accessToken = (new GoogleServiceAccountTokenProvider($credentialsPath))->getAccessToken();

        $aspectRatio = $ratio === '9:16' ? '9:16' : '16:9';
        $totalSeconds = 8 + ($extendCount * 7);
        $stageDurations = [8, ...array_fill(0, $extendCount, 7)];
        $spokenLine = $this->promptBuilder->buildSpokenLinePublic($script, $totalSeconds);
        VideoDialogue::assertFits($spokenLine, $duration);
        $dialogueSegments = $this->partitionWords($spokenLine, $stageDurations);
        $storyboardSegments = $this->partitionItems($storyboard, $stageDurations);
        $prompts = [];
        $fallbackPrompts = [];
        $stageCount = count($stageDurations);

        foreach ($stageDurations as $stage => $stageDuration) {
            $instruction = $stage === 0
                ? "Create only the opening {$stageDuration} seconds. Speak only the dialogue below and end this segment naturally without jumping ahead."
                : "Continue seamlessly from the supplied video for the next {$stageDuration} seconds. Do not repeat earlier dialogue or restart the scene. Speak only the new dialogue below while preserving the presenter's identity, voice, pace, and lip-sync.";

            $prompts[] = $this->promptBuilder->buildPromptPublic(
                $avatar,
                $voice,
                $script,
                $storyboardSegments[$stage],
                $stageDuration,
                $style,
                $dialogueSegments[$stage],
                $instruction,
                $context,
            );
            $safeContext = $context;
            if (is_array($safeContext) && isset($safeContext['brief'])) {
                // When safety-filtering triggers, remove sensitive health/bodily claim text and sanitize sensitive product names
                $rawName = $context['brief']['product_name'] ?? null;
                $safeContext['brief'] = array_filter([
                    'product_name' => $this->sanitizeForSafety($rawName),
                ]);
            }

            $fallbackPrompts[] = $this->promptBuilder->buildPromptPublic(
                $avatar,
                $voice,
                $script,
                $storyboardSegments[$stage],
                $stageDuration,
                null,
                $dialogueSegments[$stage],
                $instruction.' Use a conservative neutral studio setting, stable eye-level camera, gentle lighting, and calm presenter movement. Show no sports action, dramatic motion, risky behavior, weapons, conflict, or hazardous activity.',
                $safeContext,
            );
        }

        // Stage 1: generate the 8s base clip.
        [$stageStart, $stageEnd] = $this->progressBounds(0, $stageCount);
        $onProgress && $onProgress($stageStart, 'Creating the opening scene');
        $video = $this->baseWithRetry($baseUrl, $accessToken, $prompts[0], $fallbackPrompts[0], $aspectRatio, $onProgress, $stageStart, $stageEnd, $avatar);

        // Stage 2: extend it N times; each call adds a fixed 7s and returns a *new*, longer
        // video payload (not a separate clip), so there's nothing to stitch locally.
        for ($i = 0; $i < $extendCount; $i++) {
            [$stageStart, $stageEnd] = $this->progressBounds($i + 1, $stageCount);
            $stageLabel = 'Extending scene '.($i + 1).' of '.$extendCount;
            $onProgress && $onProgress($stageStart, $stageLabel);
            $video = $this->extendWithRetry($baseUrl, $accessToken, $prompts[$i + 1], $fallbackPrompts[$i + 1], $video, $aspectRatio, $onProgress, $stageStart, $stageEnd, $stageLabel);
        }

        $onProgress && $onProgress(96, 'Assembling final video');
        $path = $this->storeFinalAsset($video);

        return new VideoRenderResult(
            assetPath: $path,
            meta: [
                'provider' => 'gemini-veo-vertex',
                'model' => $model,
                'duration' => $totalSeconds,
                'extend_calls' => $extendCount,
                'prompt_hash' => hash('sha256', $prompts[0]),
                'extend_prompt_hashes' => array_map(static fn (string $prompt): string => hash('sha256', $prompt), array_slice($prompts, 1)),
            ],
        );
    }

    /** @return array{0: int, 1: int} */
    private function progressBounds(int $stage, int $stageCount): array
    {
        return [
            8 + (int) floor(82 * $stage / $stageCount),
            8 + (int) floor(82 * ($stage + 1) / $stageCount),
        ];
    }

    /** @return list<string> */
    private function partitionWords(string $text, array $stageDurations): array
    {
        $words = preg_split('/\s+/', trim($text), flags: PREG_SPLIT_NO_EMPTY) ?: [];
        $segments = [];
        $offset = 0;
        $remainingDuration = array_sum($stageDurations);

        foreach ($stageDurations as $index => $duration) {
            if ($index === array_key_last($stageDurations)) {
                $segments[] = implode(' ', array_slice($words, $offset));
                break;
            }

            $remainingWords = count($words) - $offset;
            $futureCapacity = array_sum(array_map(
                static fn (int $stageDuration): int => VideoDialogue::wordBudget($stageDuration),
                array_slice($stageDurations, $index + 1),
            ));
            $minimumLength = max(0, $remainingWords - $futureCapacity);
            $maximumLength = min($remainingWords, VideoDialogue::wordBudget($duration));
            $targetLength = (int) round($remainingWords * $duration / $remainingDuration);
            $targetLength = max($minimumLength, min($maximumLength, $targetLength));
            $targetEnd = $offset + $targetLength;
            $sentenceEnds = collect(range($offset + $minimumLength, $offset + $maximumLength))
                ->filter(fn (int $end) => $end > $offset && preg_match('/[.!?]["\']?$/', $words[$end - 1]) === 1);
            $end = $sentenceEnds->sortBy(fn (int $candidate) => abs($candidate - $targetEnd))->first() ?? $targetEnd;

            $segments[] = implode(' ', array_slice($words, $offset, $end - $offset));
            $remainingDuration -= $duration;
            $offset = $end;
        }

        return $segments;
    }

    /** @return list<array> */
    private function partitionItems(array $items, array $stageDurations): array
    {
        $segments = [];
        $offset = 0;
        $elapsed = 0;
        $totalDuration = array_sum($stageDurations);
        $itemCount = count($items);

        foreach ($stageDurations as $index => $duration) {
            $elapsed += $duration;
            $end = $index === array_key_last($stageDurations)
                ? $itemCount
                : (int) round($itemCount * $elapsed / $totalDuration);
            $segments[] = array_slice($items, $offset, $end - $offset);
            $offset = $end;
        }

        return $segments;
    }

    private function startBaseGeneration(string $baseUrl, string $accessToken, string $prompt, string $aspectRatio, Avatar $avatar): string
    {
        $instance = ['prompt' => $prompt];
        if ($image = VeoPresenterImage::fromAvatar($avatar)) {
            $instance['image'] = [
                'bytesBase64Encoded' => $image['data'],
                'mimeType' => $image['mimeType'],
            ];
        }

        $payload = [
            'instances' => [$instance],
            'parameters' => ['durationSeconds' => 8, 'aspectRatio' => $aspectRatio],
        ];

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

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

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

        return $this->extractOperationName($response, 'base generation');
    }

    private function baseWithRetry(string $baseUrl, string $accessToken, string $prompt, string $fallbackPrompt, string $aspectRatio, ?callable $onProgress, int $progressStart, int $progressEnd, Avatar $avatar): array
    {
        $maxAttempts = 2;
        $currentPrompt = $prompt;
        $usedSafetyFallback = false;

        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
            try {
                $operationName = $this->startBaseGeneration($baseUrl, $accessToken, $currentPrompt, $aspectRatio, $avatar);

                return $this->pollUntilDone($baseUrl, $accessToken, $operationName, $onProgress, $progressStart, $progressEnd, 'Creating the opening scene');
            } catch (VertexSafetyFilteredException $e) {
                if ($usedSafetyFallback || $attempt === $maxAttempts) {
                    throw $e;
                }

                $usedSafetyFallback = true;
                $currentPrompt = $fallbackPrompt;
                Log::channel('ai_video')->warning('Vertex Veo base render was safety-filtered, retrying with neutral visual direction', ['attempt' => $attempt]);
            } catch (TransientVertexRenderException $e) {
                if ($attempt === $maxAttempts) {
                    throw new RuntimeException('Vertex Veo generation completed without a usable video after retrying.', previous: $e);
                }

                Log::channel('ai_video')->warning('Vertex Veo base render returned no video, retrying', [
                    'attempt' => $attempt,
                    'error_type' => $e::class,
                ]);
            }
        }

        throw new RuntimeException('Vertex Veo base generation failed after retries.');
    }

    /**
     * Passing the previous clip back as `video` (with no `mask`) puts Vertex in
     * duration-extend mode per the "extend videos" API docs — it does not need
     * (and rejects) the `durationSeconds` parameter used for fresh generation.
     */
    private function startExtend(string $baseUrl, string $accessToken, string $prompt, array $video, string $aspectRatio): string
    {
        $payload = [
            'instances' => [[
                'prompt' => $prompt,
                'video' => [
                    'bytesBase64Encoded' => $video['bytesBase64Encoded'],
                    'mimeType' => $video['mimeType'] ?? 'video/mp4',
                ],
            ]],
            'parameters' => ['aspectRatio' => $aspectRatio],
        ];

        // Skip the base64 video bytes in the logged payload — they're large and not useful for diagnosis.
        Log::channel('ai_video')->info('Vertex Veo extend request', [
            'prompt_hash' => hash('sha256', $prompt),
            'aspectRatio' => $aspectRatio,
            'video_bytes_length' => strlen($video['bytesBase64Encoded']),
        ]);

        // The base64 video body is large enough that Guzzle adds "Expect: 100-continue" by
        // default, which Google's frontend proxy answers with a 417 bot-block page instead of
        // a real API response — the `expect` request option (not just the header) disables it.
        $response = Http::withToken($accessToken)
            ->withOptions(['expect' => false])
            ->timeout(60)
            ->post("{$baseUrl}:predictLongRunning", $payload);

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

        return $this->extractOperationName($response, 'extend');
    }

    /**
     * Google's Vertex backend occasionally fails a finished render with a transient
     * infra error (e.g. "Input/output error; Failed to create temporary file") while
     * writing out the finished clip server-side — nothing to do with our request. Retry
     * the extend call itself a couple of times before giving up.
     */
    private function extendWithRetry(string $baseUrl, string $accessToken, string $prompt, string $fallbackPrompt, array $video, string $aspectRatio, ?callable $onProgress, int $progressStart, int $progressEnd, string $stageLabel): array
    {
        $maxAttempts = 3;
        $currentPrompt = $prompt;
        $usedSafetyFallback = false;

        for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
            try {
                $operationName = $this->startExtend($baseUrl, $accessToken, $currentPrompt, $video, $aspectRatio);

                return $this->pollUntilDone($baseUrl, $accessToken, $operationName, $onProgress, $progressStart, $progressEnd, $stageLabel);
            } catch (VertexSafetyFilteredException $e) {
                if ($usedSafetyFallback || $attempt === $maxAttempts) {
                    throw $e;
                }

                $usedSafetyFallback = true;
                $currentPrompt = $fallbackPrompt;
                Log::channel('ai_video')->warning('Vertex Veo extension was safety-filtered, retrying with neutral visual direction', [
                    'attempt' => $attempt,
                    'stage' => $stageLabel,
                ]);
            } catch (TransientVertexRenderException $e) {
                if ($attempt === $maxAttempts) {
                    throw new RuntimeException('Vertex Veo generation failed. See logs for the vendor error.', previous: $e);
                }

                Log::channel('ai_video')->warning('Vertex Veo extend hit a transient vendor error, retrying', [
                    'attempt' => $attempt,
                    'error_type' => $e::class,
                ]);
            }
        }

        throw new RuntimeException('Vertex Veo extend failed after retries.');
    }

    private function extractOperationName($response, string $stage): string
    {
        if ($response->failed()) {
            Log::channel('ai_video')->error("Vertex Veo {$stage} request failed", ['status' => $response->status()]);

            throw new RuntimeException("Vertex Veo {$stage} request failed. See logs for the vendor response.");
        }

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

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

        return $operationName;
    }

    /** @return array{bytesBase64Encoded: string, mimeType: ?string} */
    private function pollUntilDone(string $baseUrl, string $accessToken, string $operationName, ?callable $onProgress = null, int $progressStart = 10, int $progressEnd = 90, string $stageLabel = 'Generating video'): array
    {
        $interval = (int) config('creative.gemini_veo_vertex.poll_interval_seconds', 10);
        $maxAttempts = (int) config('creative.gemini_veo_vertex.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);

            $response = Http::withToken($accessToken)
                ->timeout(30)
                ->post("{$baseUrl}:fetchPredictOperation", ['operationName' => $operationName]);

            // Skip logging the full response body once done — it carries the base64 video bytes.
            Log::channel('ai_video')->info('Vertex Veo poll response', [
                'attempt' => $attempt + 1,
                'operation' => $operationName,
                'status' => $response->status(),
                'done' => $response->json('done'),
                'completed_response' => $response->json('done') === true ? [
                    'video_count' => count($response->json('response.videos', [])),
                    'filtered_count' => $response->json('response.raiMediaFilteredCount'),
                ] : null,
            ]);

            if ($response->failed()) {
                Log::channel('ai_video')->error('Vertex Veo operation polling failed', ['status' => $response->status(), 'operation' => $operationName]);

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

            if ($response->json('done') === true) {
                if ($error = $response->json('error')) {
                    Log::channel('ai_video')->error('Vertex Veo generation failed', ['operation' => $operationName, 'error_code' => $error['code'] ?? null]);

                    if ($this->isTransientVendorError($error)) {
                        throw new TransientVertexRenderException('Transient Vertex generation error.');
                    }

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

                $video = $response->json('response.videos.0');

                if (blank($video['bytesBase64Encoded'] ?? null)) {
                    $filteredCount = (int) $response->json('response.raiMediaFilteredCount', 0);
                    $filteredReasons = $response->json('response.raiMediaFilteredReasons', []);

                    $filterReason = $this->filterReasonSummary($filteredReasons);
                    Log::channel('ai_video')->error('Vertex Veo operation completed without video bytes', [
                        'operation' => $operationName,
                        'filtered_count' => $filteredCount,
                        'filter_reason' => $filterReason,
                        'response_keys' => array_keys($response->json('response', [])),
                    ]);

                    if ($filteredCount > 0 || ! empty($filteredReasons)) {
                        throw new VertexSafetyFilteredException($filterReason ?? 'Vertex Veo safety filter rejected the video without a reason.');
                    }

                    throw new TransientVertexRenderException('Vertex Veo completed without video bytes or a filter reason.');
                }

                $onProgress && $onProgress($progressEnd, $stageLabel);

                return $video;
            }

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

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

    /** True for known-transient Vertex backend infra faults, unrelated to our request. */
    private function isTransientVendorError(array $error): bool
    {
        $message = $error['message'] ?? '';

        return str_contains($message, 'Input/output error')
            || str_contains($message, 'Failed to create temporary file');
    }

    /** @param  array{bytesBase64Encoded: string, mimeType: ?string}  $video */
    private function storeFinalAsset(array $video): string
    {
        $filename = 'creatives/videos/'.Str::ulid().'.mp4';
        $bytes = base64_decode($video['bytesBase64Encoded'], strict: true);

        if ($bytes === false) {
            throw new RuntimeException('Failed to decode the final Vertex Veo video bytes.');
        }

        $stored = Storage::disk('public')->put($filename, $bytes);

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

        return $filename;
    }

    private function filterReasonSummary(mixed $reasons): ?string
    {
        $items = is_array($reasons) ? $reasons : [$reasons];
        $reasonText = implode(' ', array_filter($items, is_string(...)));
        if ($reasonText === '') {
            return null;
        }

        $normalizedReason = strtolower($reasonText);
        $category = match (true) {
            str_contains($normalizedReason, 'usage guidelines') => 'Vertex AI usage guidelines',
            str_contains($normalizedReason, 'real person'), str_contains($normalizedReason, 'likeness') => 'Person likeness policy',
            default => 'Vertex AI safety policy',
        };

        preg_match('/support codes?:\s*([0-9, ]+)/i', $reasonText, $supportCodeList);
        preg_match_all('/\b\d{8}\b/', $supportCodeList[1] ?? '', $matches);
        $supportCodes = array_slice(array_unique($matches[0]), 0, 3);

        return $supportCodes === [] ? $category : $category.' (support code '.implode(', ', $supportCodes).')';
    }

    private function sanitizeForSafety(?string $text): ?string
    {
        if (! $text) {
            return $text;
        }

        $patterns = [
            '/\b(babydoll|lingerie|underwear|panty|panties|bra|thong|intimate wear|nightgown)\b/i' => 'loungewear',
            '/\b(sheer|see-through|transparent)\b/i' => 'elegant',
            '/\b(sexy|sensual|seductive)\b/i' => 'alluring',
        ];

        return (string) preg_replace(array_keys($patterns), array_values($patterns), $text);
    }
}
