<?php

namespace App\Services\Ai;

use App\Models\DevicePreset;
use Illuminate\Support\Str;

/**
 * Builds Konva-compatible screen layout JSON from a palette + AI content.
 *
 * The design is art-directed for CONSISTENCY: the device sits in the same place
 * across the set (no per-screen size/tilt/position jitter), and a single composed
 * decoration motif (see {@see DecoGenerator}) repeats identically on every screen.
 * Bespoke variation lives BETWEEN projects (palette + deco family from the seed),
 * not between screens of one project. The seed is stored so /ai/shuffle re-rolls
 * the whole look without a model call.
 *
 * Single-factor projects produce v3 layouts; multi-factor projects produce v4
 * envelopes with perFactor entries. The playstore_feature_graphic factor omits
 * the device frame + decoration.
 */
class ThemeGenerator
{
    /** Text roles eligible for left-alignment. */
    private const TEXT_ROLES = ['headline', 'subhead', 'body', 'eyebrow', 'stat', 'caption', 'badge'];

    /**
     * @param  array<string>  $activeFactors
     * @param  array<int, array<string, string>>  $aiScreens  [{index: 0, headline: '', ...}]
     * @param  array<string, array<string, array<int, string>>>  $factorScreenshots  [factor|'shared'][locale][idx] => storagePath
     * @param  array<int, bool>|null  $invertedByPosition  position → dark-bg flag from screenshot luminance
     * @return array<int, array{name: string, layouts: array<string, mixed>, position: int}>
     */
    public function generate(
        string $mood,
        int $screenCount,
        array $activeFactors,
        array $aiScreens,
        ?string $logoPath,
        array $factorScreenshots,
        string $defaultLocale,
        ?array $paletteOverride = null,
        ?array $customSequence = null,
        array $style = [],
        int $seed = 1,
        ?array $invertedByPosition = null,
        ?array $translations = null,
    ): array {
        $palette = ThemeRecipes::palette($mood);
        if ($paletteOverride !== null) {
            $palette = array_merge($palette, $paletteOverride);
        }

        // A design style supplies typography, decoration + alignment; the anchor
        // geometry is shared. Falls back to the default style.
        $style = $style ?: Styles::get(Styles::DEFAULT);

        // Per-deck enrichment plan (style + seed driven): which house-style
        // embellishments this set uses, in what variant. Falls back to the
        // always-on defaults when the caller didn't thread one.
        $enrich = is_array($style['enrich'] ?? null) ? $style['enrich'] : EnrichmentPlan::default();

        $sequence   = $customSequence ?? ThemeRecipes::sequence($screenCount);
        $decoFamily = $style['deco']['family'] ?? 'blobs';
        // Continuous deco field spans the whole set — built once for $screenCount.
        $deco       = new DecoGenerator(
            new SeededRandom($seed),
            $decoFamily,
            $style['deco']['density'] ?? 'medium',
            $screenCount,
            (bool) ($enrich['orbs'] ?? true),
            (bool) ($enrich['sparkles'] ?? true),
        );

        $isMulti = count($activeFactors) > 1;
        $results = [];

        $aiByPos = [];
        foreach ($aiScreens as $s) {
            $idx = (int) ($s['index'] ?? -1);
            if ($idx >= 0) {
                $aiByPos[$idx] = $s;
            }
        }

        foreach ($sequence as $position => $layoutType) {
            // Per-role content as a locale map: ['default' => en, 'fr' => …] from
            // the default copy + AI translations, with the numbered kicker applied
            // per locale.
            $contentByRole = $this->contentByRole($aiByPos[$position] ?? [], $translations, $position, $style);

            // Per-screen inversion is intentionally OFF: the whole set uses ONE
            // background for consistency. The $invertedByPosition arg is kept for
            // signature stability.
            $inverted  = false;

            // Decoration sliced from the continuous strip for THIS screen position
            // (elements overflow into adjacent screens → continuity across the set).
            $decoElements = $decoFamily === 'none' ? [] : $deco->screen($palette, $inverted, $position);

            $layouts = $isMulti
                ? $this->buildV4Envelope($layoutType, $palette, $contentByRole, $activeFactors, $inverted, $position, $logoPath, $factorScreenshots, $defaultLocale, $style, $decoElements)
                : $this->buildV3Layout($layoutType, $palette, $contentByRole, $activeFactors[0], $inverted, $position, $logoPath, $factorScreenshots, $defaultLocale, $style, $decoElements);

            $results[] = [
                'name'     => $this->screenName($layoutType, $position),
                'layouts'  => $layouts,
                'position' => $position,
            ];
        }

        return $results;
    }

    /**
     * Build per-role content as a locale map: ['default' => <default copy>,
     * '<locale>' => <translation>, …] from the screen's default copy + the AI
     * translations, with the numbered editorial kicker applied to every locale.
     *
     * @param  array<string, mixed>  $aiContent  default-locale copy for this screen
     * @param  array<string, array<int, array<string, string>>>|null  $translations  [locale][position][role]
     * @param  array<string, mixed>  $style
     * @return array<string, array<string, string>>  role => (localeMap)
     */
    private function contentByRole(array $aiContent, ?array $translations, int $position, array $style): array
    {
        $number = (bool) ($style['number_eyebrow'] ?? false);
        $out    = [];
        foreach (self::TEXT_ROLES as $role) {
            $def = trim((string) ($aiContent[$role] ?? ''));
            if ($def === '') {
                continue;
            }
            $map = ['default' => $def];
            foreach (($translations ?? []) as $loc => $byPos) {
                $t = trim((string) ($byPos[$position][$role] ?? ''));
                if ($t !== '') {
                    $map[$loc] = $t;
                }
            }
            if ($role === 'eyebrow' && $number) {
                $prefix = sprintf('%02d — ', $position + 1);
                foreach ($map as $k => $v) {
                    $map[$k] = $prefix.$v;
                }
            }
            $out[$role] = $map;
        }

        return $out;
    }

    /**
     * Per-locale screenshot overrides for a frame's `screen.localePaths` (non-default
     * locales that have an uploaded screenshot at this position).
     *
     * @return array<string, string>
     */
    private function screenLocalePaths(array $factorScreenshots, string $factor, string $defaultLocale, int $position): array
    {
        $out = [];
        foreach ([$factor, 'shared'] as $key) {
            foreach (($factorScreenshots[$key] ?? []) as $loc => $idxMap) {
                if ($loc === $defaultLocale || ! is_array($idxMap) || isset($out[$loc])) {
                    continue;
                }
                $p = $idxMap[$position] ?? $idxMap[0] ?? null;
                if (is_string($p) && $p !== '') {
                    $out[$loc] = $p;
                }
            }
        }

        return $out;
    }

    // ── Composition (alignment only — no per-screen randomness) ───────────────

    /**
     * Apply the project-level text alignment to a blueprint's anchors. The
     * background is never touched (it is full-coverage; any shift bares the canvas).
     *
     * @param  array<string, mixed>  $anchors
     * @return array<string, mixed>
     */
    private function applyComposition(array $anchors, string $role, array $style): array
    {
        if ($role === 'bg') {
            return $anchors;
        }

        // Left-align only re-anchors CENTERED text (a centered layout going left).
        // Layouts that already position text explicitly (e.g. split columns) are
        // left untouched so we don't clobber their column placement.
        if (($style['align'] ?? 'center') === 'left'
            && in_array($role, self::TEXT_ROLES, true)
            && (($anchors['left']['align'] ?? null) === 'center')
        ) {
            $a         = $anchors;
            $a['left'] = ['type' => 'percent', 'value' => 7.0];

            return $a;
        }

        return $anchors;
    }

    // ── Accent panel (depth) ──────────────────────────────────────────────────

    /**
     * Derive a color-block panel box that sits BEHIND the device: same center as
     * the frame, a touch wider (so it shows on the device's sides) and shorter
     * (so the device extends above + below it). Reads as a second depth plane —
     * the "device on a platform" look — across every device-bearing layout,
     * regardless of how the frame is anchored (centered / edge / bleed-off).
     *
     * `block`/`soft` size a rect a touch wider + shorter than the device; `glow`
     * sizes a larger ellipse that haloes the whole device (the falloff masks the
     * shape). Returns null for the `none` variant.
     *
     * @param  array<string, mixed>  $frame  frame role anchors (percent)
     * @return array<string, mixed>|null
     */
    private function panelAnchorsFromFrame(array $frame, string $variant): ?array
    {
        if ($variant === 'none') {
            return null;
        }
        $wv = $frame['width']['value'] ?? null;
        $hv = $frame['height']['value'] ?? null;
        if (! is_numeric($wv) || ! is_numeric($hv)) {
            return null;
        }
        $wv = (float) $wv;
        $hv = (float) $hv;

        $left = is_array($frame['left'] ?? null) ? $frame['left'] : [];
        $top  = is_array($frame['top'] ?? null) ? $frame['top'] : [];
        $cxPct = (($left['align'] ?? null) === 'center')
            ? (float) ($left['value'] ?? 50)
            : (float) ($left['value'] ?? 0) + $wv / 2;
        $cyPct = (($top['align'] ?? null) === 'center')
            ? (float) ($top['value'] ?? 50)
            : (float) ($top['value'] ?? 0) + $hv / 2;

        // glow/spotlight = a big soft halo around the device; block/soft/duotone =
        // a tighter rounded block behind it.
        [$panelW, $panelH] = match (true) {
            in_array($variant, ['glow', 'spotlight'], true) => [min(118.0, $wv * 1.5), $hv * 1.2],
            default                                         => [min(94.0, $wv * 1.16), $hv * 0.72],
        };

        return [
            'top'    => ['type' => 'percent', 'align' => 'center', 'value' => round($cyPct, 2)],
            'left'   => ['type' => 'percent', 'align' => 'center', 'value' => round($cxPct, 2)],
            'width'  => ['type' => 'percent', 'value' => round($panelW, 2)],
            'height' => ['type' => 'percent', 'value' => round($panelH, 2)],
        ];
    }

    /**
     * The device-backdrop shape body (id/anchors layered on by the caller), keyed
     * to the enrichment plan's `panel` variant. ALL kept deliberately subtle — the
     * device's own drop shadow does the lifting; the panel just adds a second depth
     * plane without becoming a solid accent slab. The variety (6 polished options)
     * is what makes consecutive generations feel different:
     *   block     — a soft tinted platform + a faint contact shadow.
     *   soft      — the same rect, more translucent + larger radius, no shadow.
     *   duotone   — a colour-shifted platform (panel2 → panel diagonal), no shadow.
     *   glow      — a radial accent halo bleeding out to clear.
     *   spotlight — a tighter, brighter glow lit from behind the device's TOP.
     *   none      — null (no backdrop).
     * Returns null for palettes without panel colours.
     *
     * @param  array<string, mixed>  $palette
     * @return array<string, mixed>|null
     */
    private function accentPanelBody(array $palette, string $variant): ?array
    {
        if ($variant === 'none') {
            return null;
        }
        $panel  = $palette['panel'] ?? null;
        $panel2 = $palette['panel2'] ?? $panel;
        if (! is_string($panel) || $panel === '') {
            return null;
        }

        // Reusable rounded-rect platform (block / soft / duotone differ by tone).
        $rect = fn (int $radius, float $opacity, int $angle, array $shadows): array => [
            'name'  => 'Accent panel',
            'type'  => 'shape',
            'shape' => 'rect',
            'style' => [
                'fill'    => ['type' => 'linear', 'angle' => $angle, 'stops' => [
                    ['color' => $panel2, 'offset' => 0],
                    ['color' => $panel, 'offset' => 1],
                ]],
                'radius'  => $radius,
                'opacity' => $opacity,
                'shadows' => $shadows,
            ],
        ];
        $contactShadow = [['color' => '#0B1220', 'blur' => 70, 'offsetX' => 0, 'offsetY' => 34, 'opacity' => 0.08]];

        return match ($variant) {
            'glow' => [
                'name'  => 'Device glow',
                'type'  => 'shape',
                'shape' => 'ellipse',
                'style' => ['fill' => [
                    'type' => 'radial', 'cx' => 0.5, 'cy' => 0.5, 'radius' => 0.5,
                    'stops' => [
                        ['offset' => 0,   'color' => PaletteFactory::rgba($panel, 0.40)],
                        ['offset' => 0.6, 'color' => PaletteFactory::rgba($panel2, 0.16)],
                        ['offset' => 1,   'color' => PaletteFactory::rgba($panel2, 0.0)],
                    ],
                ], 'opacity' => 1.0],
            ],
            'spotlight' => [
                'name'  => 'Device spotlight',
                'type'  => 'shape',
                'shape' => 'ellipse',
                'style' => ['fill' => [
                    'type' => 'radial', 'cx' => 0.5, 'cy' => 0.36, 'radius' => 0.5,
                    'stops' => [
                        ['offset' => 0,    'color' => PaletteFactory::rgba($panel, 0.46)],
                        ['offset' => 0.45, 'color' => PaletteFactory::rgba($panel2, 0.18)],
                        ['offset' => 1,    'color' => PaletteFactory::rgba($panel2, 0.0)],
                    ],
                ], 'opacity' => 1.0],
            ],
            'duotone' => $rect(140, 0.50, 135, []),
            'soft'    => $rect(160, 0.38, 145, []),
            default   => $rect(120, 0.50, 145, $contactShadow), // block
        };
    }

    /**
     * Trust-badge text body (social proof, lookbook hero). Star ornament in the
     * accent + a qualitative label in muted (no fabricated metrics). The model
     * may supply its own label via the `badge` content role; else a safe default.
     *
     * @param  array<string, mixed>  $palette
     * @param  array<string, mixed>  $style
     * @param  array<string, string>|null  $contentMap
     * @return array<string, mixed>
     */
    private function badgeLayerBody(array $palette, array $style, ?array $contentMap): array
    {
        $accent = is_string($palette['accentText'] ?? null) ? $palette['accentText'] : ($palette['accent'] ?? '#5B7CFA');
        $muted  = is_string($palette['muted'] ?? null) ? $palette['muted'] : '#6B7280';
        $stars  = '★★★★★';
        $label  = trim((string) ($contentMap['default'] ?? '')) ?: 'Trusted by thousands';

        $content = [];
        foreach (($contentMap ?? ['default' => $label]) as $loc => $txt) {
            $t = trim((string) $txt) ?: $label;
            $content[$loc] = $stars.'  '.$t;
        }
        if (! isset($content['default'])) {
            $content['default'] = $stars.'  '.$label;
        }

        $runs = [];
        foreach ($content as $loc => $full) {
            $runs[$loc] = [
                ['text' => $stars.'  ', 'color' => $accent],
                ['text' => mb_substr($full, mb_strlen($stars.'  ')), 'color' => $muted],
            ];
        }

        return [
            'name'             => 'Trust badge',
            'type'             => 'text',
            'align'            => ($style['align'] ?? 'center') === 'left' ? 'left' : 'center',
            'color'            => $muted,
            'fitMode'          => 'shrink',
            'fontSize'         => 46,
            'minFontSize'      => 30,
            'maxFontSize'      => 48,
            'fontFamily'       => $style['text']['eyebrow']['font'] ?? 'Inter',
            'fontWeight'       => 600,
            'lineHeight'       => 1.2,
            'letterSpacing'    => 0.0,
            'content'          => $content,
            'richRuns'         => $runs['default'],
            'richRunsByLocale' => array_diff_key($runs, ['default' => 1]),
        ];
    }

    // ── V3 (single factor) ────────────────────────────────────────────────────

    /**
     * @param  array<int, array{body: array<string,mixed>, rect: array<string,mixed>}>  $decoElements
     * @return array<string, mixed>
     */
    private function buildV3Layout(
        string $layoutType,
        array $palette,
        array $contentByRole,
        string $factor,
        bool $inverted,
        int $position,
        ?string $logoPath,
        array $factorScreenshots,
        string $defaultLocale,
        array $style,
        array $decoElements,
    ): array {
        $dims     = $this->factorDims($factor);
        $isBanner = $factor === 'playstore_feature_graphic';
        $layers   = [];

        // Accent panel behind the device — derived from this layout's frame box.
        $panelVariant = is_string($style['enrich']['panel'] ?? null) ? $style['enrich']['panel'] : 'block';
        $panelLayer = null;
        if (! $isBanner) {
            foreach ($this->blueprintForFactor($layoutType, $factor) as $bp) {
                if ($bp['role'] !== 'frame') {
                    continue;
                }
                // Derive the panel from the ASPECT-LOCKED frame box so it tracks
                // the device's real size (esp. landscape, where the box shrinks).
                [$panelPresetId] = ThemeRecipes::frameSpec($factor, $palette, $inverted);
                $lockedFrame  = $this->aspectLockFrameAnchors($bp['anchors'], $factor, $panelPresetId);
                $panelAnchors = $this->panelAnchorsFromFrame($lockedFrame, $panelVariant);
                $panelBody    = $panelAnchors ? $this->accentPanelBody($palette, $panelVariant) : null;
                if ($panelBody !== null && $panelAnchors !== null) {
                    $panelLayer = array_merge($panelBody, [
                        'id'      => Str::uuid()->toString(),
                        'anchors' => $panelAnchors,
                    ]);
                }
                break;
            }
        }

        foreach ($this->blueprintForFactor($layoutType, $factor) as $bp) {
            $role    = $bp['role'];
            $anchors = $isBanner ? $bp['anchors'] : $this->applyComposition($bp['anchors'], $role, $style);

            $layer = $this->buildSingleLayerV3($role, $anchors, $palette, $contentByRole, $factor, $inverted, $position, $logoPath, $factorScreenshots, $defaultLocale, $style, $layoutType);
            if ($layer === null) {
                continue;
            }
            $layers[] = $layer;

            if ($role === 'bg' && ! $isBanner) {
                // Panel first (behind the ambient deco washes), then deco.
                if ($panelLayer !== null) {
                    $layers[] = $panelLayer;
                }
                foreach ($decoElements as $el) {
                    $layers[] = $this->materializeDecoV3($el, $factor);
                }
            }
        }

        return [
            'version' => 3,
            'canvas'  => ['width' => $dims[0], 'height' => $dims[1]],
            'layers'  => $layers,
        ];
    }

    /**
     * @param  array<string, mixed>  $anchors
     * @return array<string, mixed>|null
     */
    private function buildSingleLayerV3(
        string $role,
        array $anchors,
        array $palette,
        array $contentByRole,
        string $factor,
        bool $inverted,
        int $position,
        ?string $logoPath,
        array $factorScreenshots,
        string $defaultLocale,
        array $style,
        string $layoutType,
    ): ?array {
        $id = Str::uuid()->toString();

        if ($role === 'badge') {
            if (empty($style['badge']) || $position !== 0) {
                return null;
            }
            $body = $this->badgeLayerBody($palette, $style, $contentByRole['badge'] ?? null);

            return array_merge($body, ['id' => $id, 'anchors' => $anchors]);
        }

        if ($role === 'bg') {
            return [
                'id'          => $id,
                'type'        => 'background',
                'fill'        => $this->bgFillForPosition($palette, $position, $inverted, $style),
                'linkBgImage' => true,
                'anchors'     => $anchors,
            ];
        }

        if ($role === 'frame') {
            if ($factor === 'playstore_feature_graphic') {
                return null;
            }
            [$presetId, $realEnabled, $frameColor] = ThemeRecipes::frameSpec($factor, $palette, $inverted);
            $anchors = $this->aspectLockFrameAnchors($anchors, $factor, $presetId);
            $ssPath = $this->resolveScreenshot($factorScreenshots, $factor, $defaultLocale, $position);
            $depth  = ThemeRecipes::frameDepth($layoutType, $palette, $inverted);
            $layer  = [
                'id'               => $id,
                'type'             => 'frame',
                'anchors'          => $anchors,
                'devicePresetId'   => $presetId,
                'realFrameEnabled' => $realEnabled,
                'realFrameColor'   => $frameColor,
                'linkDeviceFamily' => true,
                'tiltY'            => $depth['tiltY'],
                'tiltX'            => $depth['tiltX'],
                'style'            => ['shadows' => $depth['shadows']],
            ];
            if ($ssPath) {
                $layer['screen'] = ['storagePath' => $ssPath, 'fit' => 'fill'];
                $lp = $this->screenLocalePaths($factorScreenshots, $factor, $defaultLocale, $position);
                if ($lp !== []) {
                    $layer['screen']['localePaths'] = $lp;
                }
            }

            return $layer;
        }

        if ($role === 'logo') {
            if (! $logoPath) {
                return null;
            }

            return [
                'id'          => $id,
                'name'        => 'App Icon',
                'type'        => 'image',
                'storagePath' => $logoPath,
                'anchors'     => $anchors,
            ];
        }

        $map = $contentByRole[$role] ?? null;
        if (! is_array($map) || $map === []) {
            return null;
        }

        return array_merge(
            $this->textLayerBody($role, $map, $inverted, $palette, $style),
            ['id' => $id, 'anchors' => $anchors],
        );
    }

    // ── V4 (multi-factor) ─────────────────────────────────────────────────────

    /**
     * @param  array<int, array{body: array<string,mixed>, rect: array<string,mixed>}>  $decoElements
     * @return array<string, mixed>
     */
    private function buildV4Envelope(
        string $layoutType,
        array $palette,
        array $contentByRole,
        array $activeFactors,
        bool $inverted,
        int $position,
        ?string $logoPath,
        array $factorScreenshots,
        string $defaultLocale,
        array $style,
        array $decoElements,
    ): array {
        // Canonical role set + shared bodies from the portrait blueprint; each
        // factor supplies its OWN anchors (portrait layout / universal landscape /
        // banner) so every form factor is composed for its orientation.
        $blueprints   = ThemeRecipes::anchors($layoutType);
        $logoInLayout = $logoPath;
        $layerBlocks  = [];

        $factorRole = [];
        foreach ($activeFactors as $af) {
            $map = [];
            foreach ($this->blueprintForFactor($layoutType, $af) as $b) {
                $map[$b['role']] = $b['anchors'];
            }
            $factorRole[$af] = $map;
        }

        foreach ($blueprints as $bp) {
            $role       = $bp['role'];
            $id         = Str::uuid()->toString();
            $sharedBody = $this->sharedBodyV4($role, $palette, $contentByRole, $inverted, $logoInLayout, $style, $layoutType, $position);
            if ($sharedBody === null) {
                continue;
            }
            $sharedBody['id'] = $id;

            $perFactor = [];
            foreach ($activeFactors as $af) {
                $bpAnchors = $factorRole[$af][$role] ?? null; // role may not exist on this factor (e.g. frame on banner)
                if ($bpAnchors === null) {
                    continue;
                }
                if ($af !== 'playstore_feature_graphic') {
                    $bpAnchors = $this->applyComposition($bpAnchors, $role, $style);
                }
                $pf = $this->perFactorDataV4($role, $bpAnchors, $af, $palette, $inverted, $position, $factorScreenshots, $defaultLocale);
                if ($pf !== null) {
                    $perFactor[$af] = $pf;
                }
            }

            if ($perFactor === [] && $role !== 'bg') {
                continue;
            }

            $sharedBody['perFactor'] = $perFactor;
            $layerBlocks[]           = $sharedBody;

            if ($role === 'bg') {
                // Accent panel behind the device — one shared shape body with
                // per-factor anchors derived from each factor's frame box.
                $panelVariant = is_string($style['enrich']['panel'] ?? null) ? $style['enrich']['panel'] : 'block';
                $panelBody = $this->accentPanelBody($palette, $panelVariant);
                if ($panelBody !== null) {
                    $panelPerFactor = [];
                    foreach ($activeFactors as $af) {
                        $fa = $factorRole[$af]['frame'] ?? null;
                        if ($fa === null || $af === 'playstore_feature_graphic') {
                            continue;
                        }
                        // Track the aspect-locked frame box (see the frame role).
                        [$panelPresetId] = ThemeRecipes::frameSpec($af, $palette, $inverted);
                        $fa = $this->aspectLockFrameAnchors($fa, $af, $panelPresetId);
                        $pa = $this->panelAnchorsFromFrame($fa, $panelVariant);
                        if ($pa !== null) {
                            $panelPerFactor[$af] = ['anchors' => $pa];
                        }
                    }
                    if ($panelPerFactor !== []) {
                        $panelBody['id']        = Str::uuid()->toString();
                        $panelBody['perFactor'] = $panelPerFactor;
                        $layerBlocks[]          = $panelBody;
                    }
                }

                foreach ($decoElements as $el) {
                    $block = $this->materializeDecoV4($el, $activeFactors);
                    if ($block !== null) {
                        $layerBlocks[] = $block;
                    }
                }
            }
        }

        $canvas = [];
        foreach ($activeFactors as $af) {
            $dims        = $this->factorDims($af);
            $canvas[$af] = ['width' => $dims[0], 'height' => $dims[1]];
        }

        return [
            'version' => 4,
            'canvas'  => $canvas,
            'layers'  => $layerBlocks,
        ];
    }

    /**
     * @return array<string, mixed>|null
     */
    private function sharedBodyV4(
        string $role,
        array $palette,
        array $contentByRole,
        bool $inverted,
        ?string $logoPath,
        array $style,
        string $layoutType,
        int $position,
    ): ?array {
        if ($role === 'badge') {
            if (empty($style['badge']) || $position !== 0) {
                return null;
            }

            return $this->badgeLayerBody($palette, $style, $contentByRole['badge'] ?? null);
        }

        if ($role === 'bg') {
            return [
                'type'        => 'background',
                'fill'        => $this->bgFillForPosition($palette, $position, $inverted, $style),
                'linkBgImage' => true,
            ];
        }

        if ($role === 'frame') {
            $depth = ThemeRecipes::frameDepth($layoutType, $palette, $inverted);

            return [
                'type'             => 'frame',
                'linkDeviceFamily' => true,
                'tiltY'            => $depth['tiltY'],
                'tiltX'            => $depth['tiltX'],
                'style'            => ['shadows' => $depth['shadows']],
            ];
        }

        if ($role === 'logo') {
            if (! $logoPath) {
                return null;
            }

            return ['name' => 'App Icon', 'type' => 'image', 'storagePath' => $logoPath];
        }

        $map = $contentByRole[$role] ?? null;
        if (! is_array($map) || $map === []) {
            return null;
        }

        return $this->textLayerBody($role, $map, $inverted, $palette, $style);
    }

    /**
     * @param  array<string, mixed>  $anchors
     * @return array<string, mixed>|null
     */
    private function perFactorDataV4(
        string $role,
        array $anchors,
        string $factor,
        array $palette,
        bool $inverted,
        int $position,
        array $factorScreenshots,
        string $defaultLocale,
    ): ?array {
        if ($role === 'frame') {
            if ($factor === 'playstore_feature_graphic') {
                return null;
            }
            [$presetId, $realEnabled, $frameColor] = ThemeRecipes::frameSpec($factor, $palette, $inverted);
            $anchors = $this->aspectLockFrameAnchors($anchors, $factor, $presetId);
            $ssPath = $this->resolveScreenshot($factorScreenshots, $factor, $defaultLocale, $position);
            $pf     = [
                'anchors'          => $anchors,
                'devicePresetId'   => $presetId,
                'realFrameEnabled' => $realEnabled,
                'realFrameColor'   => $frameColor,
            ];
            if ($ssPath) {
                $pf['screen'] = ['storagePath' => $ssPath, 'fit' => 'fill'];
                $lp = $this->screenLocalePaths($factorScreenshots, $factor, $defaultLocale, $position);
                if ($lp !== []) {
                    $pf['screen']['localePaths'] = $lp;
                }
            }

            return $pf;
        }

        return ['anchors' => $anchors];
    }

    /**
     * The blueprint for a layout on a specific factor, by orientation:
     * feature graphic → banner; landscape factors → universal landscape; portrait
     * factors → the layout's own (portrait) blueprint.
     *
     * @return array<int, array{role: string, anchors: array<string, mixed>}>
     */
    private function blueprintForFactor(string $layoutType, string $factor): array
    {
        if ($factor === 'playstore_feature_graphic') {
            return ThemeRecipes::anchors('feature_graphic');
        }
        if (str_contains($factor, 'landscape')) {
            return ThemeRecipes::landscapeAnchors();
        }

        return ThemeRecipes::anchors($layoutType);
    }

    // ── Per-screen background ─────────────────────────────────────────────────

    /**
     * Background fill for a screen. With `vary_bg` (lookbook) the set rotates a
     * few LUMINANCE-MATCHED surface tints so adjacent screens differ for rhythm
     * (the reference-deck "white / tinted / white" cadence) WITHOUT changing the
     * surface lightness — so the precomputed text + frame colours stay readable
     * (no per-screen inversion needed). Otherwise the single shared bg.
     *
     * @param  array<string, mixed>  $palette
     * @return mixed  a Color (solid/gradient array)
     */
    private function bgFillForPosition(array $palette, int $position, bool $inverted, array $style): mixed
    {
        $base = $inverted ? $palette['bgAlt'] : $palette['bg'];
        if (empty($style['vary_bg'])) {
            return $base;
        }
        $ref = PaletteFactory::surfaceRef(is_array($base) ? ($base['stops'][0]['color'] ?? '#F6F1EA') : $base);
        [, $s, $l] = PaletteFactory::hexToHsl($ref);
        [$ah] = PaletteFactory::hexToHsl(is_string($palette['accent'] ?? null) ? $palette['accent'] : '#5B7CFA');

        // Tint toward the accent hue + a complementary cool/warm neighbour, both
        // at the SAME lightness as the base surface.
        $tintA = PaletteFactory::hslToHex($ah, min(0.16, $s + 0.10), $l);
        $tintB = PaletteFactory::hslToHex(fmod($ah + 28.0, 360.0), min(0.12, $s + 0.06), $l);
        $surfaces = [
            $base,
            ['type' => 'linear', 'angle' => 160, 'stops' => [['color' => $tintA, 'offset' => 0], ['color' => PaletteFactory::darken($tintA, 0.05), 'offset' => 1]]],
            ['type' => 'linear', 'angle' => 160, 'stops' => [['color' => $tintB, 'offset' => 0], ['color' => PaletteFactory::darken($tintB, 0.05), 'offset' => 1]]],
        ];

        return $surfaces[$position % count($surfaces)];
    }

    // ── Text layer body ───────────────────────────────────────────────────────

    /**
     * @param  array<string, mixed>  $style
     * @return array<string, mixed>
     */
    /**
     * @param  array<string, string>  $contentMap  locale => text (incl. 'default')
     */
    private function textLayerBody(string $role, array $contentMap, bool $inverted, array $palette, array $style): array
    {
        // Typography from the chosen design style (falls back to the legacy spec).
        $t = $style['text'][$role] ?? null;
        if ($t !== null) {
            [$family, $weight, $lineH, $minSz, $maxSz, $spacing, $case] = [
                $t['font'], $t['weight'], $t['lineHeight'], $t['min'], $t['max'], $t['spacing'], $t['case'] ?? 'none',
            ];
        } else {
            [$family, $weight, $lineH, $minSz, $maxSz, $spacing] = ThemeRecipes::textStyle($role);
            $case = $role === 'eyebrow' ? 'upper' : 'none';
        }

        // Apply the style's casing to every locale variant.
        $content = [];
        foreach ($contentMap as $loc => $txt) {
            $content[$loc] = match ($case) {
                'upper' => mb_strtoupper($txt),
                'title' => mb_convert_case($txt, MB_CASE_TITLE, 'UTF-8'),
                default => $txt,
            };
        }

        $body = [
            'name'          => ucfirst($role),
            'type'          => 'text',
            'align'         => ($style['align'] ?? 'center') === 'left' ? 'left' : 'center',
            'color'         => ThemeRecipes::textColor($role, $inverted, $palette),
            // 'shrink' = binary-search font size so text always fits its box
            // (height ≤ box) between min/maxFontSize — prevents headline overflow /
            // truncation. ('fit'/'fixed' do NOT shrink to the box; see textFit.ts.)
            'fitMode'       => 'shrink',
            'fontSize'      => $maxSz,
            'minFontSize'   => $minSz,
            'maxFontSize'   => $maxSz,
            'fontFamily'    => $family,
            'fontWeight'    => $weight,
            'lineHeight'    => $lineH,
            'letterSpacing' => $spacing,
            'content'       => $content,
        ];

        // Emphasis: colour (+ optional italic) the headline's last word, the
        // "premium editorial" tell from the reference decks ("Every *Day*"). The
        // runs are built from the CASED content so the renderer's charRenderFunc
        // aligns them to the rendered grapheme stream.
        if ($role === 'headline' && ! empty($style['emphasis'])) {
            $emColor = is_string($palette['accentText'] ?? null) ? $palette['accentText'] : ($palette['accent'] ?? '#5B7CFA');
            $italic  = (bool) ($style['emphasis_italic'] ?? false);
            $runs    = $this->emphasisRuns($content['default'] ?? '', $emColor, $italic);
            if ($runs !== []) {
                $body['richRuns'] = $runs;
            }
            $byLocale = [];
            foreach ($content as $loc => $txt) {
                if ($loc === 'default') {
                    continue;
                }
                $r = $this->emphasisRuns($txt, $emColor, $italic);
                if ($r !== []) {
                    $byLocale[$loc] = $r;
                }
            }
            if ($byLocale !== []) {
                $body['richRunsByLocale'] = $byLocale;
            }
        }

        return $body;
    }

    /**
     * Build rich-text runs that emphasise the LAST whitespace-separated word of
     * `$text` (accent colour + optional italic), leaving the rest default. The
     * concatenated run text equals `$text` exactly so the renderer stays aligned.
     *
     * @return array<int, array<string, mixed>>
     */
    private function emphasisRuns(string $text, string $color, bool $italic): array
    {
        if (trim($text) === '') {
            return [];
        }
        $em  = ['color' => $color] + ($italic ? ['italic' => true] : ['bold' => true]);
        $pos = mb_strrpos($text, ' ');
        if ($pos === false) {
            return [array_merge(['text' => $text], $em)];
        }

        return [
            ['text' => mb_substr($text, 0, $pos + 1)],
            array_merge(['text' => mb_substr($text, $pos + 1)], $em),
        ];
    }

    // ── Decoration materialization ────────────────────────────────────────────

    /**
     * @param  array{body: array<string,mixed>, rect: array<string,mixed>}  $el
     * @return array<string, mixed>
     */
    private function materializeDecoV3(array $el, string $factor): array
    {
        $layer            = $el['body'];
        $layer['id']      = Str::uuid()->toString();
        $layer['anchors'] = $this->decoAnchors($el['rect'], $factor);

        return $layer;
    }

    /**
     * @param  array{body: array<string,mixed>, rect: array<string,mixed>}  $el
     * @param  array<string>  $activeFactors
     * @return array<string, mixed>|null
     */
    private function materializeDecoV4(array $el, array $activeFactors): ?array
    {
        $perFactor = [];
        foreach ($activeFactors as $af) {
            if ($af === 'playstore_feature_graphic') {
                continue;
            }
            $perFactor[$af] = ['anchors' => $this->decoAnchors($el['rect'], $af)];
        }
        if ($perFactor === []) {
            return null;
        }

        $block              = $el['body'];
        $block['id']        = Str::uuid()->toString();
        $block['perFactor'] = $perFactor;

        return $block;
    }

    /**
     * Convert a factor-agnostic deco rect into anchors. `r` is the element's
     * pixel height ÷ width, so circles stay round on each canvas. `mode=center`
     * anchors by centre (the resolver offsets by the computed height), which is
     * how the spotlight stays centred behind the device on every factor.
     *
     * @param  array<string, mixed>  $rect
     * @return array<string, array<string, mixed>>
     */
    private function decoAnchors(array $rect, string $factor): array
    {
        if (($rect['mode'] ?? 'edge') === 'full') {
            return [
                'top'    => ['type' => 'percent', 'value' => 0],
                'left'   => ['type' => 'percent', 'value' => 0],
                'width'  => ['type' => 'percent', 'value' => 100],
                'height' => ['type' => 'percent', 'value' => 100],
            ];
        }

        [$cW, $cH] = $this->factorDims($factor);
        $w         = (float) $rect['w'];
        $hPct      = round($w * ($cW / $cH) * (float) ($rect['r'] ?? 1.0), 2);

        if (($rect['mode'] ?? 'edge') === 'center') {
            return [
                'top'    => ['type' => 'percent', 'align' => 'center', 'value' => (float) $rect['cy']],
                'left'   => ['type' => 'percent', 'align' => 'center', 'value' => (float) $rect['cx']],
                'width'  => ['type' => 'percent', 'value' => $w],
                'height' => ['type' => 'percent', 'value' => $hPct],
            ];
        }

        return [
            'top'    => ['type' => 'percent', 'value' => (float) $rect['top']],
            'left'   => ['type' => 'percent', 'value' => (float) $rect['left']],
            'width'  => ['type' => 'percent', 'value' => $w],
            'height' => ['type' => 'percent', 'value' => $hPct],
        ];
    }

    // ── Screenshot resolution ─────────────────────────────────────────────────

    private function resolveScreenshot(
        array $factorScreenshots,
        string $factor,
        string $defaultLocale,
        int $position,
    ): ?string {
        return $factorScreenshots[$factor][$defaultLocale][$position]
            ?? $factorScreenshots[$factor][$defaultLocale][0]
            ?? $factorScreenshots['shared'][$defaultLocale][$position]
            ?? $factorScreenshots['shared'][$defaultLocale][0]
            ?? null;
    }

    // ── Helpers ───────────────────────────────────────────────────────────────

    private function screenName(string $layoutType, int $position): string
    {
        return match ($layoutType) {
            'hero', 'hero_device_top'       => 'Intro',
            'editorial_feature'            => $position === 0 ? 'Intro' : 'Feature',
            'split_left', 'split_right'     => 'Feature',
            'feature', 'feature_device_top' => 'Feature',
            'showcase'                      => 'Showcase',
            'highlight', 'highlight_full'   => 'Highlight',
            'cta', 'cta_text'               => 'Get Started',
            default                         => 'Screen '.($position + 1),
        };
    }

    /** @return array{0: int, 1: int} [width, height] */
    /** @var array<string, float|null> memo: "presetId:orientation" => device frame aspect (w/h) */
    private array $frameAspectMemo = [];

    /**
     * Device frame PNG aspect (width/height) for a preset + the factor's
     * orientation, read from `real_frame_screen_rect.{orientation}.frame_w/_h`.
     * Memoised. Null when unavailable (no real frame) so callers skip the lock.
     */
    private function frameAspectFor(int $presetId, string $factor): ?float
    {
        $orientation = str_contains($factor, 'landscape') ? 'landscape' : 'portrait';
        $key = $presetId . ':' . $orientation;
        if (array_key_exists($key, $this->frameAspectMemo)) {
            return $this->frameAspectMemo[$key];
        }
        $rect = DevicePreset::find($presetId)?->real_frame_screen_rect[$orientation] ?? null;
        $fw = is_array($rect) ? ($rect['frame_w'] ?? null) : null;
        $fh = is_array($rect) ? ($rect['frame_h'] ?? null) : null;
        $aspect = (is_numeric($fw) && is_numeric($fh) && $fw > 0 && $fh > 0)
            ? (float) $fw / (float) $fh
            : null;

        return $this->frameAspectMemo[$key] = $aspect;
    }

    /**
     * Aspect-lock a frame's anchor box to the device's native frame aspect so
     * the device PNG never distorts. The renderer scales the frame X/Y
     * INDEPENDENTLY to fill its box (LayerNode `rfScaleX`/`rfScaleY`), and the
     * blueprint box aspect follows the CANVAS aspect — which differs from the
     * device aspect (Android phone stretches ~16%, every landscape factor
     * squishes ~40%, iPhone ~6%). We CONTAIN the device inside the blueprint
     * box: shrink whichever dimension is proportionally too large, keeping the
     * anchor's alignment (centred boxes stay centred; edge-anchored keep their
     * edge). Never enlarges, so it can't introduce overlap.
     *
     * @param  array<string, mixed>  $anchors
     * @return array<string, mixed>
     */
    private function aspectLockFrameAnchors(array $anchors, string $factor, int $presetId): array
    {
        $aspect = $this->frameAspectFor($presetId, $factor);
        if ($aspect === null) {
            return $anchors;
        }
        $wv = $anchors['width']['value'] ?? null;
        $hv = $anchors['height']['value'] ?? null;
        if (($anchors['width']['type'] ?? null) !== 'percent'
            || ($anchors['height']['type'] ?? null) !== 'percent'
            || ! is_numeric($wv) || ! is_numeric($hv)) {
            return $anchors;
        }
        [$cw, $ch] = $this->factorDims($factor);
        $boxW = (float) $wv / 100.0 * $cw;
        $boxH = (float) $hv / 100.0 * $ch;
        if ($boxW <= 0.0 || $boxH <= 0.0) {
            return $anchors;
        }
        if ($boxW / $boxH > $aspect) {
            // Box too wide → shrink width to the device aspect.
            $anchors['width']['value'] = round(($boxH * $aspect) / $cw * 100.0, 4);
        } else {
            // Box too tall → shrink height.
            $anchors['height']['value'] = round(($boxW / $aspect) / $ch * 100.0, 4);
        }

        return $anchors;
    }

    private function factorDims(string $factor): array
    {
        return match ($factor) {
            'iphone_portrait'             => [1320, 2868],
            'iphone_landscape'            => [2868, 1320],
            'ipad_portrait'               => [2064, 2752],
            'ipad_landscape'              => [2752, 2064],
            'android_phone_portrait'      => [1080, 1920],
            'android_phone_landscape'     => [1920, 1080],
            'android_10_tablet_portrait'  => [1848, 2960],
            'android_10_tablet_landscape' => [2960, 1848],
            'playstore_feature_graphic'   => [1024,  500],
            default                       => [1080, 1920],
        };
    }
}
