<?php

namespace App\Livewire\Creatives;

use App\Contracts\AsyncCompetitorAdProvider;
use App\Contracts\CompetitorAdProvider;
use App\Contracts\CreativeBriefResolver;
use App\Contracts\CreativeTitleGenerator;
use App\Contracts\ScriptGenerator;
use App\Contracts\StyleSuggester;
use App\Contracts\VoiceSynthesizer;
use App\DTOs\CompetitorAd;
use App\DTOs\CreativeBrief;
use App\Exceptions\VideoDialogueException;
use App\Jobs\GenerateImageCreativeJob;
use App\Jobs\PrefetchCreativeBriefJob;
use App\Jobs\RenderVideoCreativeJob;
use App\Models\Avatar;
use App\Models\Creative;
use App\Models\Voice;
use App\Services\AI\ApifyMetaAdLibraryProvider;
use App\Services\AI\HiggsfieldSeedanceVideoAvatarProvider;
use App\Services\AI\PuppeteerMetaAdLibraryProvider;
use App\Support\ProviderFailure;
use App\Support\StyleCatalogue;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Component;
use Livewire\Features\SupportFileUploads\TemporaryUploadedFile;
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 $projectName = '';

    public string $rawInput = '';

    /** Invalidates the prefetched brief (see prefetchBrief()) if the user goes back and edits their input. */
    public function updatedRawInput(): void
    {
        $this->brief = null;
    }

    #[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 = StyleCatalogue::CARDS;

    // 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 = '';

    // Custom pixel size, e.g. "1200" x "628" → ratio stored as "1200x628".
    public string $customRatioWidth = '';

    public string $customRatioHeight = '';

    public string $competitorQuery = '';

    public string $competitorCountry = 'US';

    // Results of the last CompetitorAdProvider::search(), as plain arrays (Livewire-safe).
    #[Locked]
    public array $competitorResults = [];

    public bool $competitorSearched = false;

    public bool $competitorSearchPending = false;

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

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

    public ?string $selectedCompetitorAdId = null;

    /** @var list<string> public-disk paths of the selected competitor ad thumbnail, used as a product visual reference. */
    #[Locked]
    public array $competitorImagePaths = [];

    public ?string $videoNotice = null;

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

    // Both video providers (Veo/Vertex) only ever renders 9:16 portrait or 16:9
    // landscape — anything else silently coerces to 16:9 — so those are the only
    // choices offered for video (unlike images, which support five ratios plus custom pixels).
    public array $videoRatios = ['9:16', '16:9'];

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

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

    // Ordered picks from $styleCatalogue/Auto, one per rendered variation (cycled if fewer
    // styles than $quantity are picked). Kept alongside the legacy single $style for quantity=1.
    public array $selectedStyles = [];

    // Image-only multi-select of sizes (see selectAllSizes()/toggleRatio()) — one variation is
    // rendered per picked ratio, cycled if fewer ratios than $quantity were chosen. Kept alongside
    // the legacy single $ratio for quantity=1/video (video never multi-selects ratio).
    public array $selectedRatios = [];

    // The image sizes picked by "Select all sizes" in the same order as $ratios.
    public const ALL_IMAGE_RATIOS = ['1:1', '9:16', '16:9', '4:5', '1.91:1'];

    // Veo handles 8s; Vertex Veo extends it to 15s or approximately 30s.
    public array $durationPresets = [8, 15, 30];

    /** Stored engine ids. The wizard reads VIDEO_ENGINE and does not let the user pick. */
    public const VIDEO_ENGINES = ['veo', HiggsfieldSeedanceVideoAvatarProvider::ENGINE];

    #[Locked]
    public string $videoEngine = HiggsfieldSeedanceVideoAvatarProvider::ENGINE;

    public function mount(): void
    {
        $this->videoEngine = self::configuredVideoEngine();
    }

    public static function configuredVideoEngine(): string
    {
        $engine = strtolower(trim((string) config('creative.video_engine', HiggsfieldSeedanceVideoAvatarProvider::ENGINE)));

        return match ($engine) {
            'veo', 'vertex' => 'veo',
            default => HiggsfieldSeedanceVideoAvatarProvider::ENGINE,
        };
    }

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

    // Free-text guidance captured on the Format step ("what kind of ad", "what to change") —
    // folded into the resolved brief's messaging so it reaches the AI prompts.
    public string $formatNotes = '';

    /** @var array<int, 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 = [];

    // Ordered indices into $scriptVariations, one per rendered variation (cycled if fewer
    // scripts than $quantity are picked) — capped at $quantity entries.
    public array $selectedScriptIndices = [];

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

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

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

    // One storyboard (list<array{scene: string}>) per entry in $selectedScriptIndices, same order.
    /** @var list<list<array{scene: string}>> */
    public array $storyboards = [];

    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;
        $this->ratio = '9:16';
        $this->quantity = 1;
        $this->selectedRatios = [];

        $this->step = 'source';
    }

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

        $this->source = $source;
        $this->step = match ($source) {
            'existing' => 'clone_select',
            'competitor' => 'competitor_search',
            default => 'input',
        };
    }

    /**
     * @return array<string, string> country name => ISO 3166-1 alpha-2
     */
    public function competitorCountries(): array
    {
        $countries = config('creative.competitor_ad_countries', []);

        return is_array($countries) ? $countries : [];
    }

    public function searchCompetitorAds(CompetitorAdProvider $provider): void
    {
        $this->validate([
            'competitorQuery' => ['required', 'string', 'max:255'],
            'competitorCountry' => ['required', 'string', 'size:2', Rule::in(array_values($this->competitorCountries()))],
        ]);

        $this->competitorResults = [];
        $this->competitorSearched = false;
        $this->competitorSearchPending = false;
        $this->competitorApifyRunId = null;
        $this->competitorSearchBackend = null;
        $this->selectedCompetitorAdId = null;
        $this->discardCompetitorThumbnails();

        try {
            $pageId = $this->resolvedCompetitorPageId();
            $mediaType = $this->type === 'video' ? 'VIDEO' : 'IMAGE';

            if ($pageId !== null) {
                logger()->info('Competitor ad search using Meta page ID', [
                    'page_id' => $pageId,
                    'query' => $this->competitorQuery,
                    'country' => $this->competitorCountry,
                ]);
            }

            if ($provider instanceof AsyncCompetitorAdProvider) {
                $this->competitorApifyRunId = $pageId !== null && $provider instanceof ApifyMetaAdLibraryProvider
                    ? $provider->beginPageSearch([$pageId], $this->competitorCountry, $mediaType)
                    : $provider->beginKeywordSearch($this->competitorQuery, $this->competitorCountry, $mediaType);
                $this->competitorSearchBackend = $provider instanceof ApifyMetaAdLibraryProvider ? 'apify' : 'puppeteer';
                $this->competitorSearchPending = true;

                return;
            }

            $results = $pageId !== null
                ? $provider->searchByPage([$pageId], $this->competitorCountry, null, $mediaType)
                : $provider->search($this->competitorQuery, $this->competitorCountry, null, $mediaType);

            $this->storeCompetitorResults($results);
        } catch (Throwable $e) {
            logger()->error('Competitor ad search failed', [
                'query' => $this->competitorQuery,
                'country' => $this->competitorCountry,
                'exception' => $e,
            ]);

            if ($this->beginApifyFallback($provider)) {
                return;
            }

            $this->failCompetitorSearch();
        }
    }

    public function pollCompetitorAds(CompetitorAdProvider $provider): void
    {
        if (! $this->competitorSearchPending || blank($this->competitorApifyRunId)) {
            return;
        }

        $collector = $this->competitorSearchBackend === 'apify'
            ? app(ApifyMetaAdLibraryProvider::class)
            : $provider;

        if (! $collector instanceof AsyncCompetitorAdProvider) {
            return;
        }

        try {
            $results = $collector->collectRun($this->competitorApifyRunId);

            if ($results === null) {
                return;
            }

            if ($results->isEmpty() && $this->beginApifyFallback($provider)) {
                return;
            }

            $this->storeCompetitorResults($results);
        } catch (Throwable $e) {
            logger()->error('Competitor ad search failed', [
                'query' => $this->competitorQuery,
                'country' => $this->competitorCountry,
                'run_id' => $this->competitorApifyRunId,
                'exception' => $e,
            ]);

            if ($this->beginApifyFallback($provider)) {
                return;
            }

            $this->failCompetitorSearch();
        }
    }

    /**
     * @param  Collection<int, CompetitorAd>  $results
     */
    private function storeCompetitorResults(Collection $results): void
    {
        $this->competitorResults = $results
            ->map(fn ($ad) => [
                'external_ad_id' => $ad->externalAdId,
                'platform' => $ad->platform,
                'advertiser_name' => $ad->advertiserName,
                'thumbnail_url' => $ad->thumbnailUrl,
                'snapshot' => $ad->snapshot,
            ])
            ->all();
        $this->competitorSearched = true;
        $this->competitorSearchPending = false;
        $this->competitorApifyRunId = null;
        $this->competitorSearchBackend = null;
    }

    private function beginApifyFallback(CompetitorAdProvider $provider): bool
    {
        if ($this->competitorSearchBackend === 'apify' || ! $provider instanceof PuppeteerMetaAdLibraryProvider) {
            return false;
        }

        try {
            $apify = app(ApifyMetaAdLibraryProvider::class);
            $pageId = $this->resolvedCompetitorPageId();
            $mediaType = $this->type === 'video' ? 'VIDEO' : 'IMAGE';

            $this->competitorApifyRunId = $pageId !== null
                ? $apify->beginPageSearch([$pageId], $this->competitorCountry, $mediaType)
                : $apify->beginKeywordSearch($this->competitorQuery, $this->competitorCountry, $mediaType);
            $this->competitorSearchBackend = 'apify';
            $this->competitorSearchPending = true;

            logger()->warning('Puppeteer Ad Library search failed; falling back to Apify', [
                'query' => $this->competitorQuery,
                'country' => $this->competitorCountry,
                'run_id' => $this->competitorApifyRunId,
            ]);

            return true;
        } catch (Throwable $e) {
            logger()->error('Apify Ad Library fallback failed', [
                'query' => $this->competitorQuery,
                'country' => $this->competitorCountry,
                'exception' => $e,
            ]);

            return false;
        }
    }

    private function failCompetitorSearch(): void
    {
        $this->competitorSearchPending = false;
        $this->competitorApifyRunId = null;
        $this->competitorSearchBackend = null;
        $this->addError('competitorQuery', 'Could not search Meta\'s Ad Library. Try again in a moment.');
    }

    private function resolvedCompetitorPageId(): ?string
    {
        $query = trim($this->competitorQuery);

        if (preg_match('/^\d+$/', $query) === 1) {
            return $query;
        }

        if (preg_match('/(?:[?&]id=|facebook\.com\/|fb\.com\/)(\d{5,})/i', $query, $matches) === 1) {
            return $matches[1];
        }

        return null;
    }

    public function selectCompetitorAd(string $externalAdId): void
    {
        $ad = collect($this->competitorResults)->firstWhere('external_ad_id', $externalAdId);

        if ($ad === null) {
            return;
        }

        $this->selectedCompetitorAdId = $externalAdId;
        $this->brief = null;
        $this->discardCompetitorThumbnails();

        $storedThumbnail = $this->storeCompetitorThumbnail($ad['thumbnail_url'] ?? null);
        if ($storedThumbnail !== null) {
            $this->competitorImagePaths = [$storedThumbnail];
        }

        $bodies = implode(' ', $ad['snapshot']['ad_creative_bodies'] ?? []);
        $titles = implode(' ', $ad['snapshot']['ad_creative_link_titles'] ?? []);

        $this->rawInput = trim(sprintf(
            "Create an ad inspired by this competitor ad from %s:\n%s\n%s",
            $ad['advertiser_name'],
            $titles,
            $bodies,
        ));

        auth()->user()->competitorAdReferences()->create([
            'platform' => $ad['platform'],
            'external_ad_id' => $ad['external_ad_id'],
            'snapshot' => $ad['snapshot'],
            'thumbnail_path' => $ad['thumbnail_url'],
            'search_query' => $this->competitorQuery,
        ]);

        $this->step = 'ratio';
    }

    private function storeCompetitorThumbnail(?string $thumbnailUrl): ?string
    {
        if (! is_string($thumbnailUrl) || trim($thumbnailUrl) === '') {
            return null;
        }

        $scheme = parse_url($thumbnailUrl, PHP_URL_SCHEME);
        $host = parse_url($thumbnailUrl, PHP_URL_HOST);
        $isTrustedHost = is_string($host) && (
            $host === 'graph.facebook.com'
            || str_ends_with($host, '.fbcdn.net')
            || str_ends_with($host, '.cdninstagram.com')
        );

        if ($scheme !== 'https' || ! $isTrustedHost || parse_url($thumbnailUrl, PHP_URL_USER) !== null || parse_url($thumbnailUrl, PHP_URL_PORT) !== null) {
            return null;
        }

        try {
            $response = Http::timeout(20)->withoutRedirecting()->withOptions(['stream' => true])->get($thumbnailUrl);

            if (! $response->successful()) {
                return null;
            }

            $contentType = strtolower(trim(explode(';', (string) $response->header('Content-Type'))[0]));
            $extensions = [
                'image/jpeg' => 'jpg',
                'image/png' => 'png',
                'image/webp' => 'webp',
                'image/gif' => 'gif',
                'image/avif' => 'avif',
            ];

            if (! isset($extensions[$contentType])) {
                return null;
            }

            $maximumBytes = 10 * 1024 * 1024;
            if ((int) $response->header('Content-Length', 0) > $maximumBytes) {
                return null;
            }

            $stream = $response->toPsrResponse()->getBody();
            $imageBytes = '';
            try {
                while (! $stream->eof()) {
                    $chunk = $stream->read(min(8192, $maximumBytes + 1 - strlen($imageBytes)));
                    if ($chunk === '') {
                        break;
                    }

                    $imageBytes .= $chunk;
                    if (strlen($imageBytes) > $maximumBytes) {
                        return null;
                    }
                }
            } finally {
                $stream->close();
            }

            if ($imageBytes === '') {
                return null;
            }

            $imageInfo = @getimagesizefromstring($imageBytes);
            if ($imageInfo === false || ($imageInfo['mime'] ?? null) !== $contentType) {
                return null;
            }

            $path = 'creatives/brief-images/'.Str::uuid().'.'.$extensions[$contentType];

            return Storage::disk('public')->put($path, $imageBytes) ? $path : null;
        } catch (Throwable $exception) {
            logger()->warning('Competitor ad thumbnail download failed', [
                'exception_type' => $exception::class,
            ]);

            return null;
        }
    }

    private function discardCompetitorThumbnails(): void
    {
        foreach ($this->competitorImagePaths as $path) {
            if (! is_string($path) || ! str_starts_with($path, 'creatives/brief-images/')) {
                continue;
            }

            Storage::disk('public')->delete($path);
        }

        $this->competitorImagePaths = [];
    }

    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->projectName = (string) ($parent->project_name ?? '');

        $this->step = 'ratio';
    }

    public function updatedUploadedImages(): void
    {
        $this->validateOnly('uploadedImages', [
            'uploadedImages' => ['required', 'array', 'min:1', 'max:5'],
            'uploadedImages.*' => ['mimes:jpeg,jpg,png,webp,gif,avif', 'max:10240'],
        ]);
    }

    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,avif', 'max:10240'],
            ]);

            $this->uploadedImagePaths = collect($this->uploadedImages)
                ->map(function ($file) {
                    $stored = $file->store('creatives/uploads', 'public');
                    $disk = Storage::disk('public');
                    $fullPath = $disk->path($stored);

                    // AVIF is supported by browsers, but major LLM vision APIs (OpenAI gpt-4o,
                    // Gemini, Anthropic) reject image/avif data URLs. Convert AVIF uploads to
                    // high-quality JPEG so vision analysis and providers process them seamlessly.
                    if (str_ends_with(strtolower($stored), '.avif') || @mime_content_type($fullPath) === 'image/avif') {
                        $jpgRelPath = preg_replace('/\.avif$/i', '.jpg', $stored);
                        if ($jpgRelPath === $stored) {
                            $jpgRelPath .= '.jpg';
                        }
                        $img = @imagecreatefromavif($fullPath);
                        if ($img !== false) {
                            imagejpeg($img, $disk->path($jpgRelPath), 95);
                            imagedestroy($img);
                            @unlink($fullPath);

                            return $jpgRelPath;
                        }
                    }

                    return $stored;
                })
                ->all();
        } else {
            $this->validate([
                'rawInput' => $this->source === 'product_url'
                    ? ['required', 'url', 'max:2000']
                    : ['required', 'string', 'max:2000'],
            ]);
        }

        $this->step = 'ratio';

        // Every source that can be prefetched, is.
        //
        // This covered product_url alone, so a prompt and an upload always paid
        // the full brief resolution at whichever click first needed it - a page
        // fetch, a screenshot call and an AI call, inside one Livewire request.
        //
        // Competitor is left out on purpose: resolveBrief() discards the
        // competitor thumbnails once it has used them, so a prefetch would leave
        // the resolve that actually matters with no images to work from.
        if (in_array($this->source, ['product_url', 'prompt', 'upload'], true)) {
            PrefetchCreativeBriefJob::dispatch(
                payload: $this->source === 'upload' ? '' : $this->rawInput,
                source: $this->source,
                images: $this->source === 'upload' ? $this->uploadedImagePaths : [],
            )->afterResponse();
        }
    }

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

        // Images support picking several sizes to generate one variation per size (see
        // ratioForIndex()); video keeps a single ratio for the whole batch.
        if ($this->type === 'image' && ($this->quantity ?? 1) > 1) {
            $this->toggleRatio($ratio);

            return;
        }

        $this->ratio = $ratio;
        $this->selectedRatios = [$ratio];
    }

    /** Adds/removes a ratio from the batch, capped at one per requested variation. */
    public function toggleRatio(string $ratio): void
    {
        $max = max(1, $this->quantity ?? 1);

        if (($pos = array_search($ratio, $this->selectedRatios, true)) !== false) {
            array_splice($this->selectedRatios, $pos, 1);

            return;
        }

        if (count($this->selectedRatios) >= $max) {
            return;
        }

        $this->selectedRatios[] = $ratio;
        $this->ratio = $this->selectedRatios[0];
    }

    /** Selects every standard image size and renders one variation per size. */
    public function selectAllSizes(): void
    {
        if ($this->type !== 'image') {
            return;
        }

        $this->quantity = count(self::ALL_IMAGE_RATIOS);
        $this->selectedRatios = self::ALL_IMAGE_RATIOS;
        $this->ratio = self::ALL_IMAGE_RATIOS[0];
    }

    /** Largest side a custom size may ask for, typed and after scaling alike. */
    private const MAX_CUSTOM_DIMENSION = 8000;

    private const MAX_CUSTOM_PIXELS = 8_294_400;

    public function submitCustomRatio(): void
    {
        $this->validate([
            'customRatioWidth' => ['required', 'integer', 'min:1', 'max:'.self::MAX_CUSTOM_DIMENSION],
            'customRatioHeight' => ['required', 'integer', 'min:1', 'max:'.self::MAX_CUSTOM_DIMENSION],
        ]);

        $width = (int) $this->customRatioWidth;
        $height = (int) $this->customRatioHeight;

        // If user entered aspect ratio numbers (e.g. 16 and 9, 16 and 16, 4 and 5),
        // normalize to standard high-resolution pixel dimensions so images are crisp.
        if ($width <= 100 && $height <= 100) {
            $aspect = $width / $height;
            if ($aspect >= 1) {
                $height = 1080;
                $width = (int) round(1080 * $aspect);
            } else {
                $width = 1080;
                $height = (int) round(1080 / $aspect);
            }

            if (max($width, $height) > self::MAX_CUSTOM_DIMENSION) {
                $scale = self::MAX_CUSTOM_DIMENSION / max($width, $height);
                $width = max(1, (int) round($width * $scale));
                $height = max(1, (int) round($height * $scale));
            }

        }

        if ($width * $height > self::MAX_CUSTOM_PIXELS) {
            $this->addError('customRatioWidth', 'Custom image size cannot exceed 8,294,400 pixels.');

            return;
        }

        $this->customRatioWidth = $width;
        $this->customRatioHeight = $height;
        $this->ratio = "{$width}x{$height}";
    }

    /** Whether a ratio string is a custom "WxH" pixel size rather than one of the ratio presets. */
    public static function isCustomRatio(?string $ratio): bool
    {
        return $ratio !== null && preg_match('/^\d+x\d+$/', $ratio) === 1;
    }

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

    public function higgsfieldModelLabel(): string
    {
        $models = config('creative.higgsfield_models', []);
        $selected = $models[config('creative.higgsfield_model', 'wan-3.0')] ?? null;

        return is_array($selected) ? (string) ($selected['label'] ?? 'Wan') : 'Wan';
    }

    /** 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' ? 3 : 20;
        // Custom pixel ratios aren't a supported video provider option (see $videoRatios).
        $allowCustomRatio = $this->type !== 'video';

        if (! in_array($this->ratio, $allowedRatios, strict: true) && ! ($allowCustomRatio && self::isCustomRatio($this->ratio))) {
            throw new \InvalidArgumentException('Invalid ratio.');
        }

        $this->validate([
            'quantity' => ['required', 'integer', 'min:1', "max:{$maxQuantity}"],
            'formatNotes' => ['nullable', 'string', 'max:600'],
        ]);

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

        $this->step = 'style';
    }

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

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

    /** Single-select when only 1 variation is being generated; toggled multi-select otherwise (see confirmStyles()). */
    public function selectStyle(string $style): void
    {
        if (($this->quantity ?? 1) > 1) {
            $this->toggleStyle($style);

            return;
        }

        $this->style = $style;
        $this->selectedStyles = [$style];
        $this->step = $this->type === 'video' ? 'script' : 'summary';
    }

    /** Adds/removes a style from the batch, capped at one per requested variation. */
    public function toggleStyle(string $style): void
    {
        $max = max(1, $this->quantity ?? 1);

        if (($pos = array_search($style, $this->selectedStyles, true)) !== false) {
            array_splice($this->selectedStyles, $pos, 1);

            return;
        }

        if (count($this->selectedStyles) >= $max) {
            return;
        }

        $this->selectedStyles[] = $style;
    }

    /** Advances from the (multi-select) style step once at least one style is picked. */
    public function confirmStyles(): void
    {
        if (empty($this->selectedStyles)) {
            return;
        }

        $this->style = $this->selectedStyles[0];
        $this->step = $this->type === 'video' ? 'script' : 'summary';
    }

    // "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->selectStyle('');
    }

    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->selectedStyles = [];
        $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->selectedStyles = [$this->style];
        $this->step = $this->type === 'video' ? 'script' : 'summary';
    }

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

        $this->resetErrorBag('scriptVariations');

        try {
            $scriptVariations = $scriptGenerator->generateVariations($brief, 4, $this->duration, $this->videoEngine);
            $this->scriptVariations = $scriptVariations;
            $this->selectedScriptIndices = [];
            $this->storyboards = [];
        } catch (VideoDialogueException $exception) {
            $this->addError('scriptVariations', 'Could not fit the generated dialogue into this duration. Please try generating scripts again.');
        }
    }

    /** Adds/removes a script variation from the batch, capped at one per requested variation. */
    public function toggleScript(int $index): void
    {
        if (! isset($this->scriptVariations[$index])) {
            return;
        }

        $max = max(1, $this->quantity ?? 1);

        if (($pos = array_search($index, $this->selectedScriptIndices, true)) !== false) {
            array_splice($this->selectedScriptIndices, $pos, 1);

            return;
        }

        if (count($this->selectedScriptIndices) >= $max) {
            array_shift($this->selectedScriptIndices);
        }

        $this->selectedScriptIndices[] = $index;
    }

    /** Advances from the (multi-select) script step once at least one script is picked. */
    public function confirmScripts(): void
    {
        if (empty($this->selectedScriptIndices)) {
            return;
        }

        $this->buildStoryboards();
        $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->provider !== 'gemini' || $voice->gender !== $avatar->gender) {
                $this->voiceId = null;
            }
        }

        // Voice options render just below; nudge the user there instead of leaving them on the avatar grid.
        $this->dispatch('avatar-selected');
    }

    public function previewVoice(int $voiceId, VoiceSynthesizer $synthesizer): void
    {
        $voice = $this->voiceForSelectedAvatar($voiceId);

        if (isset($this->voicePreviewPaths[$voiceId])) {
            return;
        }

        try {
            $this->voicePreviewPaths[$voiceId] = $synthesizer->preview($voice);
        } catch (Throwable $e) {
            $this->addError('voicePreview', 'Could not generate a voice preview. Please try again.');
        }
    }

    public function selectVoice(int $voiceId): void
    {
        $this->voiceForSelectedAvatar($voiceId);

        $this->voiceId = $voiceId;
        $this->step = 'storyboard';
    }

    private function voiceForSelectedAvatar(int $voiceId): Voice
    {
        $avatar = Avatar::find($this->avatarId);
        $voice = Voice::find($voiceId);

        if ($avatar === null || $voice === null || $voice->provider !== 'gemini' || $voice->gender !== $avatar->gender) {
            throw new \InvalidArgumentException('Choose a voice that matches the selected avatar.');
        }

        return $voice;
    }

    /** Builds one storyboard per variation with shot count matched to the requested duration. */
    public function buildStoryboards(): void
    {
        $this->storyboards = collect($this->selectedScriptIndices)
            ->map(function (int $scriptIndex) {
                $script = $this->scriptVariations[$scriptIndex] ?? [];
                $visualAction = trim((string) ($script['visual_action'] ?? 'The presenter addresses the camera naturally, then makes a small call-to-action gesture.'));

                $higgsfield = $this->videoEngine === HiggsfieldSeedanceVideoAvatarProvider::ENGINE;
                $opening = $higgsfield
                    ? sprintf('Eye-level medium close-up. %s The presenter speaks only the dialogue line. The final second is a silent close-up of the product with the mouth closed.', $visualAction)
                    : sprintf('Eye-level medium close-up with a slow subtle push-in. %s Keep the presenter visible during speech and allow a natural closing gesture.', $visualAction);

                if ($this->duration <= 8) {
                    return [['scene' => $opening]];
                }

                $scenes = [['scene' => $opening]];
                if ($higgsfield) {
                    $scenes[] = ['scene' => 'Final second: silent close-up of the product only. No presenter speech and no extra words.'];

                    return $scenes;
                }

                $productAction = trim((string) ($script['product_action'] ?? ''));
                $scenes[] = ['scene' => $productAction !== ''
                    ? 'Between spoken phrases, show the product: '.$productAction
                    : 'Continue on the presenter with a slightly wider framing and a natural reaction.'];

                if ($this->duration >= 30) {
                    $scenes[] = ['scene' => 'Continue the ad with a brief product detail if verified, otherwise stay on the presenter.'];
                    $scenes[] = ['scene' => 'Return to the presenter for the closing gesture.'];
                }

                return $scenes;
            })
            ->all();
    }

    public function updateStoryboardScene(int $variationIndex, int $sceneIndex, string $text): void
    {
        if (isset($this->storyboards[$variationIndex][$sceneIndex])) {
            $this->storyboards[$variationIndex][$sceneIndex]['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' ? 3 : 20;

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

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

    public function confirmGenerate(
        CreativeBriefResolver $briefResolver,
        CreativeTitleGenerator $titleGenerator,
    ): 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;

        $projectName = $this->projectNameFor($brief);

        $rawAiTitle = $titleGenerator->generate($brief, $this->type);
        $baseAiTitle = trim(str_replace(['-', "\n", "\r"], [' ', '', ''], $rawAiTitle));
        if ($baseAiTitle === '') {
            $baseAiTitle = $brief->productName ?: 'Creative';
        }
        $baseAiTitle = Str::limit($baseAiTitle, self::TITLE_LENGTH, '');

        for ($i = 0; $i < $this->quantity; $i++) {
            $creative = null;
            $itemStyle = $this->styleForIndex($i);
            $itemRatio = $this->type === 'image' ? $this->ratioForIndex($i) : $this->ratio;
            $itemScript = $this->type === 'video' ? $this->scriptForIndex($i) : null;
            $itemStoryboard = $this->type === 'video' ? $this->storyboardForIndex($i) : null;
            $itemTitle = $this->titleFor($itemRatio, $projectName, $baseAiTitle);

            try {
                $creative = Creative::create([
                    'user_id' => auth()->id(),
                    'type' => $this->type,
                    'status' => $this->type === 'image' ? 'queued' : 'generating',
                    'render_stage' => $this->type === 'image' ? 'Waiting for image worker' : null,
                    'source' => $source,
                    'parent_creative_id' => $this->cloneFromId,
                    'variation_group_id' => $variationGroupId,
                    'ratio' => $itemRatio,
                    'duration' => $this->type === 'video' ? $this->duration : null,
                    'style' => $itemStyle,
                    'title' => $itemTitle,
                    'project_name' => $projectName,
                    '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' => $itemScript,
                    'storyboard' => $itemStoryboard,
                    'brief' => $brief->toArray(),
                    'provider' => $this->type === 'video' ? ['engine' => $this->videoEngine] : null,
                ]);

                if ($this->type === 'video') {
                    // Rendered out-of-request: Veo/Vertex calls can take minutes (Vertex extensions
                    // 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,
                        $itemScript,
                        $itemStoryboard,
                        $this->duration,
                        $itemRatio,
                    );

                    $this->results[] = [
                        'id' => $creative->id,
                        'status' => $creative->status,
                        'asset_path' => null,
                        'render_progress' => 0,
                        'render_stage' => 'Waiting for renderer',
                    ];
                } else {
                    GenerateImageCreativeJob::dispatch($creative->id);

                    $this->results[] = [
                        'id' => $creative->id,
                        'status' => $creative->status,
                        'asset_path' => null,
                        'render_stage' => $creative->render_stage,
                    ];
                }
            } 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) {
                    // "Please try again" is wrong for a quota window: the retry
                    // fails identically until it resets. Still never the
                    // provider's own message, which is what the log is for.
                    $failureReason = ProviderFailure::isRateLimit($e)
                        ? ucfirst(ProviderFailure::rateLimited($e))
                        : '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';
    }

    /** Maximum stored creative title length, including dimension and project. */
    private const TITLE_LENGTH = 80;

    private function projectNameFor(CreativeBrief $brief): string
    {
        $website = $this->source === 'product_url'
            ? preg_replace('/^www\./i', '', (string) parse_url($this->rawInput, PHP_URL_HOST))
            : '';

        $name = trim($this->projectName) ?: trim($brief->projectName ?? '') ?: $website ?: trim($brief->productName) ?: 'Creative';

        return Str::limit($name, 120, '');
    }

    private function titleFor(string $ratio, string $projectName, string $aiTitle): string
    {
        $dimension = $this->type === 'image' ? match ($ratio) {
            '1:1' => '1200x1200',
            '9:16' => '900x1600',
            '16:9' => '1600x900',
            '4:5' => '960x1200',
            '1.91:1' => '1200x628',
            default => $ratio,
        } : $ratio;

        $project = Str::limit(trim(preg_replace('/\s+/', ' ', str_replace('-', ' ', $projectName))), 30, '');
        $prefix = $dimension.' - '.$project.' - ';

        return $prefix.Str::limit($aiTitle, self::TITLE_LENGTH - Str::length($prefix), '');
    }

    /** Resolves the style for the i-th variation, cycling the picked styles if fewer than $quantity were chosen. */
    private function styleForIndex(int $i): ?string
    {
        $styles = $this->selectedStyles !== [] ? $this->selectedStyles : [$this->style];
        $style = $styles[$i % count($styles)];

        return $style !== '' ? $style : null;
    }

    /** Resolves the ratio for the i-th variation, cycling the picked sizes if fewer than $quantity were chosen. */
    private function ratioForIndex(int $i): string
    {
        $ratios = $this->selectedRatios !== [] ? $this->selectedRatios : [$this->ratio];

        return $ratios[$i % count($ratios)];
    }

    /** Resolves the script for the i-th video variation, cycling the picked scripts if fewer than $quantity were chosen. */
    private function scriptForIndex(int $i): array
    {
        $scriptIndex = $this->selectedScriptIndices[$i % count($this->selectedScriptIndices)];

        return $this->scriptVariations[$scriptIndex];
    }

    /** Resolves the storyboard for the i-th video variation, matched by position to scriptForIndex(). */
    private function storyboardForIndex(int $i): array
    {
        return $this->storyboards[$i % count($this->storyboards)] ?? [];
    }

    /**
     * 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' ? 3 : 20;

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

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

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

        foreach ($this->selectedRatios as $selectedRatio) {
            if (! in_array($selectedRatio, $allowedRatios, strict: true) && ! ($allowCustomRatio && self::isCustomRatio($selectedRatio))) {
                throw new \InvalidArgumentException('Invalid ratio.');
            }
        }

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

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

        if ($this->type === 'video' && empty($this->selectedScriptIndices)) {
            throw new \InvalidArgumentException('At least one script must be selected.');
        }

        if ($this->type === 'video') {
            $this->voiceForSelectedAvatar($this->voiceId ?? 0);
        }

        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
    {
        // Only the raw fetched/prompted brief is cached (see prefetchBrief()) — formatNotes is
        // applied fresh on every call so it's picked up even if entered after the brief was
        // already prefetched.
        if ($this->brief === null) {
            // Read for whichever source is in play, not for product URLs alone.
            $cachedBrief = Cache::get(PrefetchCreativeBriefJob::cacheKey(
                $this->source,
                $this->source === 'upload' ? '' : $this->rawInput,
                $this->source === 'upload' ? $this->uploadedImagePaths : [],
            ));

            $this->brief = is_array($cachedBrief)
                ? $cachedBrief
                : (match (true) {
                    $this->source === 'product_url' => $briefResolver->fromProductUrl($this->rawInput),
                    $this->source === 'upload' => $briefResolver->fromImages($this->uploadedImagePaths),
                    default => $briefResolver->fromPrompt(
                        $this->rawInput,
                        $this->source === 'competitor' ? $this->competitorImagePaths : [],
                    ),
                })->toArray();

            if ($this->source === 'competitor') {
                $this->discardCompetitorThumbnails();
            }
        }

        $brief = CreativeBrief::fromArray($this->brief);
        $projectName = filled($this->projectName) ? trim($this->projectName) : $brief->projectName;
        $messaging = $brief->messaging;

        if (trim($this->formatNotes) !== '') {
            $messaging = trim($messaging."\nAdditional instructions: ".$this->formatNotes);
        }

        return new CreativeBrief(
            productName: $brief->productName,
            benefits: $brief->benefits,
            features: $brief->features,
            images: $brief->images,
            messaging: $messaging,
            cta: $brief->cta,
            projectName: $projectName,
            visualDescription: $brief->visualDescription,
            handling: $brief->handling,
        );
    }

    public function startOver(): void
    {
        $this->discardCompetitorThumbnails();

        $this->reset([
            'step', 'type', 'source', 'rawInput', 'ratio', 'style', 'selectedStyles',
            'quantity', 'customQuantity', 'videoNotice', 'results',
            'duration', 'formatNotes', 'uploadedImages', 'uploadedImagePaths', 'cloneFromId',
            'customRatioWidth', 'customRatioHeight',
            'brief', 'scriptVariations', 'selectedScriptIndices',
            'avatarId', 'voiceId', 'voicePreviewPaths', 'storyboards',
            'competitorQuery', 'competitorCountry', 'competitorResults', 'competitorSearched',
            'competitorSearchPending', 'competitorApifyRunId', 'competitorSearchBackend', 'selectedCompetitorAdId',
            'competitorImagePaths',
        ]);
        $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,
                'render_progress' => $creative->render_progress,
                'render_stage' => $creative->render_stage,
            ];
        })->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()
            ->with('asset')
            ->where('status', 'completed')
            ->when($this->type, fn ($q) => $q->where('type', $this->type))
            ->latest()
            ->take(20)
            ->get();
    }

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

    #[Computed]
    public function voices(): Collection
    {
        $query = Voice::where('provider', 'gemini')->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[] = match ($this->source) {
            'existing' => 'clone_select',
            'competitor' => 'competitor_search',
            default => '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', 'competitor_search' => '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');
    }
}
