<?php

namespace App\Livewire\Creatives;

use App\Contracts\CreativeBriefResolver;
use App\Contracts\ImageCreativeGenerator;
use App\Contracts\ScriptGenerator;
use App\Contracts\StyleSuggester;
use App\Contracts\VoiceSynthesizer;
use App\DTOs\CreativeBrief;
use App\Jobs\RenderVideoCreativeJob;
use App\Models\Avatar;
use App\Models\Creative;
use App\Models\Voice;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\WithFileUploads;
use Throwable;

#[Layout('layouts.mb', ['pageTitle' => 'New creative'])]
class ChatWizard extends Component
{
    use WithFileUploads;

    public string $step = 'type';

    // Locked: these drive which/how many billable renders confirmGenerate() dispatches,
    // so they must only change via our own select*()/submit*() methods (server-side),
    // never from a client-forged Livewire update payload.
    #[Locked]
    public ?string $type = null;

    public ?string $source = null;

    public string $rawInput = '';

    #[Locked]
    public ?string $ratio = null;

    public string $style = '';

    // "Pick a look" size filter (mirrors the design's mb-size-chip bar) — purely a client-side
    // gallery filter, doesn't affect $style until a card is actually clicked.
    public string $styleSizeFilter = 'all';

    // Ported from the design bundle's ad-style catalogue (config.php: $mbAdStyles) — one preview
    // image per ratio, stored under public/assets/mb/img/ad-style/<dir>/<ratio-file>.jpg.
    public array $styleCatalogue = [
        ['id' => 'botanical-product', 'dir' => '1', 'name' => 'Botanical Product', 'tag' => 'Beauty', 'desc' => 'Soft studio set with fresh botanicals and clean shadows.'],
        ['id' => 'clean-beauty', 'dir' => '2', 'name' => 'Clean Beauty', 'tag' => 'Skincare', 'desc' => 'Editorial serif headline over a minimal, sunlit product shot.'],
        ['id' => 'break-out-type', 'dir' => '3', 'name' => 'Break-Out Type', 'tag' => 'Tech', 'desc' => 'Product bursting through oversized display type in a real room.'],
        ['id' => 'splash-energy', 'dir' => '4', 'name' => 'Splash Energy', 'tag' => 'Beverage', 'desc' => 'Mid-air pack shot with fruit, ice and liquid splash.'],
        ['id' => 'moody-gourmet', 'dir' => '5', 'name' => 'Moody Gourmet', 'tag' => 'Food', 'desc' => 'Dark, warm-lit food hero with quiet luxury typography.'],
        ['id' => 'bold-type-pop', 'dir' => '6', 'name' => 'Bold Type Pop', 'tag' => 'Lifestyle', 'desc' => 'High-saturation colour block with playful oversized lettering.'],
        ['id' => 'appetite-offer', 'dir' => '7', 'name' => 'Appetite Offer', 'tag' => 'QSR', 'desc' => 'Close-up food hero with a loud discount badge and CTA.'],
        ['id' => 'kinetic-sport', 'dir' => '8', 'name' => 'Kinetic Sport', 'tag' => 'Sport', 'desc' => 'Brush-stroke energy, dramatic sky and a hero product cut-out.'],
    ];

    // Same 4 sizes/ratios as the wizard's Format step, plus the box aspect-ratio the design
    // uses for the gallery filter chips and per-card preview image.
    public array $styleSizeOptions = [
        ['id' => '1:1', 'file' => '1x1', 'use' => 'Feed', 'w' => 1, 'h' => 1],
        ['id' => '4:5', 'file' => '4x5', 'use' => 'Portrait', 'w' => 4, 'h' => 5],
        ['id' => '9:16', 'file' => '9x16', 'use' => 'Story', 'w' => 9, 'h' => 16],
        ['id' => '16:9', 'file' => '16x9', 'use' => 'Display', 'w' => 16, 'h' => 9],
    ];

    #[Locked]
    public ?int $quantity = null;

    public string $customQuantity = '';

    public ?string $videoNotice = null;

    public array $ratios = ['1:1', '9:16', '16:9', '4:5'];

    public array $videoRatios = ['1:1', '4:3', '9:16', '16:9'];

    public array $quantityPresets = [1, 2, 3, 5, 10];

    // Video renders are slow and billable per clip; cap how many a single click can trigger.
    public array $videoQuantityPresets = [1, 2, 4];

    // Limited to Veo's exact supported durations for now — Sora's rendered
    // audio isn't reliable, so no preset here should exceed
    // config('creative.veo_max_duration') and route to Sora.
    public array $durationPresets = [4, 6, 8, 15];

    #[Locked]
    public ?int $duration = 8;

    /** @var array<int, \Livewire\Features\SupportFileUploads\TemporaryUploadedFile> */
    public array $uploadedImages = [];

    /** @var list<string> paths on the "public" disk, populated once uploads are validated. */
    public array $uploadedImagePaths = [];

    /** @var array<string, mixed>|null cached resolved brief, shared across script generation + final generate. */
    public ?array $brief = null;

    /** @var list<array{hook: string, problem: string, product: string, benefits: string, cta: string}> */
    public array $scriptVariations = [];

    public ?int $selectedScriptIndex = null;

    /** @var array{hook: string, problem: string, product: string, benefits: string, cta: string}|null */
    public ?array $script = null;

    #[Locked]
    public ?int $avatarId = null;

    #[Locked]
    public ?int $voiceId = null;

    /** @var array<int, string> voiceId => stored preview asset path */
    public array $voicePreviewPaths = [];

    /** @var list<array{scene: string}> */
    public array $storyboard = [];

    public array $results = [];

    // Locked: recorded as parent_creative_id and only ever set (with an ownership
    // check) by selectCloneFrom() — never trust it as-is off a client payload.
    #[Locked]
    public ?int $cloneFromId = null;

    public function selectType(string $type): void
    {
        if (! in_array($type, ['image', 'video'], true)) {
            return;
        }

        $this->videoNotice = null;
        $this->type = $type;

        if ($type === 'video') {
            $this->ratio = '9:16';
            $this->quantity = 1;
        }

        $this->step = 'source';
    }

    public function selectSource(string $source): void
    {
        if (! in_array($source, ['prompt', 'product_url', 'upload', 'existing'], true)) {
            return;
        }

        $this->source = $source;
        $this->step = $source === 'existing' ? 'clone_select' : 'input';
    }

    public function selectCloneFrom(int $creativeId): void
    {
        $parent = auth()->user()->creatives()
            ->where('status', 'completed')
            ->findOrFail($creativeId);

        $this->cloneFromId = $parent->id;
        $this->type = $parent->type;
        $this->ratio = $parent->ratio;
        $this->style = (string) $parent->style;
        $this->rawInput = $parent->prompt ?? $parent->product_url ?? '';
        $this->brief = $parent->brief;

        $this->step = 'ratio';
    }

    public function submitInput(): void
    {
        if ($this->source === 'upload') {
            $this->validate([
                'uploadedImages' => ['required', 'array', 'min:1', 'max:5'],
                // 'image' also allows svg/bmp; svg is excluded since it can carry executable
                // script and is served unauthenticated straight off the public disk.
                'uploadedImages.*' => ['mimes:jpeg,jpg,png,webp,gif', 'max:10240'],
            ]);

            $this->uploadedImagePaths = collect($this->uploadedImages)
                ->map(fn ($file) => $file->store('creatives/uploads', 'public'))
                ->all();
        } else {
            $this->validate([
                'rawInput' => $this->source === 'product_url'
                    ? ['required', 'url', 'max:2000']
                    : ['required', 'string', 'max:2000'],
            ]);
        }

        $this->step = 'ratio';
    }

    public function selectRatio(string $ratio): void
    {
        $allowed = $this->type === 'video' ? $this->videoRatios : $this->ratios;

        if (!in_array($ratio, $allowed, strict: true)) {
            throw new \InvalidArgumentException(
                'Ratio must be one of: ' . implode(', ', $allowed)
            );
        }

        $this->ratio = $ratio;
    }

    public function selectDuration(int $duration): void
    {
        if (!in_array($duration, $this->durationPresets, strict: true)) {
            throw new \InvalidArgumentException(
                'Duration must be one of: ' . implode(', ', $this->durationPresets)
            );
        }

        $this->duration = $duration;
    }

    /** Format step (ratio + duration + variations, all on one screen) advances to Style once valid. */
    public function submitFormat(): void
    {
        $allowedRatios = $this->type === 'video' ? $this->videoRatios : $this->ratios;
        $maxQuantity = $this->type === 'video' ? 4 : 20;

        $this->validate([
            'ratio' => ['required', 'string', 'in:' . implode(',', $allowedRatios)],
            'quantity' => ['required', 'integer', 'min:1', "max:{$maxQuantity}"],
        ]);

        if ($this->type === 'video') {
            $this->validate(['duration' => ['required', 'integer', 'in:' . implode(',', $this->durationPresets)]]);
        }

        $this->step = 'style';
    }

    public function submitStyle(): void
    {
        $this->validate(['style' => ['nullable', 'string', 'max:255']]);

        $this->step = $this->type === 'video' ? 'script' : 'summary';
    }

    public function selectStyle(string $style): void
    {
        $this->style = $style;
    }

    // "Auto" tile: explicitly no preference at wizard time — nothing is resolved/suggested
    // now, confirmGenerate() decides (currently: generate with no style hint) when it runs.
    public function selectAutoStyle(): void
    {
        $this->style = '';
    }

    public function setStyleSizeFilter(string $size): void
    {
        $allowed = array_column($this->styleSizeOptions, 'id');

        if ($size !== 'all' && ! in_array($size, $allowed, strict: true)) {
            return;
        }

        $this->styleSizeFilter = $size;
    }

    public function skipStyle(): void
    {
        $this->style = '';
        $this->step = $this->type === 'video' ? 'script' : 'summary';
    }

    public function suggestStyle(CreativeBriefResolver $briefResolver, StyleSuggester $styleSuggester): void
    {
        $brief = $this->resolveBrief($briefResolver);
        $this->style = $styleSuggester->suggest($brief);
        $this->step = $this->type === 'video' ? 'script' : 'summary';
    }

    public function generateScripts(CreativeBriefResolver $briefResolver, ScriptGenerator $scriptGenerator): void
    {
        $brief = $this->resolveBrief($briefResolver);

        $this->scriptVariations = $scriptGenerator->generateVariations($brief, 3, $this->duration);
        $this->selectedScriptIndex = null;
        $this->script = null;
    }

    public function selectScript(int $index): void
    {
        if (!isset($this->scriptVariations[$index])) {
            return;
        }

        $this->selectedScriptIndex = $index;
        $this->script = $this->scriptVariations[$index];
        $this->step = 'avatar';
    }

    public function selectAvatar(int $avatarId): void
    {
        $avatar = Avatar::findOrFail($avatarId);
        $this->avatarId = $avatarId;

        // Voice must match the newly picked avatar's gender; clear a mismatched pick.
        if ($this->voiceId !== null) {
            $voice = Voice::find($this->voiceId);
            if (!$voice || $voice->gender !== $avatar->gender) {
                $this->voiceId = null;
            }
        }
    }

    public function previewVoice(int $voiceId, VoiceSynthesizer $synthesizer): void
    {
        if (isset($this->voicePreviewPaths[$voiceId])) {
            return;
        }

        try {
            $voice = Voice::findOrFail($voiceId);
            $this->voicePreviewPaths[$voiceId] = $synthesizer->preview($voice);
        } catch (Throwable $e) {
            $this->addError('voicePreview', "Could not generate a preview for this voice: {$e->getMessage()}");
        }
    }

    public function selectVoice(int $voiceId): void
    {
        if ($this->avatarId === null) {
            return;
        }

        $this->voiceId = $voiceId;
        $this->buildStoryboard();
        $this->step = 'storyboard';
    }

    public function buildStoryboard(): void
    {
        if ($this->script === null) {
            return;
        }

        $this->storyboard = collect(['hook', 'problem', 'product', 'benefits', 'cta'])
            ->map(fn (string $section) => ['scene' => $this->script[$section] ?? ''])
            ->all();
    }

    public function updateStoryboardScene(int $index, string $text): void
    {
        if (isset($this->storyboard[$index])) {
            $this->storyboard[$index]['scene'] = $text;
        }
    }

    public function confirmStoryboard(): void
    {
        $this->step = 'summary';
    }

    public function selectQuantity(int $quantity): void
    {
        $allowed = $this->type === 'video' ? $this->videoQuantityPresets : $this->quantityPresets;

        // Validate against preset quantities to prevent abuse (e.g., quantity 10000)
        if (!in_array($quantity, $allowed, strict: true)) {
            throw new \InvalidArgumentException(
                "Quantity must be one of: " . implode(', ', $allowed)
            );
        }

        $this->quantity = $quantity;
    }

    public function submitCustomQuantity(): void
    {
        // Video renders are slow and billable per clip; cap custom quantity lower than images.
        $max = $this->type === 'video' ? 4 : 20;

        $this->validate(['customQuantity' => ['required', 'integer', 'min:1', "max:{$max}"]]);

        $this->quantity = (int) $this->customQuantity;
    }

    public function confirmGenerate(
        CreativeBriefResolver $briefResolver,
        ImageCreativeGenerator $imageGenerator,
    ): void {
        // #[Locked] stops the client from overwriting these directly, but confirmGenerate()
        // is the entry point that actually spends money, so re-check the values it's about
        // to act on rather than trusting whatever state selectQuantity()/selectRatio()/etc.
        // last left the component in.
        $this->assertGenerationStateIsValid();

        $variationGroupId = $this->quantity > 1 ? (string) Str::ulid() : null;
        $brief = $this->resolveBrief($briefResolver);
        $source = $this->cloneFromId !== null ? ($this->type === 'video' ? 'variation' : 'clone') : $this->source;

        for ($i = 0; $i < $this->quantity; $i++) {
            $creative = null;

            try {
                // Create creative record with 'generating' status
                $creative = Creative::create([
                    'user_id' => auth()->id(),
                    'type' => $this->type,
                    'status' => 'generating',
                    'source' => $source,
                    'parent_creative_id' => $this->cloneFromId,
                    'variation_group_id' => $variationGroupId,
                    'ratio' => $this->ratio,
                    'duration' => $this->type === 'video' ? $this->duration : null,
                    'style' => $this->style ?: null,
                    'avatar_id' => $this->type === 'video' ? $this->avatarId : null,
                    'voice_id' => $this->type === 'video' ? $this->voiceId : null,
                    'product_url' => $this->source === 'product_url' ? $this->rawInput : null,
                    'prompt' => $this->source === 'prompt' ? $this->rawInput : null,
                    'script' => $this->script,
                    'storyboard' => $this->type === 'video' ? $this->storyboard : null,
                    'brief' => $brief->toArray(),
                ]);

                if ($this->type === 'video') {
                    // Rendered out-of-request: Veo/Sora calls can take minutes (Sora + an extension call
                    // especially), which reliably exceeds nginx/php-fpm request timeouts if run synchronously
                    // here (confirmed via a real 502) — see RenderVideoCreativeJob.
                    RenderVideoCreativeJob::dispatch(
                        $creative->id,
                        $this->avatarId,
                        $this->voiceId,
                        $this->script,
                        $this->storyboard,
                        $this->duration,
                        $this->ratio,
                    );

                    $this->results[] = [
                        'id' => $creative->id,
                        'status' => $creative->status,
                        'asset_path' => null,
                    ];
                } else {
                    // Generate image synchronously in chat wizard
                    $assetPath = $imageGenerator->generate($brief, $this->ratio, $this->style ?: null);

                    // Update creative with generated image path
                    $creative->update([
                        'status' => 'completed',
                        'asset_path' => $assetPath,
                    ]);

                    $this->results[] = [
                        'id' => $creative->id,
                        'status' => $creative->status,
                        'asset_path' => $assetPath,
                    ];
                }
            } catch (Throwable $e) {
                // Log the real cause, but never surface a raw vendor/exception message in the UI.
                logger()->error('Creative generation failed', ['creative_id' => $creative?->id, 'exception' => $e]);

                if ($creative) {
                    $failureReason = 'Generation failed. Please try again.';
                    $creative->update(['status' => 'failed', 'failure_reason' => $failureReason]);
                    $this->results[] = [
                        'id' => $creative->id,
                        'status' => 'failed',
                        'failure_reason' => $failureReason,
                    ];
                }
            }
        }

        $this->step = 'done';
    }

    /**
     * Re-checks everything confirmGenerate() is about to act on. #[Locked] blocks the client
     * from writing these properties directly, but every value here was still set by an earlier
     * step method (selectQuantity(), selectRatio(), selectCloneFrom(), ...), so this re-validates
     * against the same rules rather than assuming that step's guard is still satisfied — e.g. type
     * can't change after ratio/duration/quantity were picked for a different type.
     */
    private function assertGenerationStateIsValid(): void
    {
        if (! in_array($this->type, ['image', 'video'], true)) {
            throw new \InvalidArgumentException('Invalid creative type.');
        }

        $maxQuantity = $this->type === 'video' ? 4 : 20;

        if ($this->quantity === null || $this->quantity < 1 || $this->quantity > $maxQuantity) {
            throw new \InvalidArgumentException('Invalid quantity.');
        }

        $allowedRatios = $this->type === 'video' ? $this->videoRatios : $this->ratios;

        if ($this->ratio === null || ! in_array($this->ratio, $allowedRatios, strict: true)) {
            throw new \InvalidArgumentException('Invalid ratio.');
        }

        if ($this->type === 'video' && ! in_array($this->duration, $this->durationPresets, strict: true)) {
            throw new \InvalidArgumentException('Invalid duration.');
        }

        if ($this->cloneFromId !== null) {
            $ownsParent = auth()->user()->creatives()
                ->where('status', 'completed')
                ->whereKey($this->cloneFromId)
                ->exists();

            if (! $ownsParent) {
                throw new \InvalidArgumentException('Invalid source creative.');
            }
        }
    }

    private function resolveBrief(CreativeBriefResolver $briefResolver): CreativeBrief
    {
        if ($this->brief !== null) {
            return CreativeBrief::fromArray($this->brief);
        }

        $brief = match (true) {
            $this->source === 'product_url' => $briefResolver->fromProductUrl($this->rawInput),
            $this->source === 'upload' => $briefResolver->fromImages($this->uploadedImagePaths),
            default => $briefResolver->fromPrompt($this->rawInput),
        };

        $this->brief = $brief->toArray();

        return $brief;
    }

    public function startOver(): void
    {
        $this->reset([
            'step', 'type', 'source', 'rawInput', 'ratio', 'style',
            'quantity', 'customQuantity', 'videoNotice', 'results',
            'duration', 'uploadedImages', 'uploadedImagePaths', 'cloneFromId',
            'brief', 'scriptVariations', 'selectedScriptIndex', 'script',
            'avatarId', 'voiceId', 'voicePreviewPaths', 'storyboard',
        ]);
        $this->step = 'type';
    }

    /** Step back to the previous step in the current flow; answers already given are kept. */
    public function goBack(): void
    {
        $order = $this->stepOrder();
        $index = array_search($this->step, $order, true);

        if ($index === false || $index === 0) {
            return;
        }

        $this->step = $order[$index - 1];
    }

    /** Jump back to any step already passed (used by the "Edit" links on the review step). */
    public function goToStep(string $step): void
    {
        $order = $this->stepOrder();
        $target = array_search($step, $order, true);
        $current = array_search($this->step, $order, true);

        if ($target === false || $current === false || $target > $current) {
            return;
        }

        $this->step = $step;
    }

    /** Polled from the "done" step while any result (video, rendered via a queued job) hasn't finished yet. */
    public function refreshResults(): void
    {
        if (empty($this->results)) {
            return;
        }

        $creatives = Creative::whereIn('id', collect($this->results)->pluck('id'))->get()->keyBy('id');

        $this->results = collect($this->results)->map(function (array $result) use ($creatives) {
            $creative = $creatives->get($result['id']);

            if (! $creative) {
                return $result;
            }

            return [
                'id' => $creative->id,
                'status' => $creative->status,
                'asset_path' => $creative->asset_path,
                'failure_reason' => $creative->failure_reason,
            ];
        })->all();
    }

    public function hasPendingResults(): bool
    {
        return collect($this->results)->contains(fn (array $result) => ! in_array($result['status'], ['completed', 'failed'], true));
    }

    #[Computed]
    public function cloneCandidates(): Collection
    {
        return auth()->user()->creatives()->where('status', 'completed')->latest()->take(20)->get();
    }

    #[Computed]
    public function avatars(): Collection
    {
        return Avatar::orderBy('name')->get();
    }

    #[Computed]
    public function voices(): Collection
    {
        $query = Voice::orderBy('name');

        if ($this->avatarId !== null && ($gender = Avatar::find($this->avatarId)?->gender)) {
            $query->where('gender', $gender);
        }

        return $query->get();
    }

    #[Computed]
    public function liveResults(): array
    {
        // Fetch fresh creative data with current status
        return collect($this->results)
            ->map(function ($result) {
                $creative = Creative::find($result['id']);
                if (!$creative) {
                    return $result;
                }

                return [
                    'id' => $creative->id,
                    'status' => $creative->status,
                    'asset_path' => $creative->asset_path,
                    'failure_reason' => $creative->failure_reason,
                ];
            })
            ->all();
    }

    public function stepOrder(): array
    {
        $steps = ['type', 'source'];
        $steps[] = $this->source === 'existing' ? 'clone_select' : 'input';
        $steps[] = 'ratio';
        $steps[] = 'style';

        if ($this->type === 'video') {
            array_push($steps, 'script', 'avatar', 'storyboard');
        }

        array_push($steps, 'summary', 'done');

        return $steps;
    }

    /** Human label for the mb-steps tab bar (several internal steps share one tab). */
    public function stepLabel(string $step): string
    {
        return match ($step) {
            'type' => 'Type',
            'source' => 'Starting point',
            'clone_select', 'input' => 'Details',
            'ratio' => 'Format',
            'style' => 'Style',
            'script' => 'Script',
            'avatar' => 'Avatar & voice',
            'storyboard' => 'Storyboard',
            'summary', 'done' => 'Review',
            default => ucfirst($step),
        };
    }

    /** Ordered [key => label] tabs for the current flow, de-duplicated by label so steps sharing a tab don't repeat. */
    public function tabs(): array
    {
        $tabs = [];

        foreach ($this->stepOrder() as $step) {
            if ($step === 'done') {
                continue;
            }

            $label = $this->stepLabel($step);
            $tabs[$label] = $step;
        }

        return $tabs;
    }

    public function progressPercent(): int
    {
        $order = $this->stepOrder();
        $foundIndex = array_search($this->step, $order, true);
        $index = $foundIndex === false ? 0 : $foundIndex;

        return (int) round((($index + 1) / count($order)) * 100);
    }

    public function render()
    {
        return view('livewire.creatives.chat-wizard');
    }
}
