<?php
declare(strict_types=1);

namespace Instinctor\Services;

use Instinctor\Core\DB;

if (!defined('ABSPATH')) { exit; }

/**
 * Personalized instant-demo engine.
 *
 * A visitor types their trade + business name; startDemo() clones the matching
 * cinematic template page, injects the business name into the flagged headline,
 * and returns a signed /demo/<id>-<sig> URL that the /demo/:token frontend route
 * renders anonymously. The demo page stays a DRAFT (owner_id NULL, is_demo=1),
 * so it never resolves in the public /:slug namespace or the sitemap; the signed
 * route sets INSTINCTOR_RENDER_PAGE_ID to bypass the published-page gate.
 *
 * "Make it yours" (a later stage) reassigns owner_id to the visitor's new
 * account, adopting the exact page they built. Templates are hand-built draft
 * pages; each flags its business-name headline with settings.demo_role.
 */
class DemoService
{
    /** Trade slug => template page id (hand-built salon-site draft, demo_role-tagged). */
    private const TEMPLATES = [
        'salon' => 526,
    ];

    /** demo_role feature slot => platform feature_blurbs key. */
    private const FEATURE_MAP = [
        'feature_1' => 'online_booking', 'feature_2' => 'gift_cards',   'feature_3' => 'gallery',  'feature_4' => 'reviews',
        'feature_5' => 'smart_chat',     'feature_6' => 'reminders',    'feature_7' => 'payments', 'feature_8' => 'service_menu',
    ];

    /** Salon preset image pool (webp, served at POOL_BASE/<cat>/<n>.webp, thumbs -sm).
     *  Used to give each demo distinct stylist portraits + gallery, and to feed the
     *  visitor-facing photo swapper (demo-overlay.php). */
    private const POOL = [
        'women'     => [2,3,5,6,7,8,9,11,12,13,15,16,17,19,21,23,24],
        'men'       => [25,28,30,31,32,33,35,36,37,38,39,40,42],
        'nails'     => [44,45,48,50,51,53,55,58,59,60,62,63],
        'brows'     => [64,66,68,70,72,74,76,78,79,80,81,83,84],
        'makeup'    => [86,87,88,89,90,91,92,93,94,96,97,99,100,102],
        'interiors' => [103,105,106,107,110,111,113,114],
    ];
    /** Curated DISTINCT-person subset for TEAM portraits. The full pool has
     *  near-duplicate AI shots of the same model (e.g. women 5 & 6 are the same
     *  person), which read as "the same person" across the 3 team cards. One
     *  image per distinct person here. Sergei 2026-07-13. */
    private const POOL_TEAM = [
        'women' => [2, 5, 7, 9, 12, 13],
        'men'   => [25, 28, 32, 35, 38],
    ];
    private const POOL_BASE = '/storage/uploads/salon-presets/';

    /** Subtype-specific WIDE hero images (light, editorial), served at
     *  POOL_BASE/heroes/<subtype>/<subtype>-<n>.webp (thumbs -sm). Lets each
     *  demo open on a hero that matches the visitor's trade. */
    private const POOL_HEROES = [
        'nails'  => [1, 2, 3],
        'lash'   => [1, 2, 3],
        'brow'   => [1, 2, 3],
        'makeup' => [1, 2, 3],
        'hair'   => [1, 2, 3],
        'spa'    => [1, 2, 3],
    ];

    /** Beauty/salon-family keywords that all map to the salon template for now. */
    private const SALON_FAMILY = [
        'salon', 'hair', 'hairdresser', 'hairstylist', 'stylist', 'barber', 'barbershop',
        'nail', 'nails', 'manicure', 'pedicure', 'lash', 'lashes', 'brow', 'brows',
        'makeup', 'beauty', 'spa', 'facial', 'wax', 'waxing', 'esthetic', 'esthetician', 'tan', 'tanning',
    ];

    /** Resolve a trade slug to its template page id (0 if none). */
    public static function templateFor(string $trade): int
    {
        $trade = strtolower(trim($trade));
        if ($trade === '') $trade = 'salon';                 // launch default
        if (isset(self::TEMPLATES[$trade])) return self::TEMPLATES[$trade];
        // Alias any beauty/salon-family trade (e.g. "nail salon", "barber", "lash bar")
        // to the salon template. The raw trade still flows to generation, so the COPY
        // stays trade-specific even though the structure is the salon layout.
        foreach (self::SALON_FAMILY as $kw) {
            if (strpos($trade, $kw) !== false) return self::TEMPLATES['salon'];
        }
        return 0;
    }

    public static function trades(): array
    {
        return array_keys(self::TEMPLATES);
    }

    /** Full-size URL for a pool image. */
    private static function poolUrl(string $cat, int $num): string
    {
        return self::POOL_BASE . $cat . '/' . $num . '.webp';
    }

    /** Deterministic (seeded) pick of $count distinct full-size URLs from a category. */
    private static function poolPick(string $cat, int $seed, int $count, ?array $numsOverride = null): array
    {
        $nums = $numsOverride ?? (self::POOL[$cat] ?? []);
        if (!$nums) return [];
        $order = array_values($nums);
        $s = ($seed & 0x7fffffff) ?: 1;                 // Fisher-Yates with an LCG (no rand: repeatable per name)
        for ($i = count($order) - 1; $i > 0; $i--) {
            $s = ($s * 1103515245 + 12345) & 0x7fffffff;
            $j = $s % ($i + 1);
            [$order[$i], $order[$j]] = [$order[$j], $order[$i]];
        }
        return array_map(fn($n) => self::poolUrl($cat, $n), array_slice($order, 0, max(0, $count)));
    }

    /** Map a raw trade to a hero subtype (empty = no subtype hero, use interiors). */
    private static function heroSubtypeFor(string $trade): string
    {
        $t = strtolower($trade);
        foreach (['nail' => 'nails', 'lash' => 'lash', 'brow' => 'brow', 'makeup' => 'makeup',
                  'facial' => 'spa', 'spa' => 'spa', 'wax' => 'spa', 'esthetic' => 'spa', 'massage' => 'spa',
                  'hair' => 'hair', 'barber' => 'hair', 'stylist' => 'hair', 'hairdresser' => 'hair'] as $kw => $sub) {
            if (strpos($t, $kw) !== false && !empty(self::POOL_HEROES[$sub])) return $sub;
        }
        return '';
    }

    /** Seeded subtype hero URL, or '' if the subtype has none. */
    private static function heroUrlFor(string $trade, int $seed): string
    {
        $sub = self::heroSubtypeFor($trade);
        if ($sub === '') return '';
        $nums = self::POOL_HEROES[$sub];
        $n = $nums[abs($seed) % count($nums)];
        return self::POOL_BASE . 'heroes/' . $sub . '/' . $sub . '-' . $n . '.webp';
    }

    /**
     * Sibling depth-map URL for a pool image (foo.webp -> foo-depth.png), or ''
     * when the map has not been generated. Powers the always-3D hero: the section
     * renderer mounts the depth-parallax shader when bg_depth_image + bg_depth_map
     * are both set. Depth maps are precomputed once (bin/depth-batch) into the pool.
     */
    private static function depthMapFor(string $imgUrl): string
    {
        if ($imgUrl === '' || !preg_match('#\.webp$#i', $imgUrl)) return '';
        $depthUrl = preg_replace('#\.webp$#i', '-depth.png', $imgUrl);
        $base = rtrim(\Instinctor\Core\Storage::url(''), '/');
        $rel  = ($base !== '' && strpos($depthUrl, $base) === 0)
            ? ltrim(substr($depthUrl, strlen($base)), '/')
            : ltrim((string) wp_parse_url($depthUrl, PHP_URL_PATH), '/');
        return \Instinctor\Core\Storage::exists($rel) ? $depthUrl : '';
    }

    /** Rough first-name gender guess for portrait matching. Unknown -> female
     *  (salon staff skew female, and the pool is mostly women). */
    private static function guessGender(string $name): string
    {
        $first = strtolower(trim((string) (explode(' ', trim($name))[0] ?? '')));
        if ($first === '') return 'unknown';
        static $male = ['james'=>1,'john'=>1,'robert'=>1,'michael'=>1,'david'=>1,'william'=>1,'richard'=>1,'joseph'=>1,'thomas'=>1,'charles'=>1,'daniel'=>1,'matthew'=>1,'anthony'=>1,'mark'=>1,'paul'=>1,'andrew'=>1,'joshua'=>1,'josh'=>1,'kevin'=>1,'brian'=>1,'george'=>1,'edward'=>1,'timothy'=>1,'tim'=>1,'jason'=>1,'jeffrey'=>1,'ryan'=>1,'jacob'=>1,'nicholas'=>1,'eric'=>1,'stephen'=>1,'jonathan'=>1,'justin'=>1,'scott'=>1,'brandon'=>1,'frank'=>1,'benjamin'=>1,'gregory'=>1,'samuel'=>1,'raymond'=>1,'patrick'=>1,'alexander'=>1,'jack'=>1,'tyler'=>1,'aaron'=>1,'jose'=>1,'henry'=>1,'adam'=>1,'nathan'=>1,'zachary'=>1,'carlos'=>1,'marcus'=>1,'antonio'=>1,'luis'=>1,'juan'=>1,'diego'=>1,'miguel'=>1,'javier'=>1,'mateo'=>1,'liam'=>1,'noah'=>1,'ethan'=>1,'mason'=>1,'lucas'=>1,'oliver'=>1,'elijah'=>1,'logan'=>1,'sebastian'=>1,'caleb'=>1,'dylan'=>1,'max'=>1,'leo'=>1,'theo'=>1,'chris'=>1,'christopher'=>1,'peter'=>1,'simon'=>1,'martin'=>1,'victor'=>1,'omar'=>1,'andre'=>1,'derek'=>1,'trevor'=>1,'cole'=>1,'wyatt'=>1,'hunter'=>1,'jackson'=>1,'gabriel'=>1,'ivan'=>1,'nico'=>1,'jamal'=>1,'malik'=>1,'deshawn'=>1,'rashad'=>1,'xavier'=>1,'elias'=>1,'isaiah'=>1,'jayden'=>1,'kai'=>1,'ali'=>1,'hassan'=>1,'mohamed'=>1,'mohammed'=>1,'ahmed'=>1,'yusuf'=>1,'ibrahim'=>1,'santiago'=>1,'rafael'=>1,'emilio'=>1,'marco'=>1,'giovanni'=>1,'dante'=>1,'tobias'=>1,'felix'=>1,'oscar'=>1,'hector'=>1,'ramon'=>1,'cesar'=>1,'pedro'=>1,'alejandro'=>1,'fernando'=>1,'ricardo'=>1,'eduardo'=>1,'sergio'=>1,'angelo'=>1,'dominic'=>1,'xander'=>1,'jaxon'=>1,'brayden'=>1,'cody'=>1,'blake'=>1,'chase'=>1,'brody'=>1,'colton'=>1,'grayson'=>1,'weston'=>1,'malachi'=>1,'amir'=>1,'darius'=>1];
        static $female = ['mary'=>1,'jennifer'=>1,'linda'=>1,'patricia'=>1,'elizabeth'=>1,'susan'=>1,'jessica'=>1,'sarah'=>1,'karen'=>1,'lisa'=>1,'margaret'=>1,'sandra'=>1,'ashley'=>1,'kimberly'=>1,'emily'=>1,'michelle'=>1,'amanda'=>1,'melissa'=>1,'stephanie'=>1,'rebecca'=>1,'becca'=>1,'laura'=>1,'amy'=>1,'angela'=>1,'anna'=>1,'nicole'=>1,'katherine'=>1,'kate'=>1,'katie'=>1,'samantha'=>1,'christine'=>1,'emma'=>1,'olivia'=>1,'ava'=>1,'sophia'=>1,'sofia'=>1,'isabella'=>1,'mia'=>1,'charlotte'=>1,'amelia'=>1,'harper'=>1,'evelyn'=>1,'abigail'=>1,'ella'=>1,'scarlett'=>1,'grace'=>1,'chloe'=>1,'zoe'=>1,'zoey'=>1,'lily'=>1,'nora'=>1,'layla'=>1,'ruby'=>1,'maya'=>1,'aria'=>1,'riley'=>1,'sienna'=>1,'jade'=>1,'ivy'=>1,'luna'=>1,'hazel'=>1,'aurora'=>1,'naomi'=>1,'gabriela'=>1,'valentina'=>1,'camila'=>1,'daniela'=>1,'mara'=>1,'simone'=>1,'renata'=>1,'bianca'=>1,'priya'=>1,'aisha'=>1,'fatima'=>1,'leila'=>1,'yara'=>1,'jasmine'=>1,'brooke'=>1,'paige'=>1,'sydney'=>1,'morgan'=>1,'taylor'=>1,'jordan'=>1,'alexis'=>1,'natalie'=>1,'victoria'=>1,'lauren'=>1,'hannah'=>1,'megan'=>1,'rachel'=>1,'vanessa'=>1,'gabrielle'=>1,'elena'=>1,'nina'=>1,'clara'=>1,'delilah'=>1,'penelope'=>1,'stella'=>1];
        if (isset($male[$first])) return 'male';
        if (isset($female[$first])) return 'female';
        return 'unknown';
    }

    /** Uppercase hero eyebrow: "CITY · TRADE LABEL" (or just the label). */
    private static function tradeEyebrow(string $trade, string $city): string
    {
        $t = strtolower($trade);
        $label = 'BEAUTY STUDIO';
        foreach (['nail' => 'NAIL SALON', 'lash' => 'LASH STUDIO', 'brow' => 'BROW STUDIO',
                  'makeup' => 'MAKEUP STUDIO', 'barber' => 'BARBERSHOP', 'hair' => 'HAIR STUDIO',
                  'salon' => 'HAIR STUDIO', 'spa' => 'DAY SPA', 'facial' => 'SKIN STUDIO',
                  'wax' => 'WAXING STUDIO', 'esthetic' => 'SKIN STUDIO'] as $kw => $lab) {
            if (strpos($t, $kw) !== false) { $label = $lab; break; }
        }
        $city = trim($city);
        return $city !== '' ? strtoupper($city) . ' · ' . $label : $label;
    }

    /** Trade-appropriate gift-card phrase (completes "Give the gift of ___"). */
    private static function giftPhrase(string $trade): string
    {
        $t = strtolower($trade);
        foreach (['nail' => 'a fresh new set', 'lash' => 'a beautiful set of lashes',
                  'brow' => 'perfect brows', 'makeup' => 'a flawless look', 'barber' => 'a sharp new look',
                  'hair' => 'a good hair day', 'salon' => 'a good hair day', 'spa' => 'a day of calm',
                  'facial' => 'glowing skin', 'wax' => 'silky smooth skin', 'esthetic' => 'glowing skin'] as $kw => $ph) {
            if (strpos($t, $kw) !== false) return $ph;
        }
        return 'a little self-care';
    }

    /** Gallery ("recent looks") image URLs tuned to the trade + the space. */
    private static function galleryPhotosFor(string $trade, int $seed): array
    {
        $t = strtolower($trade);
        $map = [
            'nail'   => ['nails', 'nails', 'nails', 'interiors'],
            'lash'   => ['brows', 'makeup', 'brows', 'interiors'],
            'brow'   => ['brows', 'brows', 'makeup', 'interiors'],
            'makeup' => ['makeup', 'makeup', 'makeup', 'interiors'],
            'barber' => ['men', 'men', 'interiors', 'women'],
            'hair'   => ['women', 'women', 'interiors', 'women'],
            'salon'  => ['women', 'women', 'interiors', 'makeup'],
            'spa'    => ['interiors', 'women', 'makeup', 'interiors'],
            'facial' => ['interiors', 'makeup', 'women', 'interiors'],
            'wax'    => ['interiors', 'women', 'makeup', 'interiors'],
        ];
        $cats = ['women', 'makeup', 'interiors', 'women'];
        foreach ($map as $kw => $seq) { if (strpos($t, $kw) !== false) { $cats = $seq; break; } }
        $out = [];
        $counters = [];
        $shuf = [];
        foreach ($cats as $cat) {
            if (!isset($shuf[$cat])) $shuf[$cat] = self::poolPick($cat, $seed + crc32($cat), count(self::POOL[$cat] ?? []));
            if (empty($shuf[$cat])) continue;
            $i = $counters[$cat] = ($counters[$cat] ?? -1) + 1;
            $out[] = $shuf[$cat][$i % count($shuf[$cat])];
        }
        return $out;
    }

    /** Compact per-demo dataset for the "your control panel" preview: business
     *  identity + generated services + team. Read from the cloned demo page. */
    public static function datasetForPage(int $id): array
    {
        $t = DB::prefix() . 'instinctor_pages';
        $row = DB::get_row(DB::prepare("SELECT elements, settings FROM {$t} WHERE id = %d", $id), 'ARRAY_A');
        if (!$row) return [];
        $set = json_decode($row['settings'] ?? '{}', true) ?: [];
        $els = json_decode($row['elements'] ?? '[]', true) ?: [];
        $services = [];
        $team = [];
        foreach ($els as $e) {
            if (($e['settings']['demo_role'] ?? '') === 'booking_form') {
                $services = is_array($e['settings']['demo_services'] ?? null) ? $e['settings']['demo_services'] : [];
                $team     = is_array($e['settings']['demo_staff'] ?? null) ? $e['settings']['demo_staff'] : [];
            }
        }
        return [
            'business' => (string) ($set['demo_business_name'] ?? 'Your Salon'),
            'city'     => (string) ($set['demo_city'] ?? ''),
            'trade'    => (string) ($set['demo_trade'] ?? 'salon'),
            'services' => array_values(array_map(fn($s) => [
                'name'  => (string) ($s['name'] ?? ''),
                'price' => (float) ($s['price'] ?? 0),
            ], array_slice($services, 0, 8))),
            'team'     => array_values(array_map(fn($m) => [
                'name' => (string) ($m['name'] ?? ''),
                'role' => (string) ($m['title'] ?? ''),
            ], array_slice($team, 0, 6))),
        ];
    }

    /** Public manifest for the visitor-facing photo swapper: category => [{full,sm}].
     *  Includes a flattened 'heroes' category (all subtype heroes) so the swapper
     *  can re-pick the hero — the URL's first path segment is 'heroes'. */
    public static function imagePool(): array
    {
        $out = [];
        foreach (self::POOL as $cat => $nums) {
            $out[$cat] = array_map(fn($n) => [
                'full' => self::POOL_BASE . $cat . '/' . $n . '.webp',
                'sm'   => self::POOL_BASE . $cat . '/' . $n . '-sm.webp',
            ], $nums);
        }
        $heroes = [];
        foreach (self::POOL_HEROES as $sub => $nums) {
            foreach ($nums as $n) {
                $heroes[] = [
                    'full' => self::POOL_BASE . 'heroes/' . $sub . '/' . $sub . '-' . $n . '.webp',
                    'sm'   => self::POOL_BASE . 'heroes/' . $sub . '/' . $sub . '-' . $n . '-sm.webp',
                ];
            }
        }
        $out['heroes'] = $heroes;
        return $out;
    }

    /**
     * Clone the trade template, inject the business name, persist a draft demo
     * page. Returns ['id'=>int,'url'=>string] on success, or ['error'=>string].
     */
    public static function startDemo(string $trade, string $name, string $city = '', string $url = ''): array
    {
        $tplId = self::templateFor($trade);
        // Salon funnel: an odd/unmatched trade (e.g. "microblading", "med spa")
        // still gets the salon template rather than hard-failing the demo. The raw
        // trade keeps flowing to generation so the copy stays as specific as it can.
        if ($tplId <= 0) $tplId = self::TEMPLATES['salon'];

        $name = self::cleanName($name);
        if ($name === '') return ['error' => 'empty_name'];
        $city = self::cleanName($city);
        $url  = trim($url);

        $src = PageService::getById($tplId);
        if (!$src) return ['error' => 'template_missing'];

        // Personalized copy from the platform (reads their link / web-searches +
        // LLM). Best-effort: on any failure $gen is [] and the template defaults
        // stand, so the demo never hard-fails on generation.
        $gen = self::callDemoBuild($trade, $name, $city, $url);

        // ── Remap element ids (mirror PageService::duplicate) + inject name ──
        $elements = is_array($src['elements'] ?? null) ? $src['elements'] : [];
        $idMap = [];
        $newEls = [];
        $base = time();
        foreach ($elements as $i => $el) {
            $oldId = $el['id'] ?? null;
            $newId = ($base * 100000) + $i;              // wide gap, collision-safe
            if ($oldId !== null) $idMap[(string) $oldId] = $newId;
            $el['id'] = $newId;
            $newEls[] = $el;
        }
        // Per-demo photos, seeded by name so each business gets distinct faces + gallery
        // (repeatable: same name -> same set). Team portraits are matched to each
        // generated name's gender; the gallery reflects THIS trade's work.
        $seed = crc32($name);
        $womenShuf = self::poolPick('women', $seed, 99, self::POOL_TEAM['women']);   // curated DISTINCT people (no same-person dupes)
        $menShuf   = self::poolPick('men', $seed ^ 0x5f5f5f, 99, self::POOL_TEAM['men']);
        $galleryPhotos = self::galleryPhotosFor($trade, $seed);
        $heroPhoto = self::poolPick('interiors', $seed ^ 0x1234, 1);   // fallback salon space
        $heroUrl   = self::heroUrlFor($trade, $seed);                   // subtype-specific hero, '' if none
        $heroBackdrop = $heroUrl !== '' ? $heroUrl : (string) ($heroPhoto[0] ?? '');  // fixed backdrop in cinematic mode

        foreach ($newEls as &$el) {
            $pid = $el['parent_id'] ?? null;
            if ($pid !== null && $pid !== '' && isset($idMap[(string) $pid])) {
                $el['parent_id'] = $idMap[(string) $pid];
            }
            $role = $el['settings']['demo_role'] ?? '';
            if ($role === '') continue;
            $S = &$el['settings'];
            switch ($role) {
                case 'business_name':                       // hero headline (text)
                    self::setContent($S, self::escName($name));
                    break;
                case 'hero_subhead':                        // text
                    if (!empty($gen['hero_subhead'])) self::setContent($S, self::escName((string) $gen['hero_subhead']));
                    break;
                case 'about_text':                          // text
                    if (!empty($gen['about_text'])) self::setContent($S, self::escName((string) $gen['about_text']));
                    break;
                case 'services':                            // legacy text join (accepts {name,..} objects or plain strings)
                    if (!empty($gen['services']) && is_array($gen['services'])) {
                        $svc = [];
                        foreach (array_slice($gen['services'], 0, 5) as $sv) {
                            $nm = is_array($sv) ? (string) ($sv['name'] ?? '') : (string) $sv;
                            if (trim($nm) !== '') $svc[] = self::escName($nm);
                        }
                        if ($svc) self::setContent($S, implode(' &middot; ', $svc));
                    }
                    break;
                case 'service_list':                        // price_list block — rebuild pl_items from generated services
                    if (!empty($gen['services']) && is_array($gen['services'])) {
                        $items = [];
                        foreach (array_slice($gen['services'], 0, 6) as $sv) {
                            $nm = is_array($sv) ? (string) ($sv['name'] ?? '') : (string) $sv;
                            if (trim($nm) === '') continue;
                            $desc = is_array($sv) ? (string) ($sv['description'] ?? '') : '';
                            // price_list renderer esc_html's every field — store RAW, never escName (double-encodes & and ').
                            $items[] = [
                                'title'       => $nm,
                                'desc'        => $desc,        // editor reads 'desc'
                                'description' => $desc,        // frontend reads 'description'
                                'price'       => is_array($sv) ? (string) ($sv['price'] ?? '') : '',
                                'icon'        => '',
                            ];
                        }
                        if ($items) $S['pl_items'] = wp_json_encode($items);
                    }
                    break;
                case 'team_1':
                case 'team_2':
                case 'team_3':                              // flip_box team card — name+role on front, bio on back
                    $ti = (int) substr($role, 5) - 1;       // team_1 -> index 0
                    if (!empty($gen['team'][$ti]) && is_array($gen['team'][$ti])) {
                        $m  = $gen['team'][$ti];
                        $nm = (string) ($m['name'] ?? '');
                        // flip_box renderer kses's these — store RAW.
                        if (trim($nm) !== '') { $S['front_title'] = $nm; $S['back_title'] = $nm; }
                        if (!empty($m['role'])) $S['front_content'] = (string) $m['role'];
                        if (!empty($m['bio']))  $S['back_content']  = (string) $m['bio'];
                    }
                    // Distinct portrait per card, matched to the name's gender (keeps
                    // the template's dark gradient overlay). Unknown names default to the
                    // trade's likely gender: male for barbershops, female otherwise.
                    $tg = self::guessGender((string) ($S['front_title'] ?? ''));
                    $useMale = ($tg === 'male') || ($tg === 'unknown' && stripos($trade, 'barber') !== false);
                    $glist = ($useMale && $menShuf) ? $menShuf : $womenShuf;
                    if ($glist) {
                        $portrait = $glist[$ti % count($glist)];
                        // Front: portrait with a light foot-gradient (name + role readable).
                        $S['front_bg'] = 'linear-gradient(180deg,rgba(28,22,17,0.05),rgba(28,22,17,0.70)),url(' . $portrait . ') center/cover no-repeat';
                        // Back: the SAME portrait under a heavy dark scrim. With the 'fade'
                        // effect the portrait stays put and the bio appears on the dark overlay
                        // (no flip). Sergei 2026-07-13.
                        $S['back_bg'] = 'linear-gradient(180deg,rgba(20,15,12,0.80),rgba(20,15,12,0.84)),url(' . $portrait . ') center/cover no-repeat';
                        $S['animation_type'] = 'fade';
                    }
                    break;
                case 'gallery':                             // carousel — salon space + work shots
                    if ($galleryPhotos) {
                        $imgs = array_map(fn($u) => ['url' => $u, 'full_url' => $u], $galleryPhotos);
                        $S['images'] = wp_json_encode($imgs);
                    }
                    break;
                case 'hero_bg':                             // hero section background: subtype hero, else the salon space
                    $hb = $heroUrl !== '' ? $heroUrl : ($heroPhoto[0] ?? '');
                    if ($hb !== '') {
                        $S['bg_image'] = $hb;                                   // flat fallback (mobile / no-JS)
                        unset($S['bg_image_tablet'], $S['bg_image_mobile']);   // no stale per-device overrides
                        // Always-3D hero: pair the image with its precomputed depth map so
                        // the section mounts the depth-parallax shader. Falls back to the
                        // flat image when the map is not present.
                        $dm = self::depthMapFor($hb);
                        if ($dm !== '') {
                            $S['bg_depth_image']    = $hb;
                            $S['bg_depth_map']      = $dm;
                            $S['bg_depth_strength'] = 45;
                        } else {
                            unset($S['bg_depth_image'], $S['bg_depth_map']);
                        }
                    }
                    break;
                case 'hero_eyebrow':                        // small caps line above the hero name ("CITY · TRADE")
                    self::setContent($S, self::tradeEyebrow($trade, $city));
                    break;
                case 'gift_headline':                       // gift-card heading — trade-appropriate ("good hair day" etc.)
                    self::setContent($S, 'Give the gift of ' . self::giftPhrase($trade));
                    break;
                case 'gift_cards':                          // text block — reuse the generated gift_cards feature blurb
                    if (!empty($gen['feature_blurbs']['gift_cards']) && is_string($gen['feature_blurbs']['gift_cards'])) {
                        self::setContent($S, self::escName((string) $gen['feature_blurbs']['gift_cards']));
                    }
                    break;
                case 'hours_address':                       // text block — REAL hours + address + phone (Places)
                    $hoursHtml = self::buildHoursHtml($gen);
                    if ($hoursHtml !== '') self::setContent($S, $hoursHtml);
                    break;
                case 'reviews':                             // marquee — REAL Google reviews when Places found them
                    if (!empty($gen['reviews']) && is_array($gen['reviews'])) {
                        $cards = [];
                        foreach (array_slice($gen['reviews'], 0, 6) as $rv) {
                            if (!is_array($rv) || empty($rv['quote'])) continue;
                            // marquee renderer esc_html's card fields — store RAW.
                            $cards[] = [
                                'name'   => (string) ($rv['name'] ?? ''),
                                'role'   => 'Google review',
                                'quote'  => (string) $rv['quote'],
                                'rating' => (int) ($rv['rating'] ?? 5),
                                'row'    => 0,
                            ];
                        }
                        if ($cards) $S['cards'] = wp_json_encode($cards);
                    }
                    $S['frosted_glass'] = 1;   // glass reviews cards read well over the cinematic backdrop
                    break;
                case 'map':                                 // map block — REAL address (Places)
                    if (!empty($gen['address'])) {
                        $addr = (string) $gen['address'];
                        if (array_key_exists('address', $S))       $S['address'] = $addr;
                        if (array_key_exists('address_query', $S)) $S['address_query'] = $addr;
                        if (array_key_exists('map_query', $S))     $S['map_query'] = $addr;
                    }
                    break;
                case 'tagline':                             // hero kinetic line (text field)
                    if (!empty($gen['tagline'])) {
                        $S['text'] = self::escName((string) $gen['tagline']);
                        $S['custom_mode'] = 0;
                        if (isset($S['custom_lines'])) $S['custom_lines'] = '[]';
                    }
                    break;
                case 'cta_label':                           // button label
                    if (!empty($gen['cta_label'])) $S['text'] = self::escName((string) $gen['cta_label']);
                    break;
                case 'booking_form':                        // REAL booking block (was a contact form)
                    // Seed the widget with generated services + team-as-stylists.
                    // booking.php runs these fully client-side on the demo (slots,
                    // staff and submit are stubbed — nothing hits the host store).
                    // Names/prices stored RAW: the renderer esc_html's them.
                    $bkSvcs = [];
                    if (!empty($gen['services']) && is_array($gen['services'])) {
                        foreach (array_slice($gen['services'], 0, 8) as $i => $sv) {
                            $nm = is_array($sv) ? (string) ($sv['name'] ?? '') : (string) $sv;
                            if (trim($nm) === '') continue;
                            $pr = is_array($sv) ? (string) ($sv['price'] ?? '') : '';
                            $bkSvcs[] = [
                                'id'       => 9001 + $i,
                                'name'     => $nm,
                                'duration' => 45 + (($i % 3) * 15),
                                'price'    => (float) preg_replace('/[^0-9.]/', '', $pr),
                                'color'    => '#1d2327',
                                'capacity' => 1,
                                'deposit'  => 0,
                            ];
                        }
                    }
                    if ($bkSvcs) $S['demo_services'] = $bkSvcs;
                    $bkStaff = [];
                    if (!empty($gen['team']) && is_array($gen['team'])) {
                        foreach (array_slice($gen['team'], 0, 6) as $i => $m) {
                            if (!is_array($m)) continue;
                            $mn = (string) ($m['name'] ?? '');
                            if (trim($mn) === '') continue;
                            $bkStaff[] = [
                                'id'        => 8001 + $i,
                                'name'      => $mn,
                                'title'     => (string) ($m['role'] ?? ''),
                                'photo_url' => '',
                            ];
                        }
                    }
                    if ($bkStaff) $S['demo_staff'] = $bkStaff;
                    $S['demo_vertical'] = 'salon';
                    $S['title'] = 'Book at ' . $name;
                    unset($S['fields']);                        // drop leftover contact-form fields
                    break;
            }
            // Feature cards: tailored one-line blurb into each flip-box front_content.
            if (strncmp($role, 'feature_', 8) === 0 && !empty($gen['feature_blurbs']) && is_array($gen['feature_blurbs'])) {
                $fk = self::FEATURE_MAP[$role] ?? '';
                if ($fk !== '' && !empty($gen['feature_blurbs'][$fk])) {
                    $S['front_content'] = self::escName((string) $gen['feature_blurbs'][$fk]);
                }
            }
            unset($S);
        }
        unset($el);

        // ── Settings: keep the template's cinematic config, add demo flags ──
        $settings = is_array($src['settings'] ?? null) ? $src['settings'] : [];
        $settings['is_demo'] = 1;
        $settings['demo_trade'] = $trade;
        $settings['demo_business_name'] = $name;
        $settings['demo_city'] = $city;
        $settings['demo_url'] = $url;
        // The hero image doubles as the FIXED backdrop in cinematic mode: transparent
        // content screens scroll over this pinned image (impulse-page.php reads it).
        $settings['cinematic_backdrop'] = $heroBackdrop;
        // Show our (vendor) chat on the demo so a prospect can ask questions
        // before they claim. Post-claim it becomes their own per-owner chat.
        $settings['chat_visible'] = 1;

        // Raw insert: draft, owner_id NULL, is_demo. Bypasses the free-tier page
        // cap (never applies on the app) and any create()-side hooks (sitemap
        // ping etc.) that a public demo shouldn't trigger.
        $t = DB::prefix() . 'instinctor_pages';
        $slug = 'demo-' . substr(md5($name . '|' . $base . '|' . random_int(1, PHP_INT_MAX)), 0, 12);
        DB::query(DB::prepare(
            "INSERT INTO {$t} (owner_id, name, slug, status, type, elements, settings, created_at, updated_at)
             VALUES (NULL, %s, %s, 'draft', 'page', %s, %s, NOW(), NOW())",
            $name . ' (demo)', $slug, wp_json_encode($newEls), wp_json_encode($settings)
        ));
        $id = (int) DB::get_var('SELECT LAST_INSERT_ID()');
        if ($id <= 0) return ['error' => 'create_failed'];

        return ['id' => $id, 'url' => self::demoUrl($id)];
    }

    // ── Signed token: <id>-<hmac32> ─────────────────────────────────────────
    public static function signToken(int $id): string
    {
        return $id . '-' . substr(hash_hmac('sha256', 'demo:' . $id, self::secret()), 0, 32);
    }

    /** Verify a /demo/:token value; return the page id (>0) or 0. */
    public static function resolveToken(string $token): int
    {
        $token = trim($token);
        if ($token === '' || strpos($token, '-') === false) return 0;
        [$idStr, $sig] = explode('-', $token, 2);
        if (!ctype_digit($idStr)) return 0;
        $id = (int) $idStr;
        $expect = substr(hash_hmac('sha256', 'demo:' . $id, self::secret()), 0, 32);
        if (!hash_equals($expect, (string) $sig)) return 0;

        // Confirm the target is actually a demo page (defense in depth).
        $t = DB::prefix() . 'instinctor_pages';
        $row = DB::get_row(DB::prepare("SELECT settings FROM {$t} WHERE id = %d", $id), 'ARRAY_A');
        if (!$row) return 0;
        $s = json_decode($row['settings'] ?? '[]', true) ?: [];
        return !empty($s['is_demo']) ? $id : 0;
    }

    public static function demoUrl(int $id): string
    {
        return self::baseUrl() . '/demo/' . self::signToken($id);
    }

    // ── helpers ─────────────────────────────────────────────────────────────
    private static function baseUrl(): string
    {
        return defined('INSTINCTOR_STANDALONE_URL')
            ? rtrim(INSTINCTOR_STANDALONE_URL, '/')
            : 'https://instinctor.com';
    }

    private static function secret(): string
    {
        if (defined('INSTINCTOR_PLATFORM_TOKEN') && INSTINCTOR_PLATFORM_TOKEN) return (string) INSTINCTOR_PLATFORM_TOKEN;
        if (defined('AUTH_KEY') && AUTH_KEY) return (string) AUTH_KEY;
        return 'instinctor-demo-fallback-secret';
    }

    /**
     * Ask the platform to generate personalized copy (reads their link or
     * web-searches, then LLM). Returns the content slot map, or [] on any
     * failure so the caller falls back to template defaults.
     */
    private static function callDemoBuild(string $trade, string $name, string $city, string $url): array
    {
        $token = defined('INSTINCTOR_PLATFORM_TOKEN') ? (string) INSTINCTOR_PLATFORM_TOKEN : '';
        if ($token === '') return [];
        $resp = wp_remote_post('https://platform.instinctor.com/api/demo-build', [
            'headers' => ['X-Platform-Token' => $token, 'Content-Type' => 'application/json'],
            'body'    => wp_json_encode(['business_name' => $name, 'city' => $city, 'trade' => $trade, 'url' => $url]),
            'timeout' => 30,
        ]);
        if (function_exists('is_wp_error') && is_wp_error($resp)) return [];
        $body = json_decode((string) wp_remote_retrieve_body($resp), true);
        if (!is_array($body) || empty($body['success']) || empty($body['content']) || !is_array($body['content'])) return [];
        return $body['content'];
    }

    /** Set a text element's content (+ mobile mirror when present). */
    private static function setContent(array &$settings, string $value): void
    {
        $settings['content'] = $value;
        if (array_key_exists('content_mobile', $settings)) $settings['content_mobile'] = $value;
    }

    private static function cleanName(string $name): string
    {
        $name = (string) preg_replace('/\s+/', ' ', wp_strip_all_tags($name));
        return mb_substr(trim($name), 0, 48);
    }

    /**
     * Build the hours_address text-block HTML from real Places hours/address/phone.
     * Returns '' when there's nothing real to show (caller keeps the template default).
     * Mirrors the template's own markup so it drops in seamlessly.
     */
    private static function buildHoursHtml(array $gen): string
    {
        $hours   = (array) ($gen['hours'] ?? []);
        $address = (string) ($gen['address'] ?? '');
        $phone   = (string) ($gen['phone'] ?? '');
        if (!$hours && $address === '') return '';

        $rowStyle = 'display:flex;justify-content:space-between;color:#5a534c;font-size:15px;padding:5px 0;border-bottom:1px solid #eee6db';
        $rows = '';
        foreach ($hours as $line) {
            $parts = explode(': ', (string) $line, 2);
            $day  = self::escName($parts[0] ?? (string) $line);
            $time = self::escName($parts[1] ?? '');
            $rows .= '<div style="' . $rowStyle . '"><span>' . $day . '</span><span>' . $time . '</span></div>';
        }

        $html = '<div style="text-align:left">';
        if ($rows !== '') {
            $html .= '<div style="font-weight:700;color:#2a231e;font-size:16px;margin-bottom:8px">Hours</div>' . $rows;
        }
        if ($address !== '' || $phone !== '') {
            $html .= '<div style="font-weight:700;color:#2a231e;font-size:16px;margin:20px 0 6px">Find us</div>';
            $loc = self::escName($address);
            if ($phone !== '') $loc .= '<br>' . self::escName($phone);
            $html .= '<div style="color:#5a534c;font-size:15px;line-height:1.6">' . $loc . '</div>';
        }
        return $html . '</div>';
    }

    private static function escName(string $s): string
    {
        return htmlspecialchars($s, ENT_QUOTES, 'UTF-8');
    }
}
