<?php

namespace App\Services\Ai;

/**
 * Static definitions for the AI theme system.
 *
 * 5 themes × 5 layout types with per-role positioning blueprints.
 * Themes mirror the jsd / app-store-screenshots reference: bg gradient
 * (non-inverted screens) + bgAlt solid (inverted showcase/cta screens).
 */
class ThemeRecipes
{
    /**
     * Composition floor (from the reference "_QUALITY_BAR"): a full/centred device
     * should occupy 68–82% of canvas height to read large. We enforce only the
     * 68% MIN as a floor — there's no ceiling because bleeding editorial devices
     * intentionally exceed 82%. See {@see self::applyCompositionGuards()}.
     */
    private const DEVICE_PRESENCE_MIN = 68.0;

    // ── Mood → palette ────────────────────────────────────────────────────────

    /** @return array<string, mixed> */
    public static function palette(string $mood): array
    {
        return match ($mood) {
            // Bold/dynamic — cool crisp white bg, deep charcoal dark, indigo accent
            // Good for: productivity, SaaS, finance, tech tools
            'bold' => [
                'bg'    => ['type' => 'linear', 'angle' => 160, 'stops' => [
                    ['color' => '#F8F8FC', 'offset' => 0],
                    ['color' => '#EDEDF5', 'offset' => 1],
                ]],
                'bgAlt'          => ['type' => 'solid', 'value' => '#12121E'],
                'fg'             => '#12121E',
                'fgAlt'          => '#F8F8FC',
                'accent'         => '#6366F1',
                'muted'          => '#6B7280',
                'frame_iphone'   => 'space-black',
                'frame_ipad'     => 'space-black',
                'frame_android'  => 'dark',
                'frame_iphone_inv'  => 'silver',
                'frame_ipad_inv'    => 'silver',
                'frame_android_inv' => 'real-light',
            ],
            // Dark mode — deep navy bg, light inverted, purple accent
            // Good for: entertainment, gaming, music, night-use tools
            'dark' => [
                'bg'    => ['type' => 'linear', 'angle' => 160, 'stops' => [
                    ['color' => '#0A0F1C', 'offset' => 0],
                    ['color' => '#131B30', 'offset' => 1],
                ]],
                'bgAlt'          => ['type' => 'solid', 'value' => '#F0F4FC'],
                'fg'             => '#E8EEFB',
                'fgAlt'          => '#0A0F1C',
                'accent'         => '#8B5CF6',
                'muted'          => '#7B8BAA',
                'frame_iphone'   => 'silver',
                'frame_ipad'     => 'silver',
                'frame_android'  => 'real-light',
                'frame_iphone_inv'  => 'space-black',
                'frame_ipad_inv'    => 'space-black',
                'frame_android_inv' => 'dark',
            ],
            // Playful/warm — warm peach bg, dark forest green dark, terracotta accent
            // Good for: lifestyle, wellness, food, travel, social apps
            'playful' => [
                'bg'    => ['type' => 'linear', 'angle' => 160, 'stops' => [
                    ['color' => '#FFF3EE', 'offset' => 0],
                    ['color' => '#FFE5D8', 'offset' => 1],
                ]],
                'bgAlt'          => ['type' => 'solid', 'value' => '#1C3028'],
                'fg'             => '#2B1A0F',
                'fgAlt'          => '#FFF3EE',
                'accent'         => '#E8622A',
                'muted'          => '#9C6A52',
                'frame_iphone'   => 'cosmic-orange',
                'frame_ipad'     => 'silver',
                'frame_android'  => 'real-gold',
                'frame_iphone_inv'  => 'silver',
                'frame_ipad_inv'    => 'silver',
                'frame_android_inv' => 'real-light',
            ],
            // Fresh/ocean — cool sky blue bg, deep ocean dark, teal accent
            // Good for: health, meditation, travel, outdoor, weather apps
            'fresh' => [
                'bg'    => ['type' => 'linear', 'angle' => 160, 'stops' => [
                    ['color' => '#E8F4FD', 'offset' => 0],
                    ['color' => '#D6EAF8', 'offset' => 1],
                ]],
                'bgAlt'          => ['type' => 'solid', 'value' => '#0C3A5C'],
                'fg'             => '#0C2340',
                'fgAlt'          => '#E8F4FD',
                'accent'         => '#0284C7',
                'muted'          => '#4A7FA5',
                'frame_iphone'   => 'silver',
                'frame_ipad'     => 'silver',
                'frame_android'  => 'real-light',
                'frame_iphone_inv'  => 'deep-blue',
                'frame_ipad_inv'    => 'space-black',
                'frame_android_inv' => 'dark',
            ],
            // Clean/minimal — warm cream bg, warm near-black, blue accent
            // Good for: editorial, journaling, reading, minimal utility apps
            default => [ // clean
                'bg'    => ['type' => 'linear', 'angle' => 160, 'stops' => [
                    ['color' => '#F6F1EA', 'offset' => 0],
                    ['color' => '#EDE8E0', 'offset' => 1],
                ]],
                'bgAlt'          => ['type' => 'solid', 'value' => '#1A1512'],
                'fg'             => '#171717',
                'fgAlt'          => '#F6F1EA',
                'accent'         => '#5B7CFA',
                'muted'          => '#6B7280',
                'frame_iphone'   => 'silver',
                'frame_ipad'     => 'silver',
                'frame_android'  => 'real-light',
                'frame_iphone_inv'  => 'silver',
                'frame_ipad_inv'    => 'silver',
                'frame_android_inv' => 'real-light',
            ],
        };
    }

    // ── Device frame spec ─────────────────────────────────────────────────────

    /**
     * Returns [presetId, realFrameEnabled, realFrameColor] for the best
     * real-frame device for a given form factor, honoring inversion.
     *
     * @return array{0: int, 1: bool, 2: string|null}
     */
    public static function frameSpec(string $factor, array $palette, bool $inverted): array
    {
        return match ($factor) {
            'iphone_portrait' => [
                41, true,
                $inverted ? $palette['frame_iphone_inv'] : $palette['frame_iphone'],
            ],
            'iphone_landscape' => [
                45, true,
                $inverted ? $palette['frame_iphone_inv'] : $palette['frame_iphone'],
            ],
            'ipad_portrait' => [
                6, true,
                $inverted ? $palette['frame_ipad_inv'] : $palette['frame_ipad'],
            ],
            'ipad_landscape' => [
                27, true,
                $inverted ? $palette['frame_ipad_inv'] : $palette['frame_ipad'],
            ],
            'android_phone_portrait' => [
                58, true,  // Galaxy S26
                $inverted ? $palette['frame_android_inv'] : $palette['frame_android'],
            ],
            'android_phone_landscape' => [
                59, true,
                $inverted ? $palette['frame_android_inv'] : $palette['frame_android'],
            ],
            'android_10_tablet_portrait' => [
                56, true,  // Galaxy Tab S8 Ultra
                $inverted ? $palette['frame_android_inv'] : $palette['frame_android'],
            ],
            'android_10_tablet_landscape' => [
                57, true,
                $inverted ? $palette['frame_android_inv'] : $palette['frame_android'],
            ],
            default => [1, false, null],  // no real frame for unknown / playstore_feature_graphic
        };
    }

    /** Real-frame PNGs render tilted only within ±15°/axis (mirrors REAL_FRAME_TILT_LIMIT). */
    private const FRAME_TILT_CAP = 12.0;

    /**
     * Visual depth for the device frame so AI screens read as composed scenes,
     * not flat paste-ups: a soft floating drop shadow plus a layout-keyed subtle
     * 3-D tilt. Shadow strength/colour tracks the surface (dark cast on light
     * bg; an accent glow + deeper cast on dark bg so the device lifts off a
     * surface a black shadow can't show against). Centered heroes stay upright;
     * side-offset feature / split compositions get a gentle lean. Tilt is capped
     * at FRAME_TILT_CAP so real-frame PNGs never downgrade to procedural.
     *
     * @param  array<string, mixed>  $palette
     * @return array{tiltY: float, tiltX: float, shadows: array<int, array<string, mixed>>}
     */
    public static function frameDepth(string $layoutType, array $palette, bool $inverted): array
    {
        $accent = is_string($palette['accent'] ?? null) ? $palette['accent'] : '#3B82F6';

        $shadows = $inverted
            ? [
                // Accent glow halo lifts the device off the dark surface…
                ['color' => $accent, 'blur' => 130, 'offsetX' => 0, 'offsetY' => 0, 'opacity' => 0.26],
                // …over a deep cast for grounded depth.
                ['color' => '#000000', 'blur' => 70, 'offsetX' => 0, 'offsetY' => 46, 'opacity' => 0.45],
            ]
            : [
                // Soft floating cast…
                ['color' => '#0B1220', 'blur' => 96, 'offsetX' => 0, 'offsetY' => 52, 'opacity' => 0.20],
                // …plus a tight contact shadow so it doesn't look pasted.
                ['color' => '#0B1220', 'blur' => 30, 'offsetX' => 0, 'offsetY' => 10, 'opacity' => 0.14],
            ];

        // Layout-keyed lean. Centered / device-top compositions stay upright;
        // side-offset features get a gentle turn toward their text column.
        $tiltY = match ($layoutType) {
            'lb_hero'                         => -10.0,
            'split_left', 'feature'           => 7.0,
            'split_right', 'editorial_feature' => -7.0,
            'showcase', 'highlight'           => 5.0,
            default                           => 0.0,
        };
        $tiltY = max(-self::FRAME_TILT_CAP, min(self::FRAME_TILT_CAP, $tiltY));

        return ['tiltY' => $tiltY, 'tiltX' => 0.0, 'shadows' => $shadows];
    }

    // ── Screen sequence ───────────────────────────────────────────────────────

    /**
     * Returns the ordered layout types for N screens.
     *
     * Rhythm: hero → [feature → showcase → highlight]* → cta
     * Inverted screens (showcase, cta) give visual contrast to the rhythm.
     *
     * @return string[]
     */
    /** @return string[] All layout types the AI art director may choose from. */
    public static function validLayouts(): array
    {
        return [
            'hero', 'feature', 'showcase', 'highlight', 'cta',
            'hero_device_top', 'feature_device_top', 'cta_text', 'highlight_full',
            'split_left', 'split_right', 'editorial_feature', 'lb_hero',
        ];
    }

    public static function sequence(int $n): array
    {
        if ($n <= 0) {
            return [];
        }
        if ($n === 1) {
            return ['hero'];
        }
        if ($n === 2) {
            return ['hero', 'cta'];
        }

        $middle   = [];
        $midTypes = ['feature', 'showcase', 'highlight'];

        for ($i = 0; $i < $n - 2; $i++) {
            $middle[] = $midTypes[$i % count($midTypes)];
        }

        return array_merge(['hero'], $middle, ['cta']);
    }

    // ── Slot definitions (for AI prompt construction) ─────────────────────────

    /**
     * Named text slots that should be filled for a given layout type.
     * These map to the roles used in anchors().
     *
     * @return string[]
     */
    public static function slots(string $layoutType): array
    {
        return match ($layoutType) {
            'hero', 'hero_device_top', 'lb_hero'   => ['headline', 'subhead'],
            'feature', 'feature_device_top'        => ['eyebrow', 'headline'],
            'showcase'                             => ['caption'],
            'highlight'                            => ['stat', 'headline'],
            'highlight_full'                       => ['stat', 'headline', 'subhead'],
            'cta', 'cta_text'                      => ['eyebrow', 'headline', 'subhead'],
            'split_left', 'split_right'            => ['eyebrow', 'headline', 'subhead'],
            'editorial_feature'                    => ['eyebrow', 'headline', 'subhead'],
            default                                => ['headline'],
        };
    }

    // ── Layer blueprints ──────────────────────────────────────────────────────

    /**
     * Returns the ordered list of layer blueprints for a given layout type.
     * Each blueprint has: role, anchors, and optional overrides for font/styling
     * resolved by ThemeGenerator based on the palette + inversion state.
     *
     * Anchors are percentage-based — they work correctly across all form factors
     * since both canvas dimensions and element sizes scale proportionally.
     *
     * @return array<int, array{role: string, anchors: array<string, array<string, mixed>>}>
     */
    public static function anchors(string $layoutType): array
    {
        $fullBg = self::fullCoverageAnchors();
        $cx     = fn (float $top, float $w, float $h) => [
            'top'    => ['type' => 'percent', 'value' => $top],
            'left'   => ['type' => 'percent', 'align' => 'center', 'value' => 50],
            'width'  => ['type' => 'percent', 'value' => $w],
            'height' => ['type' => 'percent', 'value' => $h],
        ];
        $cxcy = fn (float $cy, float $w, float $h) => [
            'top'    => ['type' => 'percent', 'align' => 'center', 'value' => $cy],
            'left'   => ['type' => 'percent', 'align' => 'center', 'value' => 50],
            'width'  => ['type' => 'percent', 'value' => $w],
            'height' => ['type' => 'percent', 'value' => $h],
        ];
        // Left/top-edge anchored (asymmetric layouts).
        $lx = fn (float $top, float $left, float $w, float $h) => [
            'top'    => ['type' => 'percent', 'value' => $top],
            'left'   => ['type' => 'percent', 'value' => $left],
            'width'  => ['type' => 'percent', 'value' => $w],
            'height' => ['type' => 'percent', 'value' => $h],
        ];
        // Device, vertically centred, left-edge anchored (so it can bleed off a side).
        $dev = fn (float $cy, float $left, float $w, float $h) => [
            'top'    => ['type' => 'percent', 'align' => 'center', 'value' => $cy],
            'left'   => ['type' => 'percent', 'value' => $left],
            'width'  => ['type' => 'percent', 'value' => $w],
            'height' => ['type' => 'percent', 'value' => $h],
        ];
        // Device top-anchored + horizontally centred (rises from the bottom, bleeds off).
        $devTop = fn (float $top, float $w, float $h) => [
            'top'    => ['type' => 'percent', 'value' => $top],
            'left'   => ['type' => 'percent', 'align' => 'center', 'value' => 50],
            'width'  => ['type' => 'percent', 'value' => $w],
            'height' => ['type' => 'percent', 'value' => $h],
        ];

        $layout = match ($layoutType) {
            //
            // ── Editorial feature (modeled on the Slow kit): mono numbered kicker →
            //    oversized serif headline → small body, all in a left column; large
            //    device rising from the bottom; wordmark top-right. One strong
            //    composition repeated across the set. ──
            //
            'editorial_feature' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'logo',     'anchors' => $lx(3.5, 79.0, 15.0, 3.0)],
                ['role' => 'eyebrow',  'anchors' => $lx(5.5, 6.0, 88.0,  2.6)],
                ['role' => 'headline', 'anchors' => $lx(9.5, 6.0, 88.0, 17.0)],
                ['role' => 'subhead',  'anchors' => $lx(27.0, 6.0, 88.0,  6.5)],
                ['role' => 'frame',    'anchors' => $devTop(37.0, 88.0, 88.0)],
            ],
            //
            // ── Split left: text column on the LEFT, device bleeding off the RIGHT ──
            //
            'split_left' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'eyebrow',  'anchors' => $lx(32.0, 8.0, 43.0,  3.0)],
                ['role' => 'headline', 'anchors' => $lx(36.0, 8.0, 43.0, 17.5)],
                ['role' => 'subhead',  'anchors' => $lx(55.0, 8.0, 43.0, 13.0)],
                ['role' => 'frame',    'anchors' => $dev(50.0, 52.0, 62.0, 62.0)],
            ],
            //
            // ── Split right: device bleeding off the LEFT, text column on the RIGHT ──
            //
            'split_right' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'frame',    'anchors' => $dev(50.0, -14.0, 62.0, 62.0)],
                ['role' => 'eyebrow',  'anchors' => $lx(32.0, 54.0, 40.0,  3.0)],
                ['role' => 'headline', 'anchors' => $lx(36.0, 54.0, 40.0, 17.5)],
                ['role' => 'subhead',  'anchors' => $lx(55.0, 54.0, 40.0, 13.0)],
            ],
            //
            // ── Hero: small eyebrow label → big headline → subhead → device at bottom ──
            //
            'hero' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'eyebrow',  'anchors' => $cx(8.0,   88.0,  3.0)],
                ['role' => 'headline', 'anchors' => $cx(12.5,  88.0, 10.5)],
                ['role' => 'subhead',  'anchors' => $cx(24.0,  88.0,  7.0)],
                ['role' => 'frame',    'anchors' => $cxcy(71.0, 76.0, 76.0)],
            ],
            //
            // ── Lookbook hero (reference-grade): emphasis headline + subhead in a
            //    left column, a tilted device, and a trust badge at the bottom.
            //    No icon tile (emphasis + badge carry the premium signal). ──
            //
            'lb_hero' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'headline', 'anchors' => $lx(5.0,  6.0, 80.0, 16.0)],
                ['role' => 'subhead',  'anchors' => $lx(21.0, 6.0, 80.0,  6.0)],
                ['role' => 'frame',    'anchors' => $cxcy(61.0, 58.0, 68.0)],
                ['role' => 'badge',    'anchors' => $lx(96.0, 6.0, 78.0,  4.0)],
            ],
            //
            // ── Feature: eyebrow → headline → large device below (no body — a
            //    clean two-line hook over a big, fully-visible device) ──
            //
            'feature' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'eyebrow',  'anchors' => $cx(8.0,   88.0,  3.0)],
                ['role' => 'headline', 'anchors' => $cx(12.5,  88.0, 11.0)],
                ['role' => 'frame',    'anchors' => $cxcy(60.0, 70.0, 70.0)],
            ],
            //
            // ── Showcase (inverted): device dominant → small caption at bottom ──
            //
            'showcase' => [
                ['role' => 'bg',      'anchors' => $fullBg],
                ['role' => 'frame',   'anchors' => $cxcy(46.0, 84.0, 84.0)],
                ['role' => 'caption', 'anchors' => $cx(90.0,  85.0,  6.0)],
            ],
            //
            // ── Highlight (no device): big stat → supporting headline ──
            //
            'highlight' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'stat',     'anchors' => $cxcy(40.0, 80.0, 14.0)],
                ['role' => 'headline', 'anchors' => $cxcy(56.0, 88.0, 14.0)],
            ],
            //
            // ── CTA (inverted): headline → subhead → device at bottom ──
            //
            'cta' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'headline', 'anchors' => $cx(6.0,   88.0,  8.5)],
                ['role' => 'subhead',  'anchors' => $cx(16.5,  88.0,  5.5)],
                ['role' => 'frame',    'anchors' => $cxcy(60.0, 72.0, 72.0)],
            ],
            //
            // ── Hero (device top): large device flush to the TOP (fully visible —
            //    no cut-off bleed), headline + subhead in a compact block just below
            //    it (~2% gap, not a dead band). Mirror of `hero` at the bottom. ──
            //
            'hero_device_top' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'frame',    'anchors' => $cxcy(41.0, 69.0, 69.0)],
                ['role' => 'headline', 'anchors' => $cx(78.0,  88.0, 11.0)],
                ['role' => 'subhead',  'anchors' => $cx(90.0,  88.0,  7.0)],
            ],
            //
            // ── Feature (device top): device fully visible at the TOP (small top
            //    margin), eyebrow + headline in a compact block lower down (~4% gap
            //    to the device, balanced bottom margin). The top-bleed mirror of
            //    `feature` at the bottom — a clean two-line hook, no body. ──
            //
            'feature_device_top' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'frame',    'anchors' => $cxcy(41.0, 70.0, 70.0)],
                ['role' => 'eyebrow',  'anchors' => $cx(80.0,  88.0,  3.0)],
                ['role' => 'headline', 'anchors' => $cx(84.0,  88.0,  9.5)],
            ],
            //
            // ── CTA text (inverted): typographic-only ending, no device ──
            //
            'cta_text' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'eyebrow',  'anchors' => $cxcy(34.0, 88.0,  2.5)],
                ['role' => 'headline', 'anchors' => $cxcy(46.0, 88.0, 15.0)],
                ['role' => 'subhead',  'anchors' => $cxcy(60.0, 88.0,  7.5)],
            ],
            //
            // ── Highlight full: stat + headline + subhead — no device ──
            //
            'highlight_full' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'stat',     'anchors' => $cxcy(36.0, 80.0, 14.0)],
                ['role' => 'headline', 'anchors' => $cxcy(51.0, 88.0, 12.0)],
                ['role' => 'subhead',  'anchors' => $cxcy(62.0, 88.0,  7.0)],
            ],
            //
            // ── Feature graphic (horizontal 1024×500 banner) ──
            //
            'feature_graphic' => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'logo',     'anchors' => [
                    'top'    => ['type' => 'percent', 'align' => 'center', 'value' => 40],
                    'left'   => ['type' => 'percent', 'value' => 8],
                    'width'  => ['type' => 'percent', 'value' => 18],
                    'height' => ['type' => 'percent', 'value' => 45],
                ]],
                ['role' => 'headline', 'anchors' => $cx(14.0, 62.0, 38.0)],
                ['role' => 'subhead',  'anchors' => $cx(58.0, 62.0, 22.0)],
            ],
            //
            default => [
                ['role' => 'bg',       'anchors' => $fullBg],
                ['role' => 'headline', 'anchors' => $cx(5.0,  88.0, 12.0)],
            ],
        };

        return self::applyCompositionGuards($layout);
    }

    /**
     * Composition guardrails distilled from the reference "_QUALITY_BAR":
     *   - A device must read LARGE — a centred/full device occupies at least
     *     {@see self::DEVICE_PRESENCE_MIN}% of canvas height (below that it reads
     *     as a small, empty slide). We enforce a FLOOR only; large bleeding
     *     devices (editorial ~88%) are intentional, so there's no ceiling.
     *   - Side-by-side (split) devices are left-edge anchored to bleed off a side
     *     and are exempt from the full-device floor.
     * Applied to every layout so neither hand-edits nor future/AI-chosen layouts
     * can ship an undersized phone.
     *
     * @param  array<int, array{role:string, anchors: array<string,mixed>}>  $layout
     * @return array<int, array{role:string, anchors: array<string,mixed>}>
     */
    private static function applyCompositionGuards(array $layout): array
    {
        foreach ($layout as $i => $blueprint) {
            if (($blueprint['role'] ?? null) !== 'frame') {
                continue;
            }
            $anchors = $blueprint['anchors'];
            // Only full/centred devices (horizontally centre-anchored) get the
            // presence floor — split devices bleed off a side by design.
            $centred = ($anchors['left']['align'] ?? null) === 'center';
            $h = $anchors['height']['value'] ?? null;
            if ($centred && is_numeric($h) && $h < self::DEVICE_PRESENCE_MIN) {
                $layout[$i]['anchors']['height']['value'] = self::DEVICE_PRESENCE_MIN;
            }
        }

        return $layout;
    }

    // ── Text style definitions ─────────────────────────────────────────────────

    /**
     * Returns [fontFamily, fontWeight, lineHeight, minFontSize, maxFontSize, letterSpacing].
     *
     * @return array{0: string, 1: int, 2: float, 3: int, 4: int, 5: float}
     */
    public static function textStyle(string $role): array
    {
        return match ($role) {
            'headline' => ['Satoshi',  700, 0.96,  60, 120, -1.5],
            'subhead'  => ['Inter',    400, 1.35,  26,  50,  0.0],
            'body'     => ['Inter',    400, 1.50,  22,  36,  0.0],
            'eyebrow'  => ['Inter',    600, 1.00,  20,  30,  2.0],
            'stat'     => ['Satoshi',  800, 0.96,  72, 136, -1.5],
            'caption'  => ['Satoshi',  600, 1.10,  32,  56, -0.5],
            default    => ['Inter',    400, 1.40,  24,  40,  0.0],
        };
    }

    /**
     * Resolve the text color for a given role and inversion state.
     */
    public static function textColor(string $role, bool $inverted, array $palette): string
    {
        if (in_array($role, ['eyebrow', 'stat'], true)) {
            // Contrast-guarded accent for coloured emphasis words (falls back to
            // the brand accent for older palettes without the guard).
            return $palette['accentText'] ?? $palette['accent'];
        }

        return $inverted
            ? $palette['fgAlt']
            : match ($role) {
                'body', 'subhead' => $palette['muted'],
                default           => $palette['fg'],
            };
    }

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

    /** @return array<string, array<string, mixed>> */
    public static function fullCoverageAnchors(): array
    {
        return [
            'top'    => ['type' => 'percent', 'value' => 0],
            'left'   => ['type' => 'percent', 'value' => 0],
            'width'  => ['type' => 'percent', 'value' => 100],
            'height' => ['type' => 'percent', 'value' => 100],
        ];
    }

    /**
     * Universal LANDSCAPE composition: centred text stacked at the TOP, with a
     * large WIDE device rising from the bottom (bleeding off), an accent panel
     * behind it. Modelled on a hand-tuned tablet-landscape layout. One blueprint
     * reused on every landscape factor; superset of roles — the generator only
     * builds the ones a screen actually has. All text is horizontally centred.
     *
     * @return array<int, array{role: string, anchors: array<string, array<string, mixed>>}>
     */
    public static function landscapeAnchors(): array
    {
        // Horizontally-centred block. Text blocks are 90% wide; the whole text
        // stack lives ABOVE the device (which rises from the bottom to ~34% at
        // its tallest — iPhone landscape), so nothing overlaps. Vertical bands
        // are chosen so every co-occurring role set (eyebrow+headline+subhead,
        // stat+headline, caption-alone) stacks without collisions AND every
        // block ends by ~33% (clear of the frame's top edge on all factors).
        $ctr = fn (float $top, float $w, float $h) => [
            'top'    => ['type' => 'percent', 'value' => $top],
            'left'   => ['type' => 'percent', 'align' => 'center', 'value' => 50],
            'width'  => ['type' => 'percent', 'value' => $w],
            'height' => ['type' => 'percent', 'value' => $h],
        ];

        return [
            ['role' => 'bg',       'anchors' => self::fullCoverageAnchors()],
            ['role' => 'logo',     'anchors' => $ctr(2.5,  12.0,  3.5)],  // app icon (not text)
            // Top band — eyebrow (small) and stat (big number) are alternatives,
            // never co-occur, so they share the same start.
            ['role' => 'eyebrow',  'anchors' => $ctr(6.5,  90.0,  4.5)],  // 6.5–11.0
            ['role' => 'stat',     'anchors' => $ctr(6.5,  90.0,  8.0)],  // 6.5–14.5
            ['role' => 'headline', 'anchors' => $ctr(15.0, 90.0, 11.0)],  // 15.0–26.0
            ['role' => 'subhead',  'anchors' => $ctr(26.5, 90.0,  4.85)], // 26.5–31.35
            ['role' => 'body',     'anchors' => $ctr(26.5, 90.0,  6.5)],  // 26.5–33.0
            ['role' => 'caption',  'anchors' => $ctr(16.0, 90.0,  8.0)],  // 16.0–24.0 (alone)
            // Big wide device rising from the bottom, horizontally centred.
            // TOP-EDGE anchored (not centred): the AI aspect-lock
            // (ThemeGenerator::aspectLockFrameAnchors) only shrinks width/height —
            // it never moves the top anchor — so with a start-aligned top every
            // factor's device top lands at the SAME 38.8%, and taller devices
            // (iPhone) simply bleed further off the bottom instead of rising into
            // the text (a centred anchor pushed iPhone's tall device up over the
            // subhead). 95% height is just a "tall enough" ceiling the lock trims.
            ['role' => 'frame',    'anchors' => [
                'top'    => ['type' => 'percent', 'value' => 38.8],
                'left'   => ['type' => 'percent', 'align' => 'center', 'value' => 50],
                'width'  => ['type' => 'percent', 'value' => 80.0],
                'height' => ['type' => 'percent', 'value' => 95.0],
            ]],
        ];
    }
}
