import { Modal } from '@/Components/ui/Modal';
import { Input } from '@/Components/ui/Input';
import { Tooltip } from '@/Components/ui/Tooltip';
import { cn } from '@/lib/cn';
import AI_STYLES from '../../ai/styles/index.json';
import { factorLabel } from '@/editor/factorLabels';
import { useUpgradeModal } from '@/upgrade/useUpgradeModal';
import type { LocaleDto } from '@/types/inertia';
import type { FormFactor } from '@/types/layout';
import { router, usePage } from '@inertiajs/react';
import { Info, Loader2, Monitor, Plus, Smartphone, Sparkles, Tablet, Upload, X } from 'lucide-react';
import {
    useCallback,
    useEffect,
    useRef,
    useState,
    type ChangeEvent,
    type DragEvent,
} from 'react';

type ProjectRow = {
    id: number;
    name: string;
    screens_count: number;
    updated_at: string | null;
    form_factor: FormFactor;
    active_form_factors: FormFactor[];
    default_locale: string;
    active_locales: string[];
    design_tokens?: { bg: string; accent: string } | null;
    is_blank?: boolean;
};

type Props = {
    /** Existing project to generate into. Pass null to create a new blank project first. */
    project: ProjectRow | null;
    onClose: () => void;
    /**
     * When set (from Browse templates → "Fill with AI"), the AI fills THIS exact
     * hand-designed kit instead of auto-picking or designing from scratch. The
     * design-mode step is removed and devices default to the kit's factors.
     */
    forcedKit?: { key: string; name: string; activeFormFactors?: FormFactor[] } | null;
};

type StepId = 'basics' | 'devices' | 'assets';

const STATUS_MESSAGES = [
    'Studying your screenshots…',
    'Art directing the layout…',
    'Writing the copy…',
    'Placing your assets…',
    'Finishing up…',
];

// One uploaded screenshot = one screen, so the deck length follows the uploads
// (no separate count control). Capped at the App Store Connect project ceiling
// (ScreenController::MAX_SCREENS_PER_PROJECT).
const MAX_SHOTS = 10;

// Portrait/landscape/marketing groupings for the device picker
const FACTOR_GROUPS: { label: string; factors: FormFactor[] }[] = [
    { label: 'iPhone',          factors: ['iphone_portrait', 'iphone_landscape'] },
    { label: 'iPad',            factors: ['ipad_portrait', 'ipad_landscape'] },
    { label: 'Android Phone',   factors: ['android_phone_portrait', 'android_phone_landscape'] },
    { label: 'Android Tablet',  factors: ['android_10_tablet_portrait', 'android_10_tablet_landscape'] },
    { label: 'Marketing',       factors: ['playstore_feature_graphic'] },
];

// Orientation suffix for tighter chip labels
function factorChipLabel(factor: FormFactor): string {
    const base = factorLabel(factor);
    if (factor.endsWith('_landscape')) return base + ' (Landscape)';
    if (factor === 'playstore_feature_graphic') return 'Feature Graphic';
    return base;
}

// Orientation-only label for chips shown UNDER a device-family group header
// (the header already names the device, so repeating "iPhone 6.9"" is noise).
function factorOrientationLabel(factor: FormFactor): string {
    if (factor === 'playstore_feature_graphic') return 'Feature Graphic';
    if (factor.endsWith('_landscape')) return 'Landscape';
    if (factor.endsWith('_portrait')) return 'Portrait';
    return factorChipLabel(factor);
}

function factorGroupIcon(groupLabel: string) {
    if (groupLabel === 'iPhone') return Smartphone;
    if (groupLabel === 'Marketing') return Monitor;
    return Tablet;
}

function getCsrfToken(): string {
    return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') ?? '';
}

async function createBlankProject(
    name: string,
    factors: FormFactor[],
    locales: string[],
    defaultLocale: string,
): Promise<{ project_id: number; is_blank: boolean }> {
    const fd = new FormData();
    fd.append('name', name);
    factors.forEach((f) => fd.append('active_form_factors[]', f));
    locales.forEach((l) => fd.append('active_locales[]', l));
    fd.append('default_locale', defaultLocale);
    const res = await fetch('/projects', {
        method: 'POST',
        headers: { 'X-CSRF-TOKEN': getCsrfToken(), Accept: 'application/json', 'X-Return-Json': '1' },
        credentials: 'same-origin',
        body: fd,
    });
    if (!res.ok) {
        const j = (await res.json().catch(() => ({}))) as { message?: string };
        const reason =
            res.status === 402
                ? "You've reached your plan's project limit. Upgrade to create more."
                : res.status === 401 || res.status === 419
                  ? 'Your session expired. Refresh the page and try again.'
                  : "Couldn't create the project. Please try again.";
        throw new Error(j.message ?? reason);
    }
    return res.json() as Promise<{ project_id: number; is_blank: boolean }>;
}

/**
 * Upload ONE screenshot/logo file and return its stored path. Screenshots are
 * staged individually (not crammed into the generate POST) because a multi-
 * device deck can be 40+ files — past PHP's `max_file_uploads` cap (default 20),
 * which silently drops the overflow, corrupts the response, and leaves device
 * frames empty. One file per request keeps every upload well under the cap.
 */
async function stageAssetFile(projectId: number, file: File): Promise<string> {
    const fd = new FormData();
    fd.append('project_id', String(projectId));
    fd.append('file', file);
    const res = await fetch('/ai/stage-asset', {
        method: 'POST',
        headers: {
            'X-CSRF-TOKEN': getCsrfToken(),
            Accept: 'application/json',
            'X-Requested-With': 'XMLHttpRequest',
        },
        credentials: 'same-origin',
        body: fd,
    });
    const j = (await res.json().catch(() => ({}))) as { ok?: boolean; path?: string; message?: string };
    if (!res.ok || !j.ok || !j.path) {
        const reason =
            res.status === 402
                ? (j.message ?? "You've reached your plan's storage limit.")
                : res.status === 401 || res.status === 419
                  ? 'Your session expired. Refresh the page and try again.'
                  : (j.message ?? "Couldn't upload a screenshot. Please try again.");
        throw new Error(reason);
    }
    return j.path;
}

/**
 * Run async `tasks` with a bounded concurrency `limit`, preserving result order.
 * Keeps staging fast without opening dozens of parallel uploads at once.
 */
async function runPooled<T>(tasks: Array<() => Promise<T>>, limit = 4): Promise<T[]> {
    const results = new Array<T>(tasks.length);
    let next = 0;
    async function worker(): Promise<void> {
        while (next < tasks.length) {
            const i = next++;
            results[i] = await tasks[i]();
        }
    }
    await Promise.all(Array.from({ length: Math.min(limit, tasks.length) }, worker));
    return results;
}

// ── Locale search chip ──────────────────────────────────────────────────────

function LocaleChips({
    locales,
    selected,
    defaultCode,
    onToggle,
}: {
    locales: LocaleDto[];
    selected: string[];
    defaultCode: string;
    onToggle: (code: string) => void;
}) {
    const [query, setQuery] = useState('');
    const filtered = locales.filter(
        (l) =>
            l.name.toLowerCase().includes(query.toLowerCase()) ||
            l.native_name.toLowerCase().includes(query.toLowerCase()) ||
            l.code.toLowerCase().includes(query.toLowerCase()),
    );

    return (
        <div className="space-y-2">
            {/* Selected chips */}
            <div className="flex flex-wrap gap-1.5">
                {selected.map((code) => {
                    const locale = locales.find((l) => l.code === code);
                    const isDefault = code === defaultCode;
                    const canRemove = selected.length > 1;
                    return (
                        <span
                            key={code}
                            className={cn(
                                'inline-flex h-7 items-center gap-1.5 rounded-full border px-2 text-[11px] font-medium',
                                isDefault
                                    ? 'border-accent/40 bg-accent/10 text-foreground'
                                    : 'border-border bg-background text-foreground',
                            )}
                        >
                            <span>{locale?.native_name ?? code}</span>
                            {isDefault && (
                                <span className="rounded-sm bg-accent/15 px-1 text-[9px] uppercase tracking-wider text-accent">
                                    default
                                </span>
                            )}
                            {!isDefault && canRemove && (
                                <button
                                    type="button"
                                    onClick={() => onToggle(code)}
                                    className="ml-0.5 inline-flex h-4 w-4 items-center justify-center rounded-full text-muted-foreground hover:bg-muted hover:text-foreground"
                                    aria-label={`Remove ${locale?.name ?? code}`}
                                >
                                    <X className="h-3 w-3" strokeWidth={2.5} />
                                </button>
                            )}
                        </span>
                    );
                })}

                {/* Add locale popover trigger */}
                <div className="relative">
                    <Input
                        placeholder="Add locale…"
                        value={query}
                        onChange={(e) => setQuery(e.target.value)}
                        className="h-7 w-28 rounded-full px-3 text-[11px]"
                    />
                    {query.length > 0 && filtered.length > 0 && (
                        <div className="absolute left-0 top-8 z-50 max-h-40 w-48 overflow-y-auto rounded-lg border border-border bg-card py-1 shadow-lg">
                            {filtered.slice(0, 20).map((l) => (
                                <button
                                    key={l.code}
                                    type="button"
                                    onClick={() => {
                                        onToggle(l.code);
                                        setQuery('');
                                    }}
                                    className={cn(
                                        'flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors hover:bg-muted',
                                        selected.includes(l.code) && 'text-muted-foreground line-through',
                                    )}
                                >
                                    <span className="font-medium">{l.native_name}</span>
                                    <span className="font-mono text-[10px] text-muted-foreground">
                                        {l.code}
                                    </span>
                                </button>
                            ))}
                        </div>
                    )}
                </div>
            </div>
        </div>
    );
}

// ── Screenshots (multiselect → ordered list) ────────────────────────────────

type Shot = { file: File; url: string };

/** Multiselect screenshot uploader: pick many at once → ordered thumbnail strip
 *  (one screen per shot). Tiles are removable and drag-to-reorder; order = screen
 *  order. Capped at `max` (the App Store Connect per-project screen ceiling). */
function ScreenshotMultiUploader({
    shots,
    max,
    onAdd,
    onRemove,
    onReorder,
}: {
    shots: Shot[];
    max: number;
    onAdd: (files: File[]) => void;
    onRemove: (index: number) => void;
    onReorder: (from: number, to: number) => void;
}) {
    const inputRef = useRef<HTMLInputElement>(null);
    const dragFrom = useRef<number | null>(null);
    const [dragging, setDragging] = useState(false);
    const [overIdx, setOverIdx] = useState<number | null>(null);
    const full = shots.length >= max;

    return (
        <div className="space-y-2">
            {shots.length > 0 && (
                <div className="grid grid-cols-5 gap-2">
                    {shots.map((s, i) => (
                        <div
                            key={s.url}
                            draggable
                            onDragStart={(e) => {
                                dragFrom.current = i;
                                e.dataTransfer.effectAllowed = 'move';
                            }}
                            onDragOver={(e) => {
                                // Only intercept tile-reorder drags (not file drops).
                                if (dragFrom.current !== null) {
                                    e.preventDefault();
                                    setOverIdx(i);
                                }
                            }}
                            onDragLeave={() => setOverIdx((p) => (p === i ? null : p))}
                            onDrop={(e) => {
                                if (dragFrom.current === null) return;
                                e.preventDefault();
                                e.stopPropagation();
                                if (dragFrom.current !== i) onReorder(dragFrom.current, i);
                                dragFrom.current = null;
                                setOverIdx(null);
                            }}
                            onDragEnd={() => {
                                dragFrom.current = null;
                                setOverIdx(null);
                            }}
                            className={cn(
                                'group relative aspect-[9/19] cursor-move overflow-hidden rounded-lg border bg-muted/20 transition-shadow',
                                overIdx === i ? 'border-accent ring-2 ring-accent/40' : 'border-border',
                            )}
                        >
                            <img src={s.url} alt="" className="pointer-events-none h-full w-full object-cover" />
                            <span className="absolute left-1 top-1 rounded bg-black/55 px-1 text-[9px] font-medium text-white">
                                {i + 1}
                            </span>
                            <button
                                type="button"
                                onClick={() => onRemove(i)}
                                className="absolute right-1 top-1 flex h-5 w-5 items-center justify-center rounded-full bg-black/60 text-white hover:bg-black/80"
                            >
                                <X className="h-3 w-3" />
                            </button>
                        </div>
                    ))}
                </div>
            )}
            {! full && (
                <div
                    role="button"
                    tabIndex={0}
                    className={cn(
                        'flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-5 text-center transition-colors cursor-pointer',
                        dragging
                            ? 'border-accent bg-accent/5'
                            : 'border-border bg-muted/10 hover:border-accent/40 hover:bg-muted/20',
                    )}
                    onDragEnter={() => setDragging(true)}
                    onDragLeave={() => setDragging(false)}
                    onDragOver={(e) => e.preventDefault()}
                    onDrop={(e: DragEvent<HTMLDivElement>) => {
                        e.preventDefault();
                        setDragging(false);
                        // Snapshot to an array — a live FileList empties once the
                        // event settles, before the deferred setState reads it.
                        if (e.dataTransfer.files.length) onAdd(Array.from(e.dataTransfer.files));
                    }}
                    onClick={() => inputRef.current?.click()}
                    onKeyDown={(e) => e.key === 'Enter' && inputRef.current?.click()}
                >
                    <Upload className="h-5 w-5 text-muted-foreground" strokeWidth={1.5} />
                    <p className="text-sm text-foreground">
                        {shots.length > 0 ? 'Add more' : 'Drop screenshots or'}{' '}
                        <span className="text-accent">browse</span>
                    </p>
                    <p className="text-[11px] text-muted-foreground">
                        Select multiple · PNG or JPG · {max - shots.length} slot{max - shots.length !== 1 ? 's' : ''} left
                    </p>
                </div>
            )}
            <input
                ref={inputRef}
                type="file"
                accept="image/png,image/jpeg"
                multiple
                className="sr-only"
                onChange={(e: ChangeEvent<HTMLInputElement>) => {
                    // Snapshot BEFORE resetting value — `e.target.value = ''`
                    // empties the live FileList the deferred setState would read.
                    const picked = e.target.files ? Array.from(e.target.files) : [];
                    e.target.value = '';
                    if (picked.length) onAdd(picked);
                }}
            />
        </div>
    );
}

/** A radio-card for the create-form design-style picker. */
function StyleCard({
    active,
    onClick,
    title,
    desc,
}: {
    active: boolean;
    onClick: () => void;
    title: string;
    desc: string;
}) {
    return (
        <button
            type="button"
            onClick={onClick}
            aria-pressed={active}
            className={cn(
                'rounded-lg border px-3 py-2 text-left transition-colors',
                active ? 'border-accent bg-accent/10' : 'border-border bg-card hover:bg-muted/40',
            )}
        >
            <div className="text-[12px] font-medium text-foreground">{title}</div>
            <div className="text-[11px] leading-tight text-muted-foreground">{desc}</div>
        </button>
    );
}

// ── Main component ──────────────────────────────────────────────────────────

export default function AiGenerateModal({ project, onClose, forcedKit = null }: Props) {
    const { locales = [] } = usePage<{ locales?: LocaleDto[] }>().props;
    const openUpgrade = useUpgradeModal((s) => s.openModal);

    const isNewProject = project === null;
    const isBlank = isNewProject || project.is_blank === true;

    // AI generation always fills a hand-designed kit — the one the user picked,
    // or AI auto-selects the best fit when none is forced. The kit IS the design,
    // so there's no style/design-mode step. (The autonomous from-scratch path is
    // kept in the backend but no longer surfaced — see AiController.)
    const steps: StepId[] = isNewProject
        ? ['basics', 'devices', 'assets']
        : ['basics', 'assets'];

    const [stepIdx, setStepIdx] = useState(0);
    const stepId = steps[stepIdx];
    const isLastStep = stepIdx === steps.length - 1;

    // ── Step: basics ─────────────────────────────────────────────────────────
    const [projectName, setProjectName] = useState('');
    const [description, setDescription] = useState('');
    // Design intent for the blank-create flow:
    //   'match' = art-director derives palette/decoration/layout from the uploaded
    //             screenshots (the on-brand default);
    //   'kit'   = AI fills a hand-designed premium kit;
    //   <slug>  = from-scratch design in a named style (palette still derived from
    //             the screenshots — the style only sets the treatment).
    const [design, setDesign] = useState<'match' | 'kit' | string>('match');

    // ── Step: devices (new project mode only) ────────────────────────────────
    // Default to iPhone 6.9" portrait only (the primary factor) — the user adds
    // more devices deliberately. Even on the kit-fill flow we don't pre-check
    // every factor the kit ships; generating all of them by default is slow and
    // rarely wanted up front.
    const [selectedFactors, setSelectedFactors] = useState<FormFactor[]>(['iphone_portrait']);
    const [selectedLocales, setSelectedLocales] = useState<string[]>(['en']);
    const defaultLocale = selectedLocales[0] ?? 'en';

    function toggleFactor(factor: FormFactor) {
        setSelectedFactors((prev) => {
            if (prev.includes(factor)) {
                return prev.length > 1 ? prev.filter((f) => f !== factor) : prev;
            }
            return [...prev, factor];
        });
    }

    function toggleLocale(code: string) {
        setSelectedLocales((prev) => {
            if (prev.includes(code)) {
                return prev.length > 1 ? prev.filter((l) => l !== code) : prev;
            }
            return [...prev, code];
        });
    }

    // ── Step: assets ─────────────────────────────────────────────────────────
    const [logo, setLogo] = useState<File | null>(null);
    const [logoPreview, setLogoPreview] = useState<string | null>(null);
    const [logoDragging, setLogoDragging] = useState(false);
    const logoInputRef = useRef<HTMLInputElement>(null);


    // Screenshot mode: shared (one set, AI resizes to every device) vs per-device.
    const [sameForAll, setSameForAll] = useState(true);

    // Ordered screenshot lists (one screen per shot, in order), keyed by
    // `${scope}:${locale}` where scope = 'shared' or a device factor. Per-locale
    // so localized decks can ship different captures; the feature graphic is
    // never a screenshot slot (it's a marketing banner).
    const [shotLists, setShotLists] = useState<Record<string, Shot[]>>({});


    // ── Generation state ──────────────────────────────────────────────────────
    const [generating, setGenerating] = useState(false);
    const [statusIdx, setStatusIdx] = useState(0);
    const [error, setError] = useState<{
        message: string;
        reason?: string;
        status?: number;
    } | null>(null);
    const errorRef = useRef<HTMLDivElement>(null);
    const statusTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);

    // Bring the error banner into view — it renders near the bottom of a long
    // scrollable form, so a submit failure would otherwise be off-screen.
    // `block: 'nearest'` scrolls only the minimum needed within the Modal.Body
    // scroll container; `'center'` over-scrolled (the error sits near the end,
    // so centering forced ancestor scroll that shoved the modal header off-
    // screen and left a blank gap below the footer).
    useEffect(() => {
        if (error) {
            errorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
        }
    }, [error]);

    useEffect(() => {
        if (generating) {
            setStatusIdx(0);
            statusTimerRef.current = setInterval(
                () => setStatusIdx((i) => (i + 1) % STATUS_MESSAGES.length),
                2500,
            );
        } else {
            if (statusTimerRef.current) clearInterval(statusTimerRef.current);
        }
        return () => {
            if (statusTimerRef.current) clearInterval(statusTimerRef.current);
        };
    }, [generating]);

    // Cleanup object URLs on unmount
    useEffect(() => {
        return () => {
            if (logoPreview) URL.revokeObjectURL(logoPreview);
            Object.values(shotLists).forEach((arr) => arr.forEach((s) => URL.revokeObjectURL(s.url)));
        };
        // eslint-disable-next-line react-hooks/exhaustive-deps
    }, []);

    // ── Logo handlers ─────────────────────────────────────────────────────────
    const handleLogoFile = useCallback((file: File) => {
        if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) return;
        if (file.size > 2 * 1024 * 1024) return;
        setLogo(file);
        const url = URL.createObjectURL(file);
        setLogoPreview((prev) => { if (prev) URL.revokeObjectURL(prev); return url; });
    }, []);

    const removeLogo = () => {
        if (logoPreview) URL.revokeObjectURL(logoPreview);
        setLogo(null);
        setLogoPreview(null);
    };

    // ── Screenshot handlers (multiselect → ordered list per key) ───────────────
    // One screenshot per screen; capped at the project ceiling.
    const addShots = useCallback((key: string, files: File[]) => {
        setShotLists((prev) => {
            const cur = prev[key] ?? [];
            const room = MAX_SHOTS - cur.length;
            const add = files
                .filter((f) => ['image/png', 'image/jpeg'].includes(f.type))
                .slice(0, Math.max(0, room))
                .map((f) => ({ file: f, url: URL.createObjectURL(f) }));
            return add.length ? { ...prev, [key]: [...cur, ...add] } : prev;
        });
    }, []);
    const removeShot = useCallback((key: string, i: number) => {
        setShotLists((prev) => {
            const cur = prev[key] ?? [];
            if (cur[i]) URL.revokeObjectURL(cur[i].url);
            return { ...prev, [key]: cur.filter((_, idx) => idx !== i) };
        });
    }, []);
    const reorderShots = useCallback((key: string, from: number, to: number) => {
        setShotLists((prev) => {
            const cur = [...(prev[key] ?? [])];
            if (from < 0 || from >= cur.length || to < 0 || to >= cur.length || from === to) {
                return prev;
            }
            const [moved] = cur.splice(from, 1);
            cur.splice(to, 0, moved);
            return { ...prev, [key]: cur };
        });
    }, []);

    // Which factors to show screenshot slots for
    const activeFactors = isNewProject ? selectedFactors : (project?.active_form_factors ?? ['iphone_portrait']);
    // Locales that get their own screenshot lists.
    const shotsLocales = isNewProject ? selectedLocales : (project?.active_locales ?? ['en']);
    // Factors excluding the feature graphic — it's a marketing banner, never a
    // device screenshot slot (handled separately by AI gen).
    const shotFactors = activeFactors.filter((f) => f !== 'playstore_feature_graphic');

    // ── Validation ────────────────────────────────────────────────────────────
    const basicsValid =
        description.trim().length > 0 &&
        (!isNewProject || projectName.trim().length > 0);

    const devicesValid = selectedFactors.length > 0;

    // App screenshots are the core input — the AI scans them for the brand
    // palette, fills the device frames with them, and runs one screen per shot.
    // Require at least one whenever a DEVICE factor is selected; the only exempt
    // case is a marketing-graphic-only deck (no device frame to fill).
    const hasAnyShot = Object.values(shotLists).some((list) => list.length > 0);
    const assetsValid = shotFactors.length === 0 || hasAnyShot;

    function stepValid(id: StepId): boolean {
        if (id === 'basics') return basicsValid;
        if (id === 'devices') return devicesValid;
        return assetsValid;
    }

    // ── Generate ─────────────────────────────────────────────────────────────
    const handleGenerate = async () => {
        // Generating into an existing project that already has authored content
        // REPLACES every screen. Blank projects (fresh scaffold) generate
        // silently; anything the user has actually built gets an explicit,
        // un-undoable-action confirm so a gallery mis-click can't wipe a deck.
        if (
            !isNewProject &&
            project &&
            project.is_blank !== true &&
            project.screens_count > 0 &&
            !window.confirm(
                `This replaces the ${project.screens_count} screen${project.screens_count === 1 ? '' : 's'} in “${project.name}” with a new AI-generated set. This can’t be undone. Continue?`,
            )
        ) {
            return;
        }

        setError(null);
        setGenerating(true);

        let projectId: number;
        if (isNewProject) {
            try {
                const created = await createBlankProject(
                    projectName.trim(),
                    selectedFactors,
                    selectedLocales,
                    defaultLocale,
                );
                projectId = created.project_id;
            } catch (err) {
                setError({ message: err instanceof Error ? err.message : 'Could not create project.' });
                setGenerating(false);
                return;
            }
        } else {
            projectId = project.id;
        }

        const fd = new FormData();
        fd.append('project_id', String(projectId));
        // App name is derived from the project name — for a new project that's
        // the name typed above; for an existing one it's the project's own name.
        // (The dedicated "App name" field was removed; the description carries
        // the product detail the art director actually needs.)
        const appName = (isNewProject ? projectName.trim() : project?.name ?? '').trim();
        fd.append('app_name', appName || 'My App');
        fd.append('app_description', description.trim());
        if (isBlank) {
            if (forcedKit || design === 'kit') {
                // AI fills a hand-designed kit — the one the user picked, or AI
                // auto-selects the best fit when generating without a forced kit.
                fd.append('template_source', 'kit');
                fd.append('kit_key', forcedKit ? forcedKit.key : 'auto');
            } else if (design === 'match') {
                // Art-director derives the whole design from the screenshots.
                fd.append('template_source', 'generate');
            } else {
                // Named style → from-scratch design in that treatment (palette
                // still derived from the screenshots, server-side).
                fd.append('template_source', 'generate');
                fd.append('named_style', design);
            }
        }

        // Stage the logo + every screenshot individually (see stageAssetFile),
        // then hand generate the stored PATHS — never a 40+-file POST that trips
        // PHP's max_file_uploads cap. Shared mode stores one set (resolveScreenshot
        // falls back to 'shared' for every factor, and the frame fills it, so no
        // server-side per-factor resize is needed); per-device stores per factor.
        try {
            if (logo) {
                fd.append('staged_logo', await stageAssetFile(projectId, logo));
            }
            if (sameForAll || shotFactors.length <= 1) {
                for (const locale of shotsLocales) {
                    const shots = shotLists[`shared:${locale}`] ?? [];
                    const paths = await runPooled(shots.map((s) => () => stageAssetFile(projectId, s.file)));
                    paths.forEach((p, i) => fd.append(`staged_screenshots[${locale}][${i}]`, p));
                }
            } else {
                for (const factor of shotFactors) {
                    for (const locale of shotsLocales) {
                        const shots = shotLists[`${factor}:${locale}`] ?? [];
                        const paths = await runPooled(shots.map((s) => () => stageAssetFile(projectId, s.file)));
                        paths.forEach((p, i) => fd.append(`staged_device_screenshots[${factor}][${locale}][${i}]`, p));
                    }
                }
            }
        } catch (err) {
            setError({ message: err instanceof Error ? err.message : 'Could not upload your screenshots. Please try again.' });
            setGenerating(false);
            return;
        }

        try {
            const res = await fetch('/ai/generate', {
                method: 'POST',
                // Accept + X-Requested-With mark this as an AJAX/JSON request so
                // Laravel returns 422 JSON (with validation errors) and 401 JSON
                // instead of REDIRECTING (302) to a page the fetch can't parse.
                headers: {
                    'X-CSRF-TOKEN': getCsrfToken(),
                    'Accept': 'application/json',
                    'X-Requested-With': 'XMLHttpRequest',
                },
                credentials: 'same-origin',
                body: fd,
            });

            const json = await res.json().catch(() => ({})) as {
                ok?: boolean;
                project_id?: number;
                message?: string;
                reason?: string;
                errors?: Record<string, string[]>;
            };

            if (res.ok && json.ok && json.project_id) {
                router.visit(`/editor/${json.project_id}`);
                return;
            }

            // Surface the real cause in plain language — never a raw HTTP code.
            // Validation errors (422) carry a field message; everything else
            // maps its status to a human sentence the user can act on.
            const firstError = json.errors
                ? Object.values(json.errors)[0]?.[0]
                : undefined;

            let fallback: string;
            if (res.ok) {
                // 2xx that still isn't the success shape — the project may have
                // been created even though we couldn't read the result. Point
                // the user at their projects rather than implying total failure.
                fallback =
                    "Generation finished, but we couldn't open the result automatically. Check your Projects — it may already be there.";
            } else if (res.status === 401 || res.status === 419) {
                fallback =
                    'Your session expired. Refresh the page, sign in again, and retry.';
            } else if (res.status === 402) {
                fallback =
                    "You've reached a plan or monthly AI limit. Upgrade your plan or wait for your quota to reset.";
            } else if (res.status === 403) {
                fallback = "AI generation isn't available on this account.";
            } else if (res.status === 409) {
                fallback =
                    'A generation is already running for this project. Wait a moment, then try again.';
            } else if (res.status === 429) {
                fallback =
                    "You've made too many requests in a short time. Take a short break and try again.";
            } else if (res.status >= 500) {
                fallback =
                    'The AI service had a temporary problem. Please try again in a moment.';
            } else {
                fallback = 'Something went wrong while generating. Please try again.';
            }

            setError({
                message: firstError ?? json.message ?? fallback,
                reason: json.reason,
                status: res.status,
            });
        } catch {
            setError({ message: 'Network error — check your connection and try again.' });
        } finally {
            setGenerating(false);
        }
    };

    const handleNext = () => {
        if (isLastStep) {
            void handleGenerate();
        } else {
            setStepIdx((i) => i + 1);
        }
    };

    // ── Render ────────────────────────────────────────────────────────────────
    // Copy adapts to the flow: when filling a hand-designed kit, AI only writes
    // the headlines/body to fit the template (it controls every visual), so the
    // "creative direction / colours / style" framing of the from-scratch path
    // is stale and removed.
    const stepTitles: Record<StepId, string> = {
        basics:  forcedKit ? 'Tell us about your app' : 'Brief the AI',
        devices: 'Devices & locales',
        assets:  'Attach your assets',
    };
    const stepSubtitles: Record<StepId, string> = {
        basics:  forcedKit
            ? `AI writes the headlines and copy to fit the ${forcedKit.name} template — the more you share, the sharper they read.`
            : '',
        devices: 'Pick which devices and languages to generate for.',
        assets:  forcedKit
            ? 'Add your logo and app screenshots — they fill the template’s brand slots and device frames.'
            : 'Add your logo and app screenshots — they fill the chosen template’s brand slots and device frames.',
    };

    return (
        <Modal
            open
            onClose={() => {
                // Block close while a generation is in flight — the request
                // can't be cancelled server-side, so dropping the modal would
                // just orphan the in-progress deck.
                if (!generating) onClose();
            }}
            closeOnEsc={!generating}
            size="lg"
            aria-labelledby="ai-gen-title"
        >
            <Modal.Header
                titleId="ai-gen-title"
                eyebrow={generating ? 'Generating' : `Step ${stepIdx + 1} of ${steps.length}`}
                title={generating ? 'Creating your screenshots' : stepTitles[stepId]}
                subtitle={
                    generating
                        ? 'Hang tight — this usually takes 20–60 seconds.'
                        : stepSubtitles[stepId]
                }
                actions={
                    <button
                        type="button"
                        onClick={onClose}
                        disabled={generating}
                        aria-label="Close"
                        className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40"
                    >
                        <X className="h-4 w-4" />
                    </button>
                }
            />

            <Modal.Body>
                {/* The whole form is hidden (not just disabled) while a
                    generation runs, so nothing is editable mid-flight and the
                    progress panel below stands alone. `contents` keeps the
                    Modal.Body flex gap intact when shown. */}
                <div className={generating ? 'hidden' : 'contents'}>
                {/* ── Basics ── */}
                {stepId === 'basics' && (
                    <div className="space-y-4">
                        {isNewProject && (
                            <div className="space-y-1.5">
                                <label htmlFor="ai-project-name" className="text-[11px] font-medium text-muted-foreground">
                                    Project name <span className="text-destructive">*</span>
                                </label>
                                <Input
                                    id="ai-project-name"
                                    placeholder="e.g. FocusFlow iOS screenshots"
                                    maxLength={120}
                                    value={projectName}
                                    onChange={(e) => setProjectName(e.target.value)}
                                    autoFocus
                                />
                            </div>
                        )}

                        <div className="space-y-1.5">
                            <div className="flex items-end justify-between">
                                <span className="flex items-center gap-1">
                                    <label htmlFor="ai-description" className="text-[11px] font-medium text-muted-foreground">
                                        About your app <span className="text-destructive">*</span>
                                    </label>
                                    <Tooltip multiline side="bottom-start" label={
                                        <div className="w-[240px] space-y-1 text-left font-normal leading-relaxed">
                                            <p className="font-medium">What to write here</p>
                                            <ul className="list-disc space-y-0.5 pl-4 marker:text-background/50">
                                                <li>What the app does, in one plain sentence.</li>
                                                <li>Who it's for (e.g. busy professionals, students).</li>
                                                <li>The features or benefits to highlight — <strong>one per screen</strong>, since each becomes a headline.</li>
                                                <li>The tone of the copy — e.g. <em>calm &amp; reassuring</em>, <em>bold &amp; confident</em>, <em>playful</em>.</li>
                                                <li>Any exact words or phrases the headlines must use.</li>
                                            </ul>
                                            <p className="pt-0.5 text-background/70">Write a brief, not a slogan — describe the product and let the AI craft the headlines.</p>
                                        </div>
                                    }>
                                        <button
                                            type="button"
                                            aria-label="Tips for the best result"
                                            className="inline-flex text-muted-foreground/50 transition-colors hover:text-muted-foreground focus:text-muted-foreground focus:outline-none"
                                        >
                                            <Info className="h-3.5 w-3.5" />
                                        </button>
                                    </Tooltip>
                                </span>
                                <span className={cn(
                                    'text-[11px] tabular-nums',
                                    description.length > 1400 ? 'text-destructive' : 'text-muted-foreground/60',
                                )}>
                                    {description.length}/1500
                                </span>
                            </div>
                            <textarea
                                id="ai-description"
                                placeholder="e.g. FocusFlow is a habit tracker for busy professionals. Highlight the AI habit coach, streak tracking, and daily focus timer. Tone: encouraging and calm."
                                maxLength={1500}
                                rows={5}
                                value={description}
                                autoFocus={!isNewProject}
                                onChange={(e) => setDescription(e.target.value)}
                                className="block w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground shadow-sm transition-colors placeholder:text-muted-foreground/60 focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30 resize-none"
                            />
                        </div>

                        {/* Design style — only when not locked to a specific kit.
                            "Match my app" derives colours/decoration/layout from the
                            uploaded screenshots; a named style sets the treatment
                            (palette still drawn from the app); "Use a kit" fills a
                            hand-designed template. */}
                        {isBlank && !forcedKit && (
                            <div className="space-y-1.5">
                                <label className="text-[11px] font-medium text-muted-foreground">
                                    Design style <span className="text-muted-foreground/50">(optional)</span>
                                </label>
                                <div className="grid grid-cols-2 gap-2">
                                    <StyleCard
                                        active={design === 'match'}
                                        onClick={() => setDesign('match')}
                                        title="Match my app ✦"
                                        desc="Colours, decoration & layout from your screenshots"
                                    />
                                    <StyleCard
                                        active={design === 'kit'}
                                        onClick={() => setDesign('kit')}
                                        title="Use a kit"
                                        desc="Fill an AI-picked premium template"
                                    />
                                    {AI_STYLES.map((s) => (
                                        <StyleCard
                                            key={s.slug}
                                            active={design === s.slug}
                                            onClick={() => setDesign(s.slug)}
                                            title={s.name}
                                            desc={s.description}
                                        />
                                    ))}
                                </div>
                                <p className="text-[11px] text-muted-foreground">
                                    {design === 'match'
                                        ? 'The AI reads your screenshots and designs an on-brand deck around their real colours.'
                                        : design === 'kit'
                                            ? 'AI picks the best-fit kit and writes the copy to match your app.'
                                            : 'Designs from scratch in this style — colours still taken from your screenshots.'}
                                </p>
                            </div>
                        )}
                    </div>
                )}

                {/* ── Devices & locales ── */}
                {stepId === 'devices' && (
                    <div className="space-y-5">
                        {/* Device groups */}
                        <div className="space-y-3">
                            <p className="text-[11px] font-medium text-muted-foreground">
                                Devices
                            </p>
                            <div className="grid grid-cols-2 gap-x-4 gap-y-3">
                            {FACTOR_GROUPS.map((group) => {
                                const Icon = factorGroupIcon(group.label);
                                return (
                                    <div key={group.label} className="space-y-1.5">
                                        <div className="flex items-center gap-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/60">
                                            <Icon className="h-3 w-3" />
                                            <span>{group.label}</span>
                                        </div>
                                        <div className="flex flex-wrap gap-1.5">
                                            {group.factors.map((factor) => {
                                                const active = selectedFactors.includes(factor);
                                                return (
                                                    <button
                                                        key={factor}
                                                        type="button"
                                                        onClick={() => toggleFactor(factor)}
                                                        className={cn(
                                                            'inline-flex h-7 items-center rounded-full border px-3 text-[11px] font-medium transition-all',
                                                            active
                                                                ? 'border-accent/40 bg-accent/10 text-accent'
                                                                : 'border-border bg-background text-muted-foreground hover:border-accent/30 hover:text-foreground',
                                                        )}
                                                    >
                                                        {factorOrientationLabel(factor)}
                                                    </button>
                                                );
                                            })}
                                        </div>
                                    </div>
                                );
                            })}
                            </div>
                        </div>

                        {/* Locales */}
                        {locales.length > 0 && (
                            <div className="space-y-2">
                                <p className="text-[11px] font-medium text-muted-foreground">
                                    Languages
                                </p>
                                <LocaleChips
                                    locales={locales}
                                    selected={selectedLocales}
                                    defaultCode={defaultLocale}
                                    onToggle={toggleLocale}
                                />
                                <p className="text-[11px] text-muted-foreground/60">
                                    AI writes copy in the default language. Add more to set up locale slots.
                                </p>
                            </div>
                        )}
                    </div>
                )}

                {/* ── Assets ── */}
                {stepId === 'assets' && (
                    <div className="space-y-5">
                        {/* Screenshots */}
                        <div className="space-y-3">
                            <div className="sticky top-0 z-10 -mx-6 flex items-start justify-between gap-3 bg-card px-6 py-1">
                                <div>
                                    <p className="text-[11px] font-medium text-muted-foreground">
                                        App screenshots{' '}
                                        {shotFactors.length === 0
                                            ? <span className="text-muted-foreground/50">(optional)</span>
                                            : <span className="text-destructive">*</span>}
                                    </p>
                                    <p className="mt-0.5 text-[11px] text-muted-foreground/60">
                                        Select multiple — one per screen, in order.{' '}
                                        {shotFactors.length > 1 && (sameForAll || shotFactors.length <= 1)
                                            ? 'AI resizes them to every device.'
                                            : 'AI fits them to the device.'}
                                    </p>
                                </div>
                                {shotFactors.length > 1 && (
                                    <label className="flex shrink-0 cursor-pointer items-center gap-1.5 text-[11px] text-muted-foreground">
                                        <input
                                            type="checkbox"
                                            checked={sameForAll}
                                            onChange={(e) => setSameForAll(e.target.checked)}
                                            className="h-3.5 w-3.5 rounded border-border accent-accent"
                                        />
                                        Same for all devices
                                    </label>
                                )}
                            </div>

                            {(sameForAll || shotFactors.length <= 1) ? (
                                /* Shared mode — one uploader per locale */
                                <div className="space-y-3">
                                    {shotsLocales.map((locale) => (
                                        <div key={locale}>
                                            {shotsLocales.length > 1 && (
                                                <p className="mb-1.5 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/60">
                                                    {locales.find((l) => l.code === locale)?.native_name ?? locale}
                                                </p>
                                            )}
                                            <ScreenshotMultiUploader
                                                shots={shotLists[`shared:${locale}`] ?? []}
                                                max={MAX_SHOTS}
                                                onAdd={(files) => addShots(`shared:${locale}`, files)}
                                                onRemove={(i) => removeShot(`shared:${locale}`, i)}
                                                onReorder={(from, to) => reorderShots(`shared:${locale}`, from, to)}
                                            />
                                        </div>
                                    ))}
                                </div>
                            ) : (
                                /* Per-device mode — uploader per device factor × locale */
                                <div className="space-y-4">
                                    {shotFactors.map((factor) => (
                                        <div key={factor}>
                                            <p className="mb-2 text-[10px] font-medium uppercase tracking-wide text-muted-foreground/60">
                                                {factorChipLabel(factor)}
                                            </p>
                                            <div className="space-y-3">
                                                {shotsLocales.map((locale) => (
                                                    <div key={locale}>
                                                        {shotsLocales.length > 1 && (
                                                            <p className="mb-1.5 text-[10px] text-muted-foreground/50">
                                                                {locales.find((l) => l.code === locale)?.native_name ?? locale}
                                                            </p>
                                                        )}
                                                        <ScreenshotMultiUploader
                                                            shots={shotLists[`${factor}:${locale}`] ?? []}
                                                            max={MAX_SHOTS}
                                                            onAdd={(files) => addShots(`${factor}:${locale}`, files)}
                                                            onRemove={(i) => removeShot(`${factor}:${locale}`, i)}
                                                            onReorder={(from, to) => reorderShots(`${factor}:${locale}`, from, to)}
                                                        />
                                                    </div>
                                                ))}
                                            </div>
                                        </div>
                                    ))}
                                </div>
                            )}
                        </div>

                        {/* Logo */}
                        <div className="space-y-1.5">
                            <p className="sticky top-0 z-10 -mx-6 bg-card px-6 py-1 text-[11px] font-medium text-muted-foreground">
                                App logo <span className="text-muted-foreground/50">(optional)</span>
                            </p>
                            {logoPreview ? (
                                <div className="flex items-center gap-3">
                                    <img
                                        src={logoPreview}
                                        alt="Logo preview"
                                        className="h-10 w-10 rounded-lg border border-border object-contain bg-muted/30"
                                    />
                                    <div className="min-w-0">
                                        <p className="truncate text-[13px] text-foreground">{logo?.name}</p>
                                        <button
                                            type="button"
                                            onClick={removeLogo}
                                            className="mt-0.5 text-[11px] text-destructive hover:underline"
                                        >
                                            Remove
                                        </button>
                                    </div>
                                </div>
                            ) : (
                                <div
                                    role="button"
                                    tabIndex={0}
                                    className={cn(
                                        'flex items-center gap-2 rounded-lg border-2 border-dashed px-3 py-2.5 transition-colors cursor-pointer',
                                        logoDragging
                                            ? 'border-accent bg-accent/5'
                                            : 'border-border bg-muted/20 hover:border-accent/50 hover:bg-muted/40',
                                    )}
                                    onDragEnter={() => setLogoDragging(true)}
                                    onDragLeave={() => setLogoDragging(false)}
                                    onDragOver={(e) => e.preventDefault()}
                                    onDrop={(e: DragEvent<HTMLDivElement>) => {
                                        e.preventDefault();
                                        setLogoDragging(false);
                                        const f = e.dataTransfer.files[0];
                                        if (f) handleLogoFile(f);
                                    }}
                                    onClick={() => logoInputRef.current?.click()}
                                    onKeyDown={(e) => e.key === 'Enter' && logoInputRef.current?.click()}
                                >
                                    <Upload className="h-4 w-4 shrink-0 text-muted-foreground" strokeWidth={1.5} />
                                    <span className="text-[13px] text-foreground">
                                        Drop your logo or{' '}
                                        <span className="text-accent">browse</span>
                                    </span>
                                    <span className="ml-auto shrink-0 text-[11px] text-muted-foreground">
                                        PNG/JPG/WebP · 2 MB
                                    </span>
                                </div>
                            )}
                            <input
                                ref={logoInputRef}
                                type="file"
                                accept="image/png,image/jpeg,image/webp"
                                className="sr-only"
                                onChange={(e: ChangeEvent<HTMLInputElement>) => {
                                    const f = e.target.files?.[0];
                                    if (f) handleLogoFile(f);
                                    e.target.value = '';
                                }}
                            />
                        </div>
                    </div>
                )}

                {/* ── Style ── */}
                {/* Error */}
                {error && (
                    <div
                        ref={errorRef}
                        className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive"
                    >
                        {error.status === 402 && error.reason === 'plan' ? (
                            <>
                                AI generation requires a Pro plan.{' '}
                                <button
                                    type="button"
                                    onClick={() => {
                                        onClose();
                                        openUpgrade('ai');
                                    }}
                                    className="font-medium underline underline-offset-2 hover:no-underline"
                                >
                                    Upgrade →
                                </button>
                            </>
                        ) : error.status === 402 && error.reason === 'member_quota' ? (
                            "You've used your AI generation allowance for this workspace. Ask the workspace owner to raise it."
                        ) : error.status === 402 && error.reason === 'pool_quota' ? (
                            'This workspace has used its monthly AI generation quota. It resets at the start of next month — or the workspace owner can raise the limit.'
                        ) : error.status === 402 && error.reason === 'storage' ? (
                            <>
                                You've reached your plan's storage limit.{' '}
                                <button
                                    type="button"
                                    onClick={() => {
                                        onClose();
                                        openUpgrade('general');
                                    }}
                                    className="font-medium underline underline-offset-2 hover:no-underline"
                                >
                                    Upgrade →
                                </button>{' '}
                                or remove some uploads.
                            </>
                        ) : (
                            error.message
                        )}
                    </div>
                )}

                </div>{/* /form (hidden while generating) */}

                {/* Progress — replaces the form while generation runs */}
                {generating && (
                    <div className="flex flex-col items-center gap-4 py-10 text-center">
                        <Loader2 className="h-8 w-8 animate-spin text-accent" />
                        <div className="space-y-1">
                            <p
                                key={statusIdx}
                                className="animate-fade-in text-base font-medium text-foreground"
                            >
                                {STATUS_MESSAGES[statusIdx]}
                            </p>
                            <p className="text-[13px] text-muted-foreground">
                                This usually takes 20–60 seconds. Please keep this window open.
                            </p>
                        </div>
                        <ol className="flex items-center gap-1.5" aria-hidden>
                            {STATUS_MESSAGES.map((_, i) => (
                                <li
                                    key={i}
                                    className={cn(
                                        'h-1.5 rounded-full transition-all',
                                        i === statusIdx
                                            ? 'w-6 bg-accent'
                                            : i < statusIdx
                                              ? 'w-1.5 bg-accent/40'
                                              : 'w-1.5 bg-border',
                                    )}
                                />
                            ))}
                        </ol>
                    </div>
                )}
            </Modal.Body>

            <Modal.Footer>
                {stepIdx > 0 && !generating && (
                    <button
                        type="button"
                        onClick={() => setStepIdx((i) => i - 1)}
                        className="inline-flex h-9 items-center rounded-lg px-4 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
                    >
                        Back
                    </button>
                )}
                <div className="flex-1" />
                <button
                    type="button"
                    onClick={onClose}
                    disabled={generating}
                    className="inline-flex h-9 items-center rounded-lg border border-border px-4 text-sm text-foreground transition-colors hover:bg-muted disabled:pointer-events-none disabled:opacity-50"
                >
                    Cancel
                </button>
                <button
                    type="button"
                    onClick={handleNext}
                    disabled={generating || !stepValid(stepId)}
                    className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-accent px-4 text-sm font-medium text-white transition-colors hover:bg-accent/90 disabled:pointer-events-none disabled:opacity-50"
                >
                    {generating ? (
                        <Loader2 className="h-3.5 w-3.5 animate-spin" />
                    ) : isLastStep ? (
                        <Sparkles className="h-3.5 w-3.5" />
                    ) : (
                        <Plus className="h-3.5 w-3.5" />
                    )}
                    {generating ? 'Generating…' : isLastStep ? 'Generate' : 'Next'}
                </button>
            </Modal.Footer>
        </Modal>
    );
}
