<?php
declare(strict_types=1);

namespace Instinctor\Services;

use Instinctor\Core\DB;
use Instinctor\Core\Verticals as V;

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
{
    /** 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',
    ];

    /** Resolve a trade slug to its template page id (0 if none). See Core\Verticals. */
    public static function templateFor(string $trade): int
    {
        return V::templateFor($trade);
    }

    /**
     * Which vertical a trade resolves to (drives the image pool + seeding profile).
     *
     * An unmatched trade still answers with the FALLBACK rather than '': templateFor()
     * returns 0 for it (so callers can refuse to build), but a demo that IS being built
     * always needs a pool to draw from. Preserves the old behaviour, where "plumber"
     * resolved to the salon branch.
     */
    public static function verticalFor(string $trade): string
    {
        $v = V::forTrade($trade);
        return $v !== '' ? $v : V::FALLBACK;
    }

    /**
     * Which vertical an OWNER's site is, resolved from their page's own demo_trade.
     *
     * Lives here, next to verticalFor(), because more than one caller needs it: the tenant
     * dashboard (tiles, vocabulary, the Pro nudge) and DunningService (the upsell email,
     * which is the channel that actually reaches a free owner, since SMS is Pro). Copying
     * the query into each is how they drift, and a drifted answer here means a plumber is
     * told about gift cards.
     *
     * Published first, because an abandoned draft must not decide what their dashboard
     * says; falls back to any page so a not-yet-live owner still gets their own vertical.
     */
    public static function verticalForOwner(int $ownerId): string
    {
        static $cache = [];
        if (isset($cache[$ownerId])) return $cache[$ownerId];
        if ($ownerId <= 0) return $cache[$ownerId] = V::FALLBACK;

        $set = DB::get_var(DB::prepare(
            "SELECT settings FROM " . DB::prefix() . "instinctor_pages
              WHERE owner_id = %d
           ORDER BY (status = 'published') DESC, sort_order ASC, id ASC LIMIT 1",
            $ownerId
        ));
        $trade = (string) ((json_decode((string) $set, true) ?: [])['demo_trade'] ?? '');
        return $cache[$ownerId] = $trade !== '' ? self::verticalFor($trade) : V::FALLBACK;
    }

    /**
     * Does this owner's site take bookings, or does it quote first?
     *
     * The Installer template has no booking block: a trade prices a job before it can
     * offer a slot. Anything that promises an installer bookings, reminders or no-shows is
     * promising a thing their own site cannot do.
     */
    public static function ownerBooks(int $ownerId): bool
    {
        return V::books(self::verticalForOwner($ownerId));
    }

    public static function trades(): array
    {
        return V::keys();
    }

    /** Full-size URL for a pool image, honouring the vertical's directory layout. */
    private static function poolUrl(string $vertical, string $cat, int|string $num): string
    {
        $base = (string) V::cfg($vertical, 'pool.base', '');
        return V::cfg($vertical, 'pool.layout') === 'flat'
            ? $base . $num . '.webp'                 // flat: <base>/<n>.webp
            : $base . $cat . '/' . $num . '.webp';   // subdir: <base>/<cat>/<n>.webp
    }

    /** Deterministic (seeded) pick of $count distinct full-size URLs from a category. */
    private static function poolPick(string $vertical, string $cat, int $seed, int $count, ?array $numsOverride = null): array
    {
        // TAG MODEL: with no explicit id-list override, use every image tagged
        // <cat> in the library manifest (full URLs). Falls back to the config
        // id-list. Either way we end up with a URL list to shuffle + slice.
        $urls = null;
        if ($numsOverride === null) {
            $tagged = self::filesByTag($vertical, $cat);
            if ($tagged !== null) {
                if (!$tagged) return [];   // manifest authoritative: category genuinely empty
                $urls = $tagged;
            }
        }
        if ($urls === null) {
            $nums = $numsOverride ?? (array) V::cfg($vertical, 'pool.cats.' . $cat, []);
            if (!$nums) return [];
            $urls = array_map(fn($n) => self::poolUrl($vertical, $cat, $n), array_values($nums));
        }
        $order = array_values($urls);
        $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_slice($order, 0, max(0, $count));
    }

    /**
     * Map a raw trade to a hero subtype ('' = no subtype hero, caller uses interiors).
     *
     * A keyword whose set is empty does NOT match: we keep scanning. That lets a
     * vertical list a subtype it has no photos for yet without silently pointing the
     * hero at a 404.
     */
    /**
     * The hero image for a demo, obeying the one rule heroes have.
     *
     * AN INTERIOR IS NEVER A HERO (Sergei, locked 2026-07-26). Heroes are the paired
     * figure shots only: tags.json lists exactly the ten that have a -m pair, and a
     * hero without its pair is a gap to regenerate, not something to paper over. The
     * old fallback reached into interiors/, which put a PORTRAIT 832x1216 room shot
     * into a 16:9 hero slot for any trade with no subtype match.
     *
     * With no subtype hero, borrow another paired hero from the same vertical. The
     * interior stays only for a vertical with no heroes at all, where the alternative
     * is a blank hero.
     *
     * THREE call sites computed this independently, so fixing two of them changed
     * nothing on screen. One helper now, so a fourth cannot drift.
     */
    private static function heroBackdropFor(string $vertical, string $heroUrl, array $heroPhoto, int $seed): string
    {
        if ($heroUrl !== '') return $heroUrl;
        // PAIRED ONLY. imagePool() lists what the registry declares, which includes
        // heroes that were never finished: 17 of the salon's 27 have no -m pair, and the
        // fallback happily picked one (spa-3). The pair on disk is the rule, so filter on
        // it rather than trusting the registry.
        $root = defined('INSTINCTOR_STANDALONE_DIR')
            ? rtrim((string) INSTINCTOR_STANDALONE_DIR, '/')
            : dirname(__DIR__, 3);
        $paired = [];
        foreach ((array) (self::imagePool($vertical)['heroes'] ?? []) as $h) {
            $u = (string) ($h['full'] ?? '');
            if ($u === '') continue;
            $m = preg_replace('/\.webp$/', '-m.webp', $u);
            if (is_file($root . $m)) $paired[] = $u;
        }
        if ($paired) return $paired[abs($seed ^ 0x1234) % count($paired)];
        return (string) ($heroPhoto[0] ?? '');
    }

    private static function heroSubtypeFor(string $vertical, string $trade): string
    {
        $map  = (array) V::cfg($vertical, 'heroes.map', []);
        $sets = (array) V::cfg($vertical, 'heroes.sets', []);
        $t = strtolower($trade);
        foreach ($map as $kw => $sub) {
            // Leading-boundary match, same rule as Verticals::forTrade(). A plain strpos
            // here picked heroes off keywords buried in ordinary words ('pest' inside
            // "cheapest"), so the picture disagreed with the template.
            if (!preg_match('/\b' . preg_quote((string) $kw, '/') . '/iu', $t)) continue;
            if (!empty($sets[$sub])) return (string) $sub;
        }
        return '';
    }

    /** The word a business uses for its own premises. A barbershop is not a studio.
     *
     * The salon master hardcodes "The studio", "Meet the studio" and "Find the studio",
     * and none of it was ever rewritten, so every generated demo called itself a studio
     * whatever the trade: the nail salon, the barbershop and the day spa all did
     * (Sergei 2026-07-27). The hero subtype already knows nails from lashes, so ask it
     * first and only fall back to reading the words.
     */
    private static function venueNoun(string $vertical, string $trade, string $name = ''): string
    {
        $t = trim($trade . ' ' . $name);
        $BY_SUBTYPE = [
            'hair' => 'salon',   'nails'   => 'salon',  'tanning' => 'salon',
            'salon' => 'salon',  'barber'  => 'shop',   'spa'     => 'spa',
            'makeup' => 'studio','lash'    => 'studio', 'brow'    => 'studio',
            'esthetics' => 'studio',
        ];
        $sub = self::heroSubtypeFor($vertical, $t);
        if ($sub !== '' && isset($BY_SUBTYPE[$sub])) return $BY_SUBTYPE[$sub];

        // 'barber' before 'bar', and 'bar' last, or "Nail Bar" never reaches 'salon'.
        foreach (['barber' => 'shop', 'salon' => 'salon', 'spa' => 'spa',
                  'clinic' => 'clinic', 'studio' => 'studio', 'shop' => 'shop'] as $kw => $noun) {
            if (preg_match('/\b' . $kw . '/i', $t)) return $noun;
        }
        return 'studio';
    }

    /** Write a heading's text, kinetic or not.
     *
     * A kinetic keeps its words in BOTH `text` and the first custom line, and setting
     * only one of them leaves the rendered heading unchanged while the stored one looks
     * right. Every heading rewrite goes through here for that reason.
     */
    /**
     * Replace one whole word, matching the case of what it replaces: "VISIT US"
     * becomes "VISIT ME", "find us" becomes "find me". Eyebrows are all-caps and
     * headings are not, so a blind lowercase swap corrupts one or the other.
     */
    /**
     * A short heading re-voiced for one person: What we do -> What I do, Who we
     * are -> Who I am, OUR WORK -> MY WORK. Verb agreement first (we are/were),
     * then the pronouns, case preserved throughout.
     */
    private static function soloVoice(string $text): string
    {
        $t = (string) preg_replace_callback('/\bwe\s+are\b/i', static function ($m) {
            return (strtoupper($m[0]) === $m[0]) ? 'I AM' : 'I am';
        }, $text);
        $t = (string) preg_replace_callback('/\bwe\s+were\b/i', static function ($m) {
            return (strtoupper($m[0]) === $m[0]) ? 'I WAS' : 'I was';
        }, $t);
        $t = (string) preg_replace_callback('/\bwe\b/i', static function ($m) { return 'I'; }, $t);
        $t = (string) preg_replace_callback('/\bus\b/i', static function ($m) {
            return (strtoupper($m[0]) === $m[0]) ? 'ME' : 'me';
        }, $t);
        $t = (string) preg_replace_callback('/\bour\b/i', static function ($m) {
            if (strtoupper($m[0]) === $m[0]) return 'MY';
            if (ucfirst(strtolower($m[0])) === $m[0]) return 'My';
            return 'my';
        }, $t);
        return $t;
    }

    private static function swapWordKeepCase(string $text, string $from, string $to): string
    {
        return (string) preg_replace_callback('/\b' . preg_quote($from, '/') . '\b/i', static function ($m) use ($to) {
            if (strtoupper($m[0]) === $m[0]) return strtoupper($to);
            if (ucfirst(strtolower($m[0])) === $m[0]) return ucfirst($to);
            return $to;
        }, $text, 1);
    }

    private static function setHeading(array &$S, array $el, string $text): void
    {
        if (($el['type'] ?? '') === 'kinetic') {
            $S['text'] = $text;
            $cl = json_decode((string) ($S['custom_lines'] ?? ''), true);
            if (is_array($cl) && $cl) {
                $cl[0]['text'] = $text;
                $S['custom_lines'] = wp_json_encode(array_slice($cl, 0, 1));
            }
            return;
        }
        self::setContent($S, $text);
    }

    /** Swap the venue word inside a heading, leaving its kinetic line structure alone.
     *
     * Rewriting the whole string would flatten the kinetic's per-line styling, which is
     * what the solo "Meet the owner" path does deliberately. Here only one word changes,
     * so the animation survives untouched.
     */
    private static function swapVenueWord(array &$S, string $noun): void
    {
        $swap = static function (string $txt) use ($noun): string {
            return (string) preg_replace_callback(
                '/\b(studio|salon|shop|spa)\b/i',
                static fn($m) => ctype_upper($m[1][0]) ? ucfirst($noun) : $noun,
                $txt,
                1
            );
        };
        if (isset($S['text']) && is_string($S['text'])) $S['text'] = $swap($S['text']);
        $cl = json_decode((string) ($S['custom_lines'] ?? ''), true);
        if (is_array($cl) && $cl) {
            foreach ($cl as &$line) {
                if (isset($line['text']) && is_string($line['text'])) $line['text'] = $swap($line['text']);
            }
            unset($line);
            $S['custom_lines'] = wp_json_encode($cl);
        }
    }

    /** Seeded subtype hero URL, or '' if the trade has no matching subtype.
     *  An explicit $archetype (the LLM's face/place/product/digital classification)
     *  wins when the vertical actually has photos for that set; otherwise we fall back
     *  to the trade/name keyword map. Verticals without archetype sets (salon, etc.)
     *  simply ignore it, since $sets[$archetype] is empty for them. */
    private static function heroUrlFor(string $vertical, string $trade, int $seed, string $name = '', string $archetype = '', bool $archetypeForced = false): string
    {
        $sets = (array) V::cfg($vertical, 'heroes.sets', []);
        // Three tiers, not two: the CLASSIFIER's archetype loses to a trade
        // subtype (a massage therapist gets the massage hero, not the guessed
        // portrait), but a VISITOR's explicit tap on the start page beats both.
        // Without this, choosing 'Your place' changed nothing for any trade
        // with its own hero, which is what made the old switcher look dead
        // (Sergei 2026-07-29).
        // A PINNED profession outranks even the visitor's tap: a photographer has
        // exactly one hero pair, desktop + mobile of the same image, and no chip
        // may swap it for a generic portrait (Sergei 2026-07-29). The entry page
        // hides the style chips for these professions, so the tap cannot normally
        // happen; this is the server-side guarantee behind that.
        $pinnedProf = V::prof(V::professionFor($trade . ' ' . $name), 'hero') === 'pinned';
        if (!$pinnedProf && $archetypeForced && $archetype !== '' && !empty($sets[$archetype])) {
            $sub = $archetype;
            $kwSub = '';
            goto resolved;
        }
        // Read the business name too: a generic "salon" trade still gets a hair/
        // nails/spa hero when the name says so ("Aurora Hair Studio" -> hair).
        $kwSub = self::heroSubtypeFor($vertical, trim($trade . ' ' . $name));
        // TRADE-SPECIFIC subs beat the LLM archetype: "massage therapist" must get
        // the massage hero, not the face-archetype portrait the classifier picked.
        // The four broad archetypes still win for everything else, so the demo
        // overlay's archetype switcher keeps working on archetype-driven trades.
        $ARCHES = ['face', 'place', 'product', 'digital', 'general'];
        if ($kwSub !== '' && !in_array($kwSub, $ARCHES, true)) {
            $sub = $kwSub;
        } elseif ($archetype !== '' && !empty($sets[$archetype])) {
            $sub = $archetype;
        } else {
            $sub = $kwSub;
        }
        resolved:
        if ($sub === '') return '';
        $base   = (string) V::cfg($vertical, 'pool.base', '');
        $layout = (string) V::cfg($vertical, 'heroes.layout');
        // TAG MODEL first: any image tagged hero:<sub> in the library manifest,
        // wherever it physically sits. One image can serve several trades.
        // A manifest is authoritative: empty tag = no hero for this trade
        // (caller falls back to the interiors pick), never stale config ids.
        $tagged = self::filesByTag($vertical, 'hero:' . $sub);
        // A pinned profession has ONE hero: that image IS the profession's site,
        // and rotation would only ever swap it for a worse fit (Sergei 2026-07-29).
        $pinned = $pinnedProf;
        if ($tagged !== null) return $tagged ? $tagged[$pinned ? 0 : abs($seed) % count($tagged)] : '';
        // Subdir sets are FOLDER-DRIVEN: rotate over every {sub}-*.webp actually
        // present on disk, so heroes added/removed via the platform image tool
        // change the rotation with no config edit. Falls back to the configured
        // index list when the folder is empty/unreadable (and for flat layouts,
        // whose ids are arbitrary names, not {sub}-N).
        if ($layout !== 'flat') {
            $files = self::heroFilesFor($base, $sub);
            if ($files) return $files[$pinned ? 0 : abs($seed) % count($files)];
        }
        $nums = (array) V::cfg($vertical, 'heroes.sets.' . $sub, []);
        if (!$nums) return '';
        $n = array_values($nums)[abs($seed) % count($nums)];
        return $layout === 'flat'
            ? $base . $n . '.webp'                                        // flat: <base>/<n>.webp
            : $base . 'heroes/' . $sub . '/' . $sub . '-' . $n . '.webp'; // subdir
    }

    /**
     * Every PRIMARY hero URL in a subdir set's folder ({base}heroes/{sub}/),
     * i.e. {sub}-*.webp excluding the -sm/-m/-mobile variants (depth maps are
     * .png, so already excluded). Sorted for deterministic seeding. [] when the
     * folder is missing. Powers the folder-driven rotation in heroUrlFor.
     */
    private static function heroFilesFor(string $base, string $sub): array
    {
        $relDir = preg_replace('#^.*?/uploads/#', '', rtrim($base, '/')) . '/heroes/' . $sub;
        $absDir = \Instinctor\Core\Storage::path($relDir);
        if (!is_dir($absDir)) return [];
        $out = [];
        foreach (glob($absDir . '/' . $sub . '-*.webp') ?: [] as $f) {
            $b = basename($f, '.webp');
            if (preg_match('/-(sm|m|mobile)$/i', $b)) continue;
            $out[] = rtrim($base, '/') . '/heroes/' . $sub . '/' . basename($f);
        }
        sort($out);
        return $out;
    }

    /**
     * TAG MODEL: a library's {library}/tags.json maps rel-within-library => [tags]
     * so one image can serve many categories with no copies. Cached per request.
     */
    /**
     * The pooled photo that best fits a service NAME, skipping ones already used.
     *
     * Cards used to take gallery[$idx], i.e. shuffle order, which has nothing to do
     * with what the card says. Every image carries subject tags from the manifest
     * ('cards', 'banner', 'vehicle', 'signage', 'printer'), so the name can be scored
     * against them: a word of the service name appearing in a tag, or in the file's
     * own subject-named filename, is a point. Returns '' when nothing scores, and the
     * caller falls back to positional order.
     */
    private static function bestShotFor(string $name, array $pool, array $used, string $vertical): string
    {
        $name = strtolower(trim($name));
        if ($name === '' || !$pool) return '';
        // Words worth matching on: drop the connective noise a service name carries.
        $stop = ['and', 'the', 'for', 'with', 'your', 'our', 'of', 'to', 'a', 'in', 'on', 'custom', 'orders', 'service', 'services'];
        $words = array_values(array_filter(preg_split('/[^a-z]+/', $name) ?: [],
            static fn($w) => strlen($w) > 2 && !in_array($w, $stop, true)));
        if (!$words) return '';

        $base = (string) V::cfg($vertical, 'pool.base', '');
        $man  = $base !== '' ? self::tagManifest($base) : null;

        // SEARCH THE WHOLE POOL, not the six the gallery happened to draw. The gallery
        // takes gallery.count images; the cards were then matched against only those,
        // so the business-cards photo and the wide-format banner run were unreachable
        // and "Business Cards" settled for a shot of trimmed sheets (Sergei 2026-08-01).
        // Widened to every manifest image in the same folder as the pool's own picks.
        if (is_array($man) && $pool) {
            $dir = trim(dirname(ltrim(str_replace(rtrim($base, '/'), '', (string) $pool[0]), '/')), '.');
            if ($dir !== '' && $dir !== '/') {
                // Folder alone is not enough. A retired image keeps its folder, so
                // print-vehicle-wrap came back through this widening after being
                // untagged, and matched "Vehicle Decals" with the plain colour respray
                // it was pulled for (Sergei 2026-08-01). Require a shared tag with the
                // pool's own images, which is what makes it part of that pool.
                $poolTags = [];
                foreach ($pool as $pu) {
                    $pr = ltrim(str_replace(rtrim($base, '/'), '', (string) $pu), '/');
                    foreach ((array) ($man[$pr] ?? []) as $t) $poolTags[strtolower((string) $t)] = 1;
                }
                $wide = [];
                foreach ($man as $rel => $relTags) {
                    if (!str_starts_with($rel, rtrim($dir, '/') . '/')) continue;
                    $shared = false;
                    foreach ((array) $relTags as $t) {
                        if (isset($poolTags[strtolower((string) $t)])) { $shared = true; break; }
                    }
                    if ($shared) $wide[] = rtrim($base, '/') . '/' . $rel;
                }
                if (count($wide) > count($pool)) $pool = $wide;
            }
        }

        $best = ''; $bestScore = 0;
        foreach ($pool as $url) {
            $url = (string) $url;
            if (in_array($url, $used, true)) continue;               // never repeat a card photo
            $rel  = ltrim(str_replace(rtrim($base, '/'), '', $url), '/');
            $tags = is_array($man) ? array_map('strtolower', (array) ($man[$rel] ?? [])) : [];
            $hay  = strtolower($rel) . ' ' . implode(' ', $tags);
            $score = 0;
            foreach ($words as $w) {
                // singular/plural both count: "cards" should match the 'cards' tag and
                // "banners" the 'banner' one.
                $stem = rtrim($w, 's');
                if ($stem !== '' && str_contains($hay, $stem)) $score++;
            }
            if ($score > $bestScore) { $bestScore = $score; $best = $url; }
        }
        return $bestScore > 0 ? $best : '';
    }

    private static array $tagManifestCache = [];
    /** null = no manifest for this library (legacy fallbacks apply); [] = manifest exists but empty. */
    private static function tagManifest(string $base): ?array
    {
        if (array_key_exists($base, self::$tagManifestCache)) return self::$tagManifestCache[$base];
        $rel = preg_replace('#^.*?/uploads/#', '', rtrim($base, '/')) . '/tags.json';
        $abs = \Instinctor\Core\Storage::path($rel);
        $m   = is_file($abs) ? (json_decode((string) @file_get_contents($abs), true) ?: []) : null;
        return self::$tagManifestCache[$base] = $m;
    }

    /**
     * Full URLs of every primary image tagged $tag in the vertical's library
     * (sorted). null when the library has NO manifest (caller uses the legacy
     * folder-scan / config path). When a manifest EXISTS it is authoritative:
     * an empty tag really is empty; never fall through to stale config lists
     * that may reference deleted files.
     */
    private static function filesByTag(string $vertical, string $tag): ?array
    {
        $base = (string) V::cfg($vertical, 'pool.base', '');
        if ($base === '') return null;
        $man = self::tagManifest($base);
        if ($man === null) return null;
        $out = [];
        foreach ($man as $rel => $tags) {
            if (in_array($tag, (array) $tags, true)) $out[] = rtrim($base, '/') . '/' . $rel;
        }
        sort($out);
        return $out;
    }

    /**
     * Per-hero text alignment. Every hero image has its own composition, so the
     * hero headline should sit in that image's negative space, not always dead
     * centre. Default is 'center' (the edge-subject direction, where the person
     * sits at a frame edge and the open middle carries the text). A hero whose
     * copyspace is off to one side is tagged in the vertical's heroes.align map
     * (keyed by subtype -> index), e.g. an interior with a clean right column.
     * Returns 'center' | 'left' | 'right'.
     */
    private static function heroAlignFor(string $vertical, string $heroUrl): string
    {
        if ($heroUrl === '' ) return 'center';
        // subdir hero: .../heroes/<sub>/<sub>-<n>.webp
        if (preg_match('#/heroes/([a-z0-9]+)/[a-z0-9]+-([0-9]+)\.webp#i', $heroUrl, $m)) {
            $aligns = (array) V::cfg($vertical, 'heroes.align.' . strtolower($m[1]), []);
            $a = (string) ($aligns[(int) $m[2]] ?? '');
            if (in_array($a, ['left', 'center', 'right'], true)) return $a;
        }
        return 'center';
    }

    /**
     * Per-image 9-dot alignment (platform image tool): a library image may carry
     * an `align:<h>-<v>` tag (h: left|center|right, v: top|center|bottom) set
     * via the manager's 9-dot picker, because every hero has its own busy areas
     * and copyspace. Returns ['h'=>,'v'=>] or null when the image has no tag
     * (caller then keeps the template's own valign and the legacy h-align).
     */
    private static function heroAlignTagFor(string $vertical, string $heroUrl): ?array
    {
        $base = (string) V::cfg($vertical, 'pool.base', '');
        if ($base === '' || $heroUrl === '') return null;
        $man = self::tagManifest($base);
        if (!$man) return null;
        $rel = ltrim(substr($heroUrl, strlen(rtrim($base, '/'))), '/');
        foreach ((array) ($man[$rel] ?? []) as $t) {
            if (preg_match('/^align:(left|center|right)-(top|center|bottom)$/', (string) $t, $m)) {
                return ['h' => $m[1], 'v' => $m[2]];
            }
        }
        return null;
    }

    /**
     * Mobile hero companion. Hero images may ship a portrait crop beside the
     * desktop file ({name}-m.webp, managed from the platform image tool). When
     * one exists it is used as the section's bg_image_mobile so mobile shows
     * the dedicated crop instead of a repositioned desktop image. '' when none.
     */
    /**
     * Profession review pools. The template cards praised cleanliness on a
     * photographer's site because they were written once for InStudio and
     * inherited by everyone (Sergei 2026-07-29). Four per profession, in the
     * profession's own vocabulary; names deliberately ordinary.
     */
    private const REVIEW_POOLS = [
        'photographer' => [
            ['Emily R.', 'The gallery came back in three days and every shot felt like us.'],
            ['Mark and Jess', 'She caught moments we did not even see happen.'],
            ['Dana W.', 'Booked for the wedding, came back for newborn photos.'],
            ['Chris P.', 'Zero awkward posing. The candids are the best pictures we own.'],
        ],
        'massage' => [
            ['Laura B.', 'My back finally let go after months. Booked again before I left.'],
            ['Tom H.', 'Found the knot in ten minutes and worked it out.'],
            ['Priya S.', 'The prenatal massage was the first good sleep I had in weeks.'],
            ['Alex M.', 'On time, professional, and my headache was gone by evening.'],
        ],
        'handyman' => [
            ['Karen D.', 'Fixed the door, two shelves and a faucet in one visit.'],
            ['Bill T.', 'Showed up when he said, done by lunch, fair price.'],
            ['Renee C.', 'Sent a photo of the finished repair before I got home.'],
            ['Sam O.', 'The list I kept postponing for a year, gone in an afternoon.'],
        ],
        'plumber' => [
            ['Janet M.', 'Water heater replaced same day. Straight price, no surprises.'],
            ['Rob K.', 'Found the leak the last guy missed in five minutes.'],
            ['Steve F.', 'Cleared the drain and showed me why it kept clogging.'],
            ['Maria L.', 'Midnight burst pipe, and he answered and came out.'],
        ],
        'electrician' => [
            ['Don W.', 'Panel upgrade done clean, passed inspection first try.'],
            ['Ashley N.', 'EV charger installed in a morning, walked me through it.'],
            ['Gary P.', 'Finally an electrician who labels the breakers.'],
            ['Yuki T.', 'Quoted a number, and that number was the bill.'],
        ],
        'hvac' => [
            ['Paula G.', 'AC died in July, running again the same afternoon.'],
            ['Marcus D.', 'Serviced the furnace and our bill dropped noticeably.'],
            ['Irene S.', 'Explained the options without pushing the expensive one.'],
            ['Ted R.', 'New unit in, old one hauled away, house cool by dinner.'],
        ],
        'cleaning' => [
            ['Nadia H.', 'The house has not looked like this since we moved in.'],
            ['Greg B.', 'Deep clean before listing, and our agent noticed immediately.'],
            ['Sue E.', 'Same crew every week and they remember the details.'],
            ['Victor M.', 'In and out in three hours, spotless.'],
        ],
        'roofing' => [
            ['Hank J.', 'Storm damage inspected next day, photos of everything.'],
            ['Carol A.', 'A straight answer: repair, not replace. Saved us thousands.'],
            ['Dev P.', 'New roof in two days, yard cleaner than they found it.'],
            ['Lynn F.', 'Insurance paperwork handled without me chasing anyone.'],
        ],
        'auto' => [
            ['Derek W.', 'Brakes done in an afternoon, and the estimate came with photos of the worn pads.'],
            ['Sandra P.', 'They texted a price, I approved from my phone, and the truck was ready by five.'],
            ['Luis M.', 'Check engine light diagnosed for what they quoted, not a dollar more.'],
            ['Kathy R.', 'Second opinion saved me a transmission I did not need. Honest shop.'],
        ],
        'detailing' => [
            ['Priya S.', 'The car came back looking newer than the day I bought it.'],
            ['Mark D.', 'Ceramic coating done right: water just rolls off, months later.'],
            ['Elena V.', 'Interior smelled like a rental gone wrong. They made it disappear.'],
            ['Chris B.', 'Booked online, dropped the keys, got photos when it was done.'],
        ],
        'music' => [
            ['Rachel K.', 'My daughter actually practices now. That says everything.'],
            ['Owen S.', 'Went from zero to playing at a family party in a year.'],
            ['Mia C.', 'Patient, structured, and the lessons are the fun kind of hard.'],
            ['The Delgados', 'Three kids through this studio, all still playing.'],
        ],
        'tutor' => [
            ['Angela P.', 'Two grade levels up in reading by spring.'],
            ['Marcus W.', 'The test score went from maybe to done deal.'],
            ['Jenny L.', 'Explains it the way school never managed to.'],
            ['David R.', 'The homework fights just stopped.'],
        ],
        'driving' => [
            ['Tyler B.', 'Passed the road test first try.'],
            ['Donna M.', 'Calm instructor. My nervous teen came back confident.'],
            ['Raj S.', 'Pickup at school made scheduling painless.'],
            ['Kim V.', 'Taught the habits, not just the test.'],
        ],
    ];

    private static function mobileVariantFor(string $imgUrl): string
    {
        if ($imgUrl === '' || !preg_match('#\.webp$#i', $imgUrl)) return '';
        foreach (['-m.webp', '-mobile.webp'] as $suf) {
            $cand = preg_replace('#\.webp$#i', $suf, $imgUrl);
            $rel  = preg_replace('#^.*?/uploads/#', '', $cand);
            if ($rel !== $cand && \Instinctor\Core\Storage::exists($rel)) return $cand;
        }
        return '';
    }

    /**
     * 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 : '';
    }

    /**
     * The portrait a team member gets.
     *
     * Shared by the team flip-boxes AND the booking staff picker so the two can never
     * disagree. They did: the cards showed faces while the picker right below them fell
     * back to grey initials, because the staff seed hardcoded photo_url = '' (Anna,
     * 2026-07-16). Same name + same index => same face, by construction.
     */
    private static function portraitFor(string $memberName, int $idx, string $trade, string $profile, string $soloPortrait, array $womenShuf, array $menShuf): string
    {
        if ($profile === 'solo') return $soloPortrait;   // one person IS the business
        $tg = self::guessGender($memberName);
        // Unknown names follow the trade's likely gender: male for barbershops, female
        // otherwise (the pool skews that way).
        $useMale = ($tg === 'male') || ($tg === 'unknown' && stripos($trade, 'barber') !== false);
        $glist = ($useMale && $menShuf) ? $menShuf : $womenShuf;
        return $glist ? (string) $glist[$idx % count($glist)] : '';
    }

    /** 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).
     * Label comes from the vertical's own keyword map; first match wins.
     */
    private static function tradeEyebrow(string $trade, string $city, string $vertical = V::FALLBACK): string
    {
        $label = V::matchMap($vertical, 'eyebrow.map', $trade, (string) V::cfg($vertical, 'eyebrow.default', ''));
        $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 $vertical = V::FALLBACK): string
    {
        return V::matchMap($vertical, 'gift.map', $trade, (string) V::cfg($vertical, 'gift.default', ''));
    }

    /**
     * Gallery ("recent looks") image URLs, per the vertical's gallery mode:
     *
     *   'category'  — pull N straight from one category (a solo instructor's gallery is
     *                 just lesson shots; there is no per-trade sequence to compose).
     *   'trade_map' — a per-trade sequence of categories, so a nail bar shows nails and
     *                 a barbershop shows men. Falls back to the vertical's default seq.
     */
    private static function galleryPhotosFor(string $trade, int $seed, string $vertical = V::FALLBACK, string $archetype = ''): array
    {
        if (V::cfg($vertical, 'gallery.mode') === 'category') {
            $cat = (string) V::cfg($vertical, 'gallery.cat', '');
            // A pool counts as real when the MANIFEST has files for it. The old test
            // only read pool.cats from the registry, so a tag-only pool (print, retail,
            // workshop) looked empty and was skipped however well it matched.
            $stocked = function (string $c) use ($vertical): bool {
                if ($c === '') return false;
                $tagged = self::filesByTag($vertical, $c);
                if (is_array($tagged)) return $tagged !== [];
                return !empty((array) V::cfg($vertical, 'pool.cats.' . $c, []));
            };
            // An archetype (from the LLM) can steer the gallery to matching photos; a
            // still-empty archetype cat (products/digital) falls back to the default cat.
            if ($archetype !== '') {
                $byA = (array) V::cfg($vertical, 'gallery.by_archetype', []);
                $aCat = (string) ($byA[$archetype] ?? '');
                if ($stocked($aCat)) $cat = $aCat;
            }
            // TRADE KEYWORDS OUTRANK THE ARCHETYPE. The classifier only has four
            // buckets, so it cannot tell a sign shop from a soap maker; the words the
            // owner typed can. First match wins, and only if that pool has photos.
            $tl = ' ' . strtolower($trade) . ' ';
            foreach ((array) V::cfg($vertical, 'gallery.by_trade', []) as $kw => $tCat) {
                if (strpos($tl, (string) $kw) === false) continue;
                if ($stocked((string) $tCat)) { $cat = (string) $tCat; break; }
            }
            return self::poolPick($vertical, $cat, $seed, (int) V::cfg($vertical, 'gallery.count', 6));
        }

        $t    = strtolower($trade);
        $map  = (array) V::cfg($vertical, 'gallery.map', []);
        $cats = (array) V::cfg($vertical, 'gallery.default', []);
        foreach ($map as $kw => $seq) { if (strpos($t, (string) $kw) !== false) { $cats = (array) $seq; break; } }

        $out = [];
        $counters = [];
        $shuf = [];
        foreach ($cats as $cat) {
            if (!isset($shuf[$cat])) {
                $all = (array) V::cfg($vertical, 'pool.cats.' . $cat, []);
                $shuf[$cat] = self::poolPick($vertical, $cat, $seed + crc32($cat), count($all));
            }
            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) {
            // booking_form OR quote_form: verticals that cannot quote a slot before
            // seeing the job (home services) carry the same dataset on their estimate
            // block instead. Reading only booking_form emptied this panel for them.
            if (in_array(($e['settings']['demo_role'] ?? ''), ['booking_form', 'quote_form'], true)) {
                $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'),
            // Recovery contact from the entry form: pre-fills the free-publish card
            // so the visitor is never asked for the same details twice.
            'email'    => (string) ($set['contact_email'] ?? ''),
            'phone'    => (string) ($set['contact_phone'] ?? ''),
            '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.
     *
     * Defaults to the fallback vertical because demo-overlay.php has no vertical in
     * scope: an instructor demo currently offers the salon pool in its swapper. That
     * predates this refactor and is left as-is here (one concern per build); the param
     * is what a caller needs once the overlay learns which demo it is decorating.
     */
    public static function imagePool(string $vertical = V::FALLBACK): array
    {
        $flat = V::cfg($vertical, 'pool.layout') === 'flat';
        $base = (string) V::cfg($vertical, 'pool.base', '');
        $sm   = static fn(string $full): string => preg_replace('#\.webp$#i', '-sm.webp', $full);

        // ONE SOURCE OF TRUTH WITH THE GENERATOR. This built the picker from the
        // hardcoded numeric id-lists in Verticals while the PAGE is built from the
        // tag manifest, so the two drifted the moment any image was added by tag:
        // the new trade photographs rendered on the demo but catOf() did not know
        // them, so they had no entry, no "Change photo", and no chip. The manifest
        // wins when it exists, exactly as filesByTag does (Sergei 2026-07-30).
        $man  = self::tagManifest($base);
        $tags = [];
        if (is_array($man)) {
            foreach ($man as $rel => $tt) {
                foreach ((array) $tt as $t) $tags[$t][] = rtrim($base, '/') . '/' . $rel;
            }
            foreach ($tags as $t => $urls) { sort($urls); $tags[$t] = $urls; }
        }

        $out = [];
        foreach ((array) V::cfg($vertical, 'pool.cats', []) as $cat => $nums) {
            $out[$cat] = array_map(static function ($n) use ($flat, $base, $cat, $sm) {
                $full = $flat ? $base . $n . '.webp' : $base . $cat . '/' . $n . '.webp';
                return ['full' => $full, 'sm' => $sm($full)];
            }, (array) $nums);
        }
        $heroFlat = V::cfg($vertical, 'heroes.layout') === 'flat';
        $heroes = [];
        foreach ((array) V::cfg($vertical, 'heroes.sets', []) as $sub => $nums) {
            foreach ((array) $nums as $n) {
                $full = $heroFlat ? $base . $n . '.webp' : $base . 'heroes/' . $sub . '/' . $sub . '-' . $n . '.webp';
                $heroes[] = ['full' => $full, 'sm' => $sm($full)];
            }
        }
        $out['heroes'] = $heroes;

        // MERGED LAST, ON PURPOSE. This ran before the heroes block above, which
        // then assigned $out['heroes'] outright and threw away every hero the
        // manifest had contributed, so the hero had no category, no entry and no
        // chip. Anything merged here has to survive the plain assignments.
        //
        // Every tagged category, including ones the config never listed (the
        // profession pools: photographer, plumbing, hvac...). Config entries are
        // kept and merged so nothing that worked before stops working.
        foreach ($tags as $cat => $urls) {
            // NAMESPACED TAGS ARE NOT CATEGORIES OF THEIR OWN. 'subject:mini-split'
            // describes content for the router, and letting it through made catOf()
            // file an HVAC photo under 'mini-split', so the picker offered one
            // picture instead of the nine in the trade pool. 'hero:hvac' names one
            // specific hero, but it is still A HERO, so those fold into 'heroes'.
            if (strncmp($cat, 'hero:', 5) === 0) {
                $cat = 'heroes';
            } elseif (strpos($cat, ':') !== false) {
                continue;
            }
            $have = [];
            foreach ($out[$cat] ?? [] as $row) $have[$row['full']] = true;
            foreach ($urls as $u) {
                if (isset($have[$u])) continue;
                $out[$cat][] = ['full' => $u, 'sm' => $sm($u)];
            }
        }
        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].
     */
    /**
     * Is this a real description of a business, or is it "123"?
     *
     * Every demo mints a free, publicly-indexable site at instinctor.com/<slug>, so the
     * entry form is a spam target. This is the bar. It is deliberately cheap and dumb:
     * it stops the lazy 90% (blank, "123", "asdf", a mashed keyboard) without a captcha
     * and without punishing a plumber who types four honest words.
     *
     * It is also a quality gate. This string IS the trade: it picks the vertical by
     * keyword and feeds the copy generator. A blank one used to silently become a salon,
     * which is how a plumber ended up with a hair-studio site.
     *
     * MIRRORED in assets/js/impulse-demo.js (describeProblem). Change both or the client
     * waves through what the server then rejects, and the user hits it after the montage.
     *
     * @return string '' when acceptable, else an error code the client maps to a message.
     */
    public static function describeProblem(string $s): string
    {
        $s = trim((string) preg_replace('/\s+/u', ' ', $s));
        if ($s === '') return 'empty_description';
        // 20 chars / 3 words / 10 letters. "Plumber in Philadelphia" (23/3/20) passes;
        // "123", "aaaaaaaaaaaaaaaaaaaaaa" (1 word) and "1234567890 1234567890" (2 words,
        // 0 letters) do not. Determined spam needs the IP rate limit, not this.
        if (mb_strlen($s) < 20) return 'short_description';
        if (count(preg_split('/\s+/u', $s) ?: []) < 3) return 'short_description';
        if (preg_match_all('/\p{L}/u', $s) < 10) return 'short_description';
        return '';
    }

    /**
     * @param array $facts What the VISITOR told us, rather than what a model inferred.
     *   staff       'solo' | 'small' | 'large'   who actually works there
     *   owner_name  the person's name, when it is one person
     *
     * Anything absent here falls back to inference exactly as before, so this is
     * additive: the generator keeps guessing for callers that do not ask.
     *
     * Asking exists because inference was arbitrary where it mattered most. Two equally
     * neutral descriptions, "Piano lessons for children and adults" and "Yoga classes,
     * all levels welcome", landed on opposite answers, and the one guessed as a school
     * was given three invented teachers with names, bios and portraits that the owner
     * then has to find and delete from their own site (Sergei 2026-07-28).
     */
    public static function startDemo(string $trade, string $name, string $city = '', string $url = '', string $email = '', string $phone = '', array $facts = []): array
    {
        // Enforced here rather than only in the controller so every caller gets it.
        $problem = self::describeProblem($trade);
        if ($problem !== '') return ['error' => $problem];

        $tplId = self::templateFor($trade);
        // An odd/unmatched trade (e.g. "microblading", "med spa") still gets the
        // fallback vertical's 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 = (int) V::cfg(V::FALLBACK, 'template', 0);

        $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);
        $vertical = self::verticalFor($trade);
        $profession = V::professionFor($trade . ' ' . $name);
        // Business archetype from the platform LLM (face/place/product/digital). Steers
        // the hero + gallery on verticals that define archetype sets (the generic
        // builder); ignored elsewhere. Validated so a stray value can't pick a bad cat.
        $archetype = strtolower(trim((string) ($gen['hero_archetype'] ?? '')));
        // WHAT THEY TOLD US BEATS WHAT THE MODEL GUESSED, same rule as staff:
        // the start page now asks what the site should show first, and an
        // explicit tap outranks the classifier (Sergei 2026-07-29).
        $saidArch = strtolower(trim((string) ($facts['archetype'] ?? '')));
        $archForced = in_array($saidArch, ['face', 'place', 'product', 'digital'], true);
        if ($archForced) $archetype = $saidArch;
        if (!in_array($archetype, ['face', 'place', 'product', 'digital'], true)) $archetype = '';
        // The ONE behaviour switch between verticals, and it is declared in the registry
        // rather than tested against a vertical name. A new vertical picks a profile;
        // it does not add a branch here.
        $profile  = (string) V::cfg($vertical, 'seed', 'team');
        $soloPortrait = '';
        if ($profile === 'solo') {
            // Solo (one person IS the business): one portrait, a single-category
            // gallery, a field-matched hero falling back to an interior. No team or
            // gender logic, because there is no team.
            $womenShuf = $menShuf = [];
            $soloPortrait = self::poolPick($vertical, 'portraits', $seed, 99)[0] ?? '';
            $galleryPhotos = self::galleryPhotosFor($trade, $seed ^ 0x2ab1, $vertical, $archetype);
            $heroPhoto = self::poolPick($vertical, 'interiors', $seed ^ 0x1234, 1);
            $heroUrl   = self::heroUrlFor($vertical, $trade, $seed, $name, $archetype, $archForced);
        } else {
            // Team: distinct portraits per card, matched to each generated name's gender.
            $womenShuf = self::poolPick($vertical, 'women', $seed, 99, (array) V::cfg($vertical, 'pool.team.women', []));   // curated DISTINCT people (no same-person dupes)
            $menShuf   = self::poolPick($vertical, 'men', $seed ^ 0x5f5f5f, 99, (array) V::cfg($vertical, 'pool.team.men', []));
            $galleryPhotos = self::galleryPhotosFor($trade, $seed, $vertical, $archetype);
            $heroPhoto = self::poolPick($vertical, 'interiors', $seed ^ 0x1234, 1);   // fallback space
            $heroUrl   = self::heroUrlFor($vertical, $trade, $seed, $name, $archetype, $archForced); // subtype-specific hero (reads name too), '' if none
        }
        // AN INTERIOR IS NEVER A HERO (Sergei, locked 2026-07-26). Heroes are the paired
        // figure shots only: a hero without its -m pair is not a hero, it is a gap to be
        // regenerated. Falling back to an interiors/ pick put a PORTRAIT 832x1216 room
        // shot into a 16:9 hero slot on any trade with no subtype match.
        //
        // With no subtype hero, borrow another paired hero from the same vertical rather
        // than reaching for a room. The interiors pick stays only as the last resort for
        // a vertical that has no heroes at all, where the alternative is nothing.
        $heroBackdrop = self::heroBackdropFor($vertical, $heroUrl, (array) $heroPhoto, (int) $seed);

        // A profession with its own tagged portfolio draws EVERY pooled image from
        // it: gallery, and the service-card fronts that previously showed whatever
        // the archetype pool held.
        $profTag = (string) (V::prof($profession, 'gallery.tag') ?? '');
        if ($profTag !== '') {
            $profShots = self::poolPick($vertical, $profTag, crc32($name), 12);
            if ($profShots) $galleryPhotos = $profShots;
        }


        // ONE address for BOTH the map and the "Find us" block, so the visitor edits
        // it once (in Find us) and the map follows. Fall back to the city when Places
        // found nothing, so there's always an editable line + a sensibly-centred map.
        $demoAddress = trim((string) ($gen['address'] ?? ''));
        // When Places found no real street address we still center the map on the city,
        // but the visible "Find us" line becomes an editable "add your address" prompt
        // instead of a bare city name (Sergei 2026-07-13).
        $gen['address_is_placeholder'] = false;
        if ($demoAddress === '' && $city !== '') { $demoAddress = $city; $gen['address_is_placeholder'] = true; }
        $gen['address'] = $demoAddress;

        // SOLO business? A one-person operation must never get a section of
        // fictional staff presented as "the team" (Sergei 2026-07-25). Primary
        // signal: the LLM's judgment; fallback: first-person-singular wording
        // in their own description with no plural business words.
        // WHAT THEY TOLD US BEATS WHAT THE MODEL GUESSED. The visitor picking "just me"
        // is the only reliable signal here; everything below is inference kept for
        // callers that ask nothing.
        $staffSaid = strtolower(trim((string) ($facts['staff'] ?? '')));
        $solo = $staffSaid !== '' ? ($staffSaid === 'solo') : !empty($gen['solo']);
        if ($staffSaid === '' && !array_key_exists('solo', $gen)) {
            $d = ' ' . strtolower($trade) . ' ';
            $solo = (bool) preg_match('/\b(i|my|myself)\b/', $d)
                 && !preg_match('/\b(we|our|us|team|staff|crew)\b/', $d);
        }

        // Elements the seeding decides this business does not need. Collected here
        // and filtered after the loop, because removing from an array you are
        // iterating by reference is how you get a half-walked tree.
        $dropIds = [];
        $workWallByParent = [];   // section id -> whisper-wall word, set by work_eyebrow

        // ── Illustratable services first ──────────────────────────────────────
        // The generator writes the services from the owner's own description, so it
        // can name something the photo library has never seen: a print shop got
        // "Window Graphics" and the card had to show a door sign instead (Sergei
        // 2026-08-01). The offer is not the problem, the PICTURE is, so reorder here:
        // services we can actually illustrate take the cards, the rest keep their
        // place in the price list, which lists every service regardless.
        //
        // Only when the pool carries subject tags. A pool without them scores nothing,
        // and reordering on an all-zero score would shuffle the list for no reason.
        $svcIllustratable = 0;
        if (!empty($gen['services']) && is_array($gen['services']) && $galleryPhotos) {
            $scored = [];
            $anyMatch = false;
            foreach ($gen['services'] as $i => $sv) {
                $nm  = is_array($sv) ? (string) ($sv['name'] ?? '') : (string) $sv;
                $hit = $nm !== '' && self::bestShotFor($nm, $galleryPhotos, [], $vertical) !== '';
                if ($hit) $anyMatch = true;
                $scored[] = [$hit ? 0 : 1, $i, $sv];        // illustratable first, order kept inside each group
            }
            if ($anyMatch) {
                usort($scored, static fn($a, $b) => [$a[0], $a[1]] <=> [$b[0], $b[1]]);
                $gen['services'] = array_map(static fn($r) => $r[2], $scored);
                $svcIllustratable = count(array_filter($scored, static fn($r) => $r[0] === 0));
            }
        }

        // ── Service cards: never a ragged last row ────────────────────────────
        // The card grid is three across, so four or five services leave one or two
        // cards stranded on a second row with a hole beside them (Sergei 2026-07-29:
        // "What we do should never have an uneven number of cards"). Drop to the
        // largest count that fills its rows exactly, and narrow the grid when the
        // business genuinely has one or two services so those sit as a full row of
        // their own rather than a third of one. The price list still lists them all:
        // this trims the PICTURE, never the offer.
        $svcValid = 0;
        foreach ((array) ($gen['services'] ?? []) as $sv) {
            if (is_array($sv) && trim((string) ($sv['name'] ?? '')) !== '') $svcValid++;
        }
        $svcValid  = min($svcValid, 6);
        // AND NEVER MORE CARDS THAN WE HAVE PICTURES FOR. A card whose service has no
        // matching photo has to borrow one, which is how "Window Graphics" ended up
        // under a door sign (Sergei 2026-08-01). Three is the floor: below that the
        // band looks thin, so a pool that can only illustrate one or two services
        // keeps the old behaviour and borrows. The price list is untouched either way.
        if ($svcIllustratable >= 3) $svcValid = min($svcValid, $svcIllustratable);
        // Cards may never show the same photograph twice, so the card count is
        // capped by the number of DISTINCT photos the trade pool holds. Plumbing
        // has three, so six cards was three photos shown twice.
        $cardShots = [];
        $svcValid  = min($svcValid, max(0, count($galleryPhotos)) ?: $svcValid);
        // Largest count that tiles with no hole: 6 (3x2), 4 (2x2), 3 (3x1), else
        // the count itself. Rounding 4 and 5 down to 3 hid real services for no
        // reason: 4 fills a 2x2 exactly (Sergei 2026-07-30 likes the fuller grid).
        $svcCards  = $svcValid >= 6 ? 6 : ($svcValid >= 4 ? 4 : $svcValid);
        $svcCols   = $svcCards === 6 ? 3 : ($svcCards === 4 ? 2 : ($svcCards === 3 ? 3 : max(1, $svcCards)));
        $svcRows   = $svcCards > 0 ? (int) ceil($svcCards / $svcCols) : 1;
        $svcGridId = '';
        foreach ($newEls as $e0) {
            if (str_starts_with((string) ($e0['settings']['demo_role'] ?? ''), 'svc_flip_')) {
                // Ids were remapped above but parent_id is still the TEMPLATE id at this
                // point, so this has to go through $idMap or it can never match an
                // element's (already new) id and the column write is silently skipped.
                $svcGridId = (string) ($idMap[(string) ($e0['parent_id'] ?? '')] ?? '');
                break;
            }
        }

        foreach ($newEls as &$el) {
            $pid = $el['parent_id'] ?? null;
            if ($pid !== null && $pid !== '' && isset($idMap[(string) $pid])) {
                $el['parent_id'] = $idMap[(string) $pid];
            }
            // The footer block carries the brand on its copyright line and has no
            // demo_role, so it was skipped by the guard below. Its own `site_name`
            // setting overrides the store name, so writing it here puts the tenant's
            // name on THEIR copy and leaves the shared renderer - and therefore
            // instinctor.com's own footer - completely untouched (Sergei 2026-07-26).
            if (($el['type'] ?? '') === 'footer' && $name !== '') {
                $el['settings']['site_name'] = $name;
            }

            // The services grid follows its card count, so the row is always full.
            if ($svcGridId !== '' && (string) ($el['id'] ?? '') === $svcGridId && $svcCards > 0) {
                $el['settings']['columns'] = $svcCols;
                $el['settings']['rows']    = $svcRows;
            }

            $role = $el['settings']['demo_role'] ?? '';
            if ($role === '') continue;
            $S = &$el['settings'];

            // Flip-box services (svc_flip_1..6): the SAME generated services the price
            // list seeds from, minus the price, which is the entire point of this view.
            // Prefix-matched rather than six switch cases.
            if (str_starts_with($role, 'svc_flip_')) {
                $idx = (int) substr($role, 9) - 1;
                // Past the count that fills the last row: drop, even though a
                // service exists for it.
                if ($svcCards > 0 && $idx >= $svcCards) { $dropIds[] = (string) $el['id']; continue; }
                $sv  = $gen['services'][$idx] ?? null;
                if (is_array($sv) && trim((string) ($sv['name'] ?? '')) !== '') {
                    // Flip renderers esc their fields — store RAW, same rule as pl_items.
                    $S['front_title']  = (string) $sv['name'];
                    $S['back_title']   = (string) $sv['name'];
                    $S['back_content'] = (string) ($sv['description'] ?? '');
                } elseif (!empty($gen['services'])) {
                    // NO SERVICE FOR THIS CARD, but the business does have some: drop it
                    // rather than leave the template's own words standing. A cleaning
                    // company that gave four services was showing "Inspection report" and
                    // "Upgrades and replacements", the contractor template's defaults,
                    // which read as plausible services it does not offer (Sergei
                    // 2026-07-28). Same rule the team cards already use.
                    //
                    // The guard matters: when the generator returns NOTHING, dropping
                    // every card leaves a headed section with nothing under it, which is
                    // worse than generic copy. In that case the template's cards stay.
                    $dropIds[] = (string) $el['id'];
                    continue;
                }
                // The card photo comes from the demo's OWN gallery pick (trade-first
                // ordering), so an electrician's cards show electrician work.
                // NO MODULO. This cycled the pool per card, and the pools are small:
                // three plumbing photos behind four cards meant "Leak Repair" and
                // "Whole-Home Repipe" carried the same photograph (Sergei 2026-07-30,
                // and visible in the very first frame he sent). $svcCards is now
                // capped by the number of distinct photos, so the index can never
                // run past the end; if it somehow does, the card goes without a
                // photo rather than repeating one.
                // MATCH THE PICTURE TO THE SERVICE NAME, not to the loop counter.
                // Index order is the shuffled pool order, so "Business Cards" drew a
                // door sign, "Banners & Yard Signs" drew business cards, and the vehicle
                // wrap photo we do own was never chosen at all (Sergei 2026-08-01).
                // Scored against the image's own subject tags; ties and misses fall back
                // to the next unused photo, so a card is never left blank and never
                // repeats one already used.
                $want = strtolower((string) ($sv['name'] ?? ''));
                $pick = self::bestShotFor($want, $galleryPhotos, $cardShots, $vertical);
                if ($pick === '' && isset($galleryPhotos[$idx])) $pick = (string) $galleryPhotos[$idx];
                if ($pick !== '') {
                    $S['front_image'] = $pick;
                    $cardShots[] = $pick;
                }
                continue;
            }

            switch ($role) {
                case 'business_name':                       // hero headline (text OR kinetic)
                    // The canonical masters carry the hero title as a KINETIC block
                    // (Sergei 2026-07-24), whose copy lives in `text` (+ optional
                    // custom_lines JSON), not `content`. Write the right key per type,
                    // preserving the first line's motion when custom_lines exist.
                    if (($el['type'] ?? '') === 'kinetic') {
                        // RAW, not escaped. The kinetic renderer esc_attr()s data-text and
                        // data-custom-lines and esc_html()s the crawlable span, so storing
                        // an escaped name double-encodes it: 'Hartley & Sons' rendered as
                        // 'Hartley &amp; Sons' in the hero headline itself, because the
                        // engine paints the visible title from those attributes
                        // (Sergei 2026-07-30). Applies to any name with & < > " '.
                        $S['text'] = $name;
                        $cl = json_decode((string) ($S['custom_lines'] ?? ''), true);
                        if (is_array($cl) && $cl) {
                            $cl[0]['text'] = $name;
                            $S['custom_lines'] = wp_json_encode(array_slice($cl, 0, 1));
                        }
                    } else {
                        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_block':
                    // The `team` block renders from the STAFF table, which a demo cannot
                    // have (no owner, so no staff rows). Seed its `members` prop instead:
                    // a prop, not a second copy, because staff wins the moment it exists
                    // — see impulse_team_members(). Same cast and the same portraits the
                    // flip-box cards used, so the demo looks identical either way.
                    $seed = [];
                    foreach (array_slice((array) ($gen['team'] ?? []), 0, $solo ? 1 : 6) as $ti2 => $m2) {
                        if (!is_array($m2)) continue;
                        $nm2 = trim((string) ($m2['name'] ?? ''));
                        // The owner told us their name at the entry form; the one card a
                        // solo business shows is THEM, so it carries their name and their
                        // portrait rather than the model's invented stand-in. The flip-box
                        // path already did this; the team block is what salon uses.
                        if ($ti2 === 0 && $solo && trim((string) ($facts['owner_name'] ?? '')) !== '') {
                            $nm2 = trim((string) $facts['owner_name']);
                        }
                        if ($nm2 === '') continue;
                        $seed[] = [
                            'name'      => $nm2,
                            // Solo: the one card is THE OWNER, whatever role the LLM gave.
                            'title'     => $solo ? 'Owner' : (string) ($m2['role'] ?? ''),
                            // Bio rides along: members with one render as flip cards.
                            'bio'       => (string) ($m2['bio'] ?? ''),
                            'photo_url' => self::portraitFor($nm2, $ti2, $trade, $profile, $soloPortrait, $womenShuf, $menShuf),
                        ];
                    }
                    // Solo with a transient LLM miss: never leave the template's
                    // three fictional staff standing - truncate the baked cast to
                    // one card and call them the owner.
                    if ($solo && !$seed) {
                        $baked = (array) json_decode((string) ($S['members'] ?? ''), true);
                        if (!empty($baked[0]) && is_array($baked[0])) {
                            $seed = [array_merge($baked[0], ['title' => 'Owner'])];
                        }
                    }
                    if ($seed) $S['members'] = wp_json_encode($seed);
                    break;

                // "The studio" / "Find the studio" over a barbershop or a nail salon.
                // The master's word is only right for the trades that happen to be
                // studios, so swap it for the one this business would use.
                case 'about_heading':
                case 'map_heading':
                    self::swapVenueWord($S, self::venueNoun($vertical, $trade, $name));
                    break;

                case 'team_heading':
                    // Solo: "Meet the team" over one person reads wrong. Kinetic-aware,
                    // same pattern as business_name.
                    if (!$solo) {
                        // "Meet the studio" means meet the people, so say that instead
                        // of naming the premises at all. A heading that already names a
                        // ROLE rather than the premises ("Meet your teacher") just needs
                        // pluralising: swapVenueWord finds no venue word and would have
                        // left a music school with four teachers reading "your teacher".
                        $cur = (string) ($S['text'] ?? '');
                        if (preg_match('/\b(teacher|instructor|coach|trainer|stylist|barber)\b/i', $cur, $rm)
                            && !preg_match('/\b' . $rm[1] . 's\b/i', $cur)) {
                            self::setHeading($S, $el, preg_replace('/\b(' . $rm[1] . ')\b/i', '$1s', $cur, 1));
                        } else {
                            self::swapVenueWord($S, 'team');
                        }
                        break;
                    }
                    // A heading that already names the ROLE is correct in the singular
                    // for a solo business: "Meet your teacher" over one music teacher
                    // needs nothing done to it, and replacing it with "Meet the owner"
                    // made a guitar teacher sound like a franchise (2026-07-28).
                    if ($solo && preg_match('/\b(teacher|instructor|coach|trainer|stylist|barber)\b/i',
                                            (string) ($S['text'] ?? ''))) {
                        break;
                    }
                    if ($solo) {
                        if (($el['type'] ?? '') === 'kinetic') {
                            $S['text'] = 'Meet the owner';
                            $cl = json_decode((string) ($S['custom_lines'] ?? ''), true);
                            if (is_array($cl) && $cl) { $cl[0]['text'] = 'Meet the owner'; $S['custom_lines'] = wp_json_encode(array_slice($cl, 0, 1)); }
                        } else {
                            self::setContent($S, 'Meet the owner');
                        }
                    }
                    break;
                case 'quote_heading':
                    // 'How can we help?' speaks as a company; a solo owner says I.
                    $curQ = (string) ($S['text'] ?? ($S['content'] ?? ''));
                    $newQ = $solo ? self::swapWordKeepCase($curQ, 'we', 'I')
                                  : self::swapWordKeepCase($curQ, 'I', 'we');
                    if ($newQ !== $curQ) {
                        if (($el['type'] ?? '') === 'kinetic') self::setHeading($S, $el, $newQ);
                        else self::setContent($S, $newQ);
                    }
                    break;
                case 'find_us':
                case 'find_us_eyebrow':
                    // The template speaks solo ("Where to find me"), and solo demos keep
                    // that line; a team demo swaps the pronoun. Symmetric, so a template
                    // authored in team voice ("VISIT US") self-corrects for solo too
                    // (Sergei 2026-07-28: 713 said "find me" over three teacher cards
                    // under a VISIT US eyebrow). Case-preserving: eyebrows are all-caps.
                    $curF = (string) ($S['text'] ?? ($S['content'] ?? ''));
                    $newF = $solo ? self::swapWordKeepCase($curF, 'us', 'me')
                                  : self::swapWordKeepCase($curF, 'me', 'us');
                    if ($newF !== $curF) {
                        if (($el['type'] ?? '') === 'kinetic') self::setHeading($S, $el, $newF);
                        else self::setContent($S, $newF);
                    }
                    break;
                case 'team_eyebrow':
                    // Same head-count rule as team_heading, for the caps line above it:
                    // YOUR INSTRUCTOR stays for a solo teacher, pluralises for a school;
                    // an OUR TEAM eyebrow over one person becomes THE OWNER.
                    $curE = trim(strip_tags((string) ($S['content'] ?? ($S['text'] ?? ''))));
                    if (!$solo && preg_match('/\b(teacher|instructor|coach|trainer|stylist|barber)\b/i', $curE, $em)
                        && !preg_match('/\b' . $em[1] . 's\b/i', $curE)) {
                        $suffix = (strtoupper($em[1]) === $em[1]) ? 'S' : 's';
                        self::setContent($S, preg_replace('/\b(' . $em[1] . ')\b/', '$1' . $suffix, $curE, 1));
                    } elseif ($solo && preg_match('/\bteam\b/i', $curE)) {
                        self::setContent($S, (strtoupper($curE) === $curE) ? 'THE OWNER' : 'The owner');
                    }
                    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
                    // A CARD WITH NOBODY IN IT IS DELETED, not left standing. Nothing
                    // hid an unfilled card, so a template with three of them and a
                    // two-person business published a stranger called "Your name",
                    // portrait and all. Only team_1 survives an empty generation, so a
                    // page always has a team section rather than a gap (2026-07-28).
                    // A SOLO BUSINESS GETS ONE CARD. The team BLOCK already sliced to one
                    // when solo; these flip cards never did, and the generator happily
                    // returns three entries for a one-person school, so a solo guitar
                    // teacher published three cards all named Ray Sandoval with three
                    // different bios (2026-07-28). Emptiness alone was not the test.
                    if ($ti > 0 && ($solo || empty($gen['team'][$ti]))) { $dropIds[] = (string) $el['id']; break; }
                    if (!empty($gen['team'][$ti]) && is_array($gen['team'][$ti])) {
                        $m  = $gen['team'][$ti];
                        $nm = (string) ($m['name'] ?? '');
                        // A solo owner who gave their name gets THEIR name on the card,
                        // not the model's invention, and the portrait follows from it
                        // because portraitFor reads front_title.
                        if ($ti === 0 && $solo && trim((string) ($facts['owner_name'] ?? '')) !== '') {
                            $nm = trim((string) $facts['owner_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.
                    $portrait = self::portraitFor((string) ($S['front_title'] ?? ''), $ti, $trade, $profile, $soloPortrait, $womenShuf, $menShuf);
                    if ($portrait !== '') {
                        // 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: a slightly-dark translucent overlay (no portrait of its own).
                        // With the 'slideup' effect it slides up from the bottom over the
                        // front portrait — which shows through the veil — and the bio reads on
                        // the darker foot. Not a flip. Sergei 2026-07-14.
                        $S['back_bg'] = 'linear-gradient(180deg,rgba(24,18,14,0.34),rgba(18,13,10,0.92))';
                        $S['animation_type'] = 'slideup';
                    }
                    break;
                case 'work_eyebrow':
                case 'work_heading':                        // the band over the gallery
                    // 'A look at our work' fits a contractor and reads wrong over a
                    // massage studio (Sergei 2026-07-29). Voice by archetype: a FACE
                    // or PLACE business shows where you will be, a PRODUCT business
                    // shows what it makes, DIGITAL shows recent projects.
                    $wMap = [
                        'face'    => ['INSIDE',   'A look inside'],
                        'place'   => ['THE SPACE', 'A look inside'],
                        'product' => ['THE WORK', 'What we make'],
                        'digital' => ['PROJECTS', 'Recent projects'],
                    ];
                    if (isset($wMap[$archetype])) {
                        if ($role === 'work_eyebrow') {
                            self::setContent($S, $wMap[$archetype][0]);
                            $workWallByParent[(string) ($el['parent_id'] ?? '')] = $wMap[$archetype][0];
                        }
                        elseif (false) {}
                        elseif (($el['type'] ?? '') === 'kinetic') self::setHeading($S, $el, $wMap[$archetype][1]);
                        else self::setContent($S, $wMap[$archetype][1]);
                    }
                    break;
                case 'gallery':                             // carousel — salon space + work shots
                    // NOT SEEDED WHERE THE POOL CANNOT MATCH THE TRADE. Instructor has
                    // thirteen sub-trades and one shared pool of 21 generic shots, so a
                    // karate dojo and a piano teacher drew their "recent work" from the
                    // same pictures. A wrong photo is the single loudest tell that a site
                    // came out of a template, louder than no photo at all (Sergei
                    // 2026-07-28). The carousel already renders nothing on a published
                    // page when empty and shows add-photo placeholders in the editor, so
                    // leaving it unseeded IS the "appears when they add photos" behaviour.
                    if ($vertical === 'instructor') { $galleryPhotos = []; }
                    if ($galleryPhotos) {
                        // The gallery is a CAROUSEL on older templates and a MARQUEE on
                        // installer, and they read different keys: images vs cards. Seeding
                        // only `images` left the installer demo's work strip empty, which
                        // would have recorded a video with no portfolio in it (2026-07-28).
                        if (($el['type'] ?? '') === 'marquee') {
                            $cards = [];
                            foreach ($galleryPhotos as $u) {
                                $cards[] = ['image' => $u, 'caption' => '', 'row' => 0];
                            }
                            $S['cards'] = wp_json_encode($cards);
                        } else {
                            $imgs = array_map(fn($u) => ['url' => $u, 'full_url' => $u], $galleryPhotos);
                            $S['images'] = wp_json_encode($imgs);
                        }
                        break;
                    }
                    // No photos means the heading above it would stand over nothing, so
                    // the whole section goes rather than leaving an empty titled band.
                    $galleryOwner = (string) ($el['parent_id'] ?? '');
                    if ($galleryOwner !== '') $dropIds[] = $galleryOwner;
                    break;
                case 'hero_bg':                             // hero section background: subtype hero, else the salon space
                    $hb = self::heroBackdropFor($vertical, $heroUrl, (array) $heroPhoto, (int) $seed);
                    if ($hb !== '') {
                        $S['bg_image'] = $hb;                                   // flat fallback (mobile / no-JS)
                        unset($S['bg_image_tablet']);
                        $mob = self::mobileVariantFor($hb);                     // dedicated portrait crop, if the set ships one
                        if ($mob !== '') $S['bg_image_mobile'] = $mob; else unset($S['bg_image_mobile']);
                        // Top-anchored: every rotation hero is an edge-subject portrait
                        // with the face in the top third; a centred cover-crop cuts the
                        // head off on wide/short viewports (Sergei 2026-07-24). The
                        // depth shader follows via data-depth-focal.
                        $S['bg_position'] = '50% 20%';
                        // Headline sits in THIS image's copyspace: the 9-dot tag
                        // (both axes) wins; else legacy h-align, template valign kept.
                        $ta = self::heroAlignTagFor($vertical, $hb);
                        if ($ta) { $S['content_align'] = $ta['h']; $S['valign'] = $ta['v']; }
                        else     { $S['content_align'] = self::heroAlignFor($vertical, $hb); }
                        // 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;
                            // Respect the master's canonical tuning (e.g. relief mode at
                            // strength 32, Sergei 2026-07-24): only default when unset.
                            // bg_depth_mode is deliberately never touched here.
                            if ((int) ($S['bg_depth_strength'] ?? 0) <= 0) $S['bg_depth_strength'] = 45;
                        } else {
                            unset($S['bg_depth_image'], $S['bg_depth_map']);
                        }
                        // The depth canvas paints OVER bg_image_mobile, so without its own
                        // pair the phone still showed the landscape crop (Sergei 2026-07-28:
                        // the hero must always use the vertical -m pair on mobile).
                        $mdm = ($mob !== '') ? self::depthMapFor($mob) : '';
                        if ($mob !== '' && $mdm !== '' && $dm !== '') {
                            $S['bg_depth_image_mobile'] = $mob;
                            $S['bg_depth_map_mobile']   = $mdm;
                        } else {
                            unset($S['bg_depth_image_mobile'], $S['bg_depth_map_mobile']);
                        }
                    }
                    break;
                case 'hero_eyebrow':                        // small caps line above the hero name ("CITY · TRADE")
                    // Read the NAME as well as the trade, exactly as the hero does.
                    // Without it "Bloom Lash Studio" whose trade said "hair salon" got a
                    // lash hero under a HAIR STUDIO eyebrow. Sergei spotted it 2026-07-16.
                    self::setContent($S, self::tradeEyebrow($trade . ' ' . $name, $city, $vertical));
                    break;
                case 'copyright':                           // footer line ("© NAME · CITY")
                    // There was no case for this at all, so every demo of every vertical
                    // shipped the TEMPLATE's fake name in its footer: a plumber called
                    // Keystone read "© Belle & Blush · Austin". It only ever looked right
                    // on the salon master, by coincidence, because Belle & Blush is its
                    // own placeholder.
                    self::setContent($S, '&copy; ' . self::escName($name) . ($city !== '' ? ' &middot; ' . self::escName($city) : ''));
                    break;
                case 'gift_headline':                       // gift-card heading — trade-appropriate ("good hair day" etc.)
                    // Name too, same reason: the gift card offered "a good hair day" at a lash studio.
                    self::setContent($S, 'Give the gift of ' . self::giftPhrase($trade . ' ' . $name, $vertical));
                    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':                       // hours + address card
                    // Two element generations share this role. The installer template
                    // (751) carries the `hours` BLOCK: on the demo it shows the editable
                    // address prompt (typing it re-keys the map and lands in address_seed
                    // via demoPersist); on a published site the owner's profile drives it
                    // from the dashboard. Hours are never seeded: owners set them once in
                    // Settings, which is the point of the block.
                    // Older templates (salon 526) still carry a TEXT element and keep the
                    // painted-HTML path.
                    // What they answered at the entry form, parked where publish can
                    // find it. Same trick as address_seed: the demo has no owner yet, so
                    // there is nowhere else to put a fact that must survive to the
                    // published site (2026-07-28).
                    if (!empty($facts['hours']['days'])) {
                        $S['hours_seed'] = wp_json_encode($facts['hours']);
                    }
                    if (($el['type'] ?? '') === 'hours') {
                        $S['address_placeholder'] = '1';
                    } else {
                        $hoursHtml = self::buildHoursHtml($gen, (array) ($facts['hours'] ?? []));
                        if ($hoursHtml !== '') self::setContent($S, $hoursHtml);
                    }
                    break;
                case 'reviews':                             // marquee — keeps template cards
                    // Was a Google Places overlay that never ran in production (the key
                    // was never configured; removed 2026-07-17 as redundant). Owners
                    // gather real reviews via /dashboard/reviews instead.
                    break;
                case 'service_area':
                    // WHERE THEY WORK, NOT WHERE THEY ARE. A trade travels: nobody visits
                    // a handyman's address, so this band lists the places they cover
                    // rather than a street (Sergei 2026-07-28). Philly Assembly reads
                    // "Philadelphia and all neighborhoods, Montgomery, Bucks, Delaware
                    // and Chester counties", which is local knowledge only the generator
                    // has. When it does not supply one, name their city honestly rather
                    // than inventing surrounding towns we cannot verify.
                    $area = trim((string) ($gen['service_area'] ?? ''));
                    if ($area === '' && $city !== '') {
                        $area = 'Serving ' . self::escName($city) . ' and the surrounding area. '
                              . 'Tell us where you are and we will confirm.';
                    }
                    if ($area !== '') self::setContent($S, $area);
                    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'] ?? ''),
                                // Same face as this member's flip-box card, not grey initials.
                                'photo_url' => self::portraitFor($mn, $i, $trade, $profile, $soloPortrait, $womenShuf, $menShuf),
                            ];
                        }
                    }
                    if ($bkStaff) $S['demo_staff'] = $bkStaff;
                    $S['demo_vertical'] = $vertical;
                    $S['title'] = 'Book at ' . $name;
                    unset($S['fields']);                        // drop leftover contact-form fields
                    break;

                case 'quote_form':                          // estimate block — home services ask for a price, not a slot
                    // A trade cannot offer a fixed slot before seeing the job, so the
                    // Installer template asks for a quote where the salon asks for a
                    // booking. The block's own defaults are already trade-neutral, so
                    // the only per-demo work is the heading and the dataset below.
                    // NO heading. The section already carries one ("Get a free quote"),
                    // and setting this rendered it TWICE on every demo: the block's copy
                    // sat directly under the section's, in the block's hardcoded dark
                    // text, on the section's dark brown. It read as a smudge. Only visible
                    // in a browser: both strings are in the HTML, so every curl passed.
                    //
                    // Blanking it on template 751 was not enough either, because this line
                    // put it straight back on every generated demo. The fix belongs where
                    // the value is AUTHORED, not where it is stored.
                    $S['heading'] = '';
                    // The sub-line is vertical-specific (a quote for a job vs an inquiry
                    // about a home), so it is authored in the registry with a {name} slot
                    // rather than branched here. Default keeps the quote-for-a-job wording
                    // Installer/Ingine rely on; real estate supplies its own inquiry line.
                    $subTpl = (string) V::cfg($vertical, 'quote.sub', 'Tell {name} about the job and they will come back to you with a price.');
                    // RAW name: the estimate renderer esc_html()s sub on output, so
                    // pre-escaping here printed 'Hartley &amp; Sons' on the live page
                    // for any business with an ampersand (Sergei 2026-07-30).
                    $S['sub'] = str_replace('{name}', $name, $subTpl);
                    // The section is dark, so the block's light-background defaults are
                    // wrong here. The sub is the only text it still renders.
                    $S['sub_color'] = '#c9bdb2';
                    // demo_services/demo_staff are carried for datasetForPage(), which
                    // builds the "your control panel" preview. It used to read them off
                    // booking_form only, so dropping booking here would have quietly
                    // emptied that panel for every Installer demo.
                    $qSvcs = [];
                    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'] ?? '') : '';
                            $qSvcs[] = [
                                'id'    => 9001 + $i,
                                'name'  => $nm,
                                'price' => (float) preg_replace('/[^0-9.]/', '', $pr),
                            ];
                        }
                    }
                    if ($qSvcs) $S['demo_services'] = $qSvcs;
                    $qStaff = [];
                    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;
                            $qStaff[] = [
                                'id'        => 8001 + $i,
                                'name'      => $mn,
                                'title'     => (string) ($m['role'] ?? ''),
                                'photo_url' => self::portraitFor($mn, $i, $trade, $profile, $soloPortrait, $womenShuf, $menShuf),
                            ];
                        }
                    }
                    if ($qStaff) $S['demo_staff'] = $qStaff;
                    $S['demo_vertical'] = $vertical;
                    unset($S['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);

        // Bands authored with a literal tint can be remapped to an accent slot by
        // the palette bake, which is how WHY US came out mid-blue on a warm site
        // (Sergei 2026-07-29). Transparent inherits the page, so it always agrees
        // with its neighbours whatever theme is drawn.
        foreach ($newEls as &$eB) {
            if (($eB['type'] ?? '') !== 'section' || (string) ($eB['parent_id'] ?? '') !== '') continue;
            $lab = '';
            foreach ($newEls as $eL) {
                if ((string) $eL['parent_id'] !== (string) $eB['id'] || ($eL['type'] ?? '') !== 'text') continue;
                $lab = mb_strtoupper(trim(strip_tags((string) ($eL['settings']['content'] ?? ''))));
                break;
            }
            if ($lab === 'WHY US' || $lab === 'HOW IT WORKS') $eB['settings']['bg_color'] = 'transparent';
        }
        unset($eB);

        // ── Profession profile (Sergei's approved matrix, 2026-07-29) ────────
        // The vertical picked the template; the profession decides what survives
        // on it. Silent profile = no change.
        $profWallIds = [];
        // A template may carry BOTH lead forms (generic 867 does), and every demo
        // keeps exactly one: the profession's choice, else the vertical's books flag.
        $leadPref = $profession !== '' ? (string) (V::prof($profession, 'lead') ?? '') : '';
        if ($leadPref === '') $leadPref = V::cfg($vertical, 'books', false) ? 'booking' : 'quote';
        $dualEst = null; $dualBook = null;
        foreach ($newEls as $iD => $eD) {
            $rD = $eD['settings']['demo_role'] ?? '';
            if ($rD === 'quote_form')   $dualEst = $iD;
            if ($rD === 'booking_form') $dualBook = $iD;
        }
        if ($dualEst !== null && $dualBook !== null) {
            $dropIds[] = (string) $newEls[$leadPref === 'booking' ? $dualEst : $dualBook]['id'];
        }
        // A template-born grid gallery carries placeholder children: EVERY demo
        // replaces them with its own pooled photos (profession-tagged when the
        // profile says so, archetype pool otherwise), profiled or not.
        foreach ($newEls as $iG => $eG) {
            if (($eG['settings']['demo_role'] ?? '') !== 'gallery' || ($eG['type'] ?? '') !== 'grid') continue;
            $gidT = (string) $eG['id'];
            foreach ($newEls as $eT) {
                if ((string) $eT['parent_id'] === $gidT && ($eT['type'] ?? '') === 'image') $dropIds[] = (string) $eT['id'];
            }
            if ($galleryPhotos) {
                // WHOLE ROWS ONLY. ceil() on an 8-photo pool asked for three rows and
                // left the ninth cell empty (Sergei 2026-07-30: "empty cells are not
                // allowed"). This is the path that actually runs when the template's
                // gallery is ALREADY a grid, which is every current template; the
                // profession rebuild further down only fires when it is not, so
                // fixing the rounding there alone changed nothing.
                $showT = min(12, (int) floor(count($galleryPhotos) / 3) * 3);
                if ($showT < 3) $showT = 0;
                $rowsT = (int) ($showT / 3);
                $newEls[$iG]['settings']['rows'] = max(1, $rowsT);
                for ($riT = 0; $riT < $rowsT; $riT++) $newEls[$iG]['settings']['row_height_' . $riT] = 250;
                $niT = 0;
                foreach (array_slice($galleryPhotos, 0, $showT) as $uT) {
                    $newEls[] = ['id' => $gidT . '-d' . ($niT + 1), 'parent_id' => $gidT, 'type' => 'image',
                                 'sort_order' => $niT, 'settings' => ['src' => $uT, 'border_radius' => 8, 'fill_container' => 1]];
                    $niT++;
                }
            }
        }
        if ($profession !== '') {
            // $seed is clobbered into an array by the team_block case above;
            // the profile draws from its own copy of the real seed.
            $profSeed = crc32($name);
            $galleryEl = null; $galleryOwnerId = '';
            $estimateEl = null; $bookingEl = null;
            $byId = [];
            foreach ($newEls as $i2 => $e2) $byId[(string) $e2['id']] = $i2;

            foreach ($newEls as $i2 => $e2) {
                $r2 = $e2['settings']['demo_role'] ?? '';
                if ($r2 === 'gallery')      { $galleryEl = $i2; $galleryOwnerId = (string) $e2['parent_id']; }
                if ($r2 === 'quote_form')   $estimateEl = $i2;
                if ($r2 === 'booking_form') $bookingEl = $i2;
            }

            // address:false drops the whole address/map band. The map's section is
            // found from the map element upward; grandchildren left orphaned by the
            // one-level drop filter never render (same as the gallery drop).
            if (V::prof($profession, 'address') === false) {
                foreach ($newEls as $e2) {
                    if (($e2['settings']['demo_role'] ?? '') !== 'map') continue;
                    $sec = (string) $e2['parent_id'];
                    for ($hops = 0; $hops < 4 && $sec !== '' && isset($byId[$sec]); $hops++) {
                        $p2 = (string) ($newEls[$byId[$sec]]['parent_id'] ?? '');
                        if ($p2 === '') break;
                        $sec = $p2;
                    }
                    if ($sec !== '') {
                        $dropIds[] = $sec;
                        // and every descendant, transitively: the one-level filter
                        // would strand grandchildren (the map inside the grid) as
                        // invisible orphan rows in the JSON.
                        $frontier = [$sec];
                        while ($frontier) {
                            $next = [];
                            foreach ($newEls as $e3) {
                                if (in_array((string) ($e3['parent_id'] ?? ''), $frontier, true)) {
                                    $dropIds[] = (string) $e3['id'];
                                    $next[] = (string) $e3['id'];
                                }
                            }
                            $frontier = $next;
                        }
                    }
                }
            }
            // hours:false drops a standalone hours band (verticals whose hours live
            // inside the address band are covered by the drop above).
            if (V::prof($profession, 'hours') === false) {
                foreach ($newEls as $e2) {
                    if (($e2['settings']['demo_role'] ?? '') !== 'hours_address') continue;
                    if (in_array((string) $e2['parent_id'], $dropIds, true)) continue;
                    $dropIds[] = (string) $e2['id'];
                }
            }

            // lead: which form survives when the template carries both is resolved
            // for EVERY demo below; here the profile only records its preference.
            $lead = (string) (V::prof($profession, 'lead') ?? '');
            if ($lead === 'booking' && $bookingEl !== null) {
                foreach ($newEls as &$e2) {
                    if (($e2['settings']['demo_role'] ?? '') === 'quote_heading') {
                        self::setHeading($e2['settings'], $e2, 'Book a session');
                    }
                }
                unset($e2);
            }

            // form: email/zip toggles on the quote form.
            if ($estimateEl !== null) {
                $f = (array) (V::prof($profession, 'form') ?? []);
                if (array_key_exists('email', $f)) $newEls[$estimateEl]['settings']['show_email'] = $f['email'] ? 1 : 0;
                if (array_key_exists('zip', $f))   $newEls[$estimateEl]['settings']['show_zip']   = $f['zip'] ? 1 : 0;
            }

            // reviews: the profession's own voice on the marquee cards.
            $pool = self::REVIEW_POOLS[(string) V::prof($profession, 'reviews', '')] ?? null;
            if ($pool) {
                foreach ($newEls as &$e2) {
                    if (($e2['settings']['demo_role'] ?? '') !== 'reviews') continue;
                    $cards = [];
                    foreach ($pool as $rv) {
                        $cards[] = ['name' => $rv[0], 'role' => 'Client', 'quote' => $rv[1], 'rating' => 5, 'row' => 0];
                    }
                    $e2['settings']['cards'] = wp_json_encode($cards);
                }
                unset($e2);
            }

            // about:third puts ABOUT directly after the hero slogan.
            if (V::prof($profession, 'about') === 'third') {
                foreach ($newEls as $e2) {
                    if (($e2['settings']['demo_role'] ?? '') !== 'about_text') continue;
                    $sec = (string) $e2['parent_id'];
                    if (isset($byId[$sec]) && ($newEls[$byId[$sec]]['parent_id'] ?? '') === '') {
                        $newEls[$byId[$sec]]['sort_order'] = 16;   // hero 10, slogan 15, services 18
                    }
                }
            }

            // gallery: wall-only heading, and grid conversion when tagged photos exist.
            if ($galleryEl !== null && $galleryOwnerId !== '' && isset($byId[$galleryOwnerId])) {
                $g = (array) (V::prof($profession, 'gallery') ?? []);
                if (($g['wall'] ?? '') !== '' && !in_array($galleryOwnerId, $dropIds, true)) {
                    foreach ($newEls as &$e2) {
                        if ((string) $e2['parent_id'] !== $galleryOwnerId) continue;
                        $t2 = $e2['type'] ?? '';
                        if ($t2 === 'text' || $t2 === 'kinetic') $dropIds[] = (string) $e2['id'];
                    }
                    unset($e2);
                    $newEls[$byId[$galleryOwnerId]]['settings']['tw_text'] = (string) $g['wall'];
                    $profWallIds[] = $galleryOwnerId;   // the adaptive echo below must not overwrite this
                }
                if (($g['block'] ?? '') === 'grid' && ($newEls[$galleryEl]['type'] ?? '') !== 'grid') {
                    $tagged = ($g['tag'] ?? '') !== '' ? self::poolPick($vertical, (string) $g['tag'], $profSeed, 12) : [];
                    if ($tagged) {
                        // Rebuild the portfolio as a grid gallery: the block a
                        // photographer's portfolio actually needs, with a lightbox.
                        $gid = $newEls[$galleryEl]['id'];
                        $newEls[$galleryEl]['type'] = 'grid';
                        // Even by construction: fixed row heights, stretch sizing,
                        // 12px gaps both axes, and fill-container children so a
                        // vertical shot crops into its cell instead of stretching
                        // the row (Sergei 2026-07-29: even, no matter what).
                        // ROUND DOWN to whole rows. ceil() meant 8 photos asked for
                        // 3 rows and left a hole in the ninth cell, which is the
                        // empty-cell rule Sergei set on 2026-07-30 ("6 = two rows of
                        // 3, 4 = two rows of 2, 3 = one row of 3, never a gap").
                        // Capped at 9 so a deep pool does not run four rows.
                        $tagged = array_slice($tagged, 0, min(9, (int) floor(count($tagged) / 3) * 3));
                        if (count($tagged) < 3) $tagged = [];
                        $rows3 = (int) ceil(count($tagged) / 3);
                        $gset = [
                            'columns' => 3, 'columns_mobile' => 2, 'rows' => $rows3,
                            'gap' => 12, 'gap_horizontal' => 12, 'gap_vertical' => 12,
                            'cell_sizing' => 'stretch', 'layout_mode' => 'grid',
                            'section_max_width' => 1200,
                            'images_lightbox' => 1, 'demo_role' => 'gallery',
                        ];
                        for ($ri = 0; $ri < $rows3; $ri++) $gset['row_height_' . $ri] = 250;
                        $newEls[$galleryEl]['settings'] = $gset;
                        $ni = 0;   // sort_order IS the cell index, zero-based
                        foreach ($tagged as $u) {
                            $newEls[] = ['id' => $gid . '-g' . ($ni + 1), 'parent_id' => $gid, 'type' => 'image',
                                         'sort_order' => $ni, 'settings' => ['src' => $u, 'border_radius' => 8, 'fill_container' => 1]];
                            $ni++;
                        }
                    }
                    // No tagged photos: the empty-gallery rule already drops the band,
                    // which is honest until the profession's real images land.
                }
            }
        }

        if ($dropIds) {
            // Drop the elements themselves and anything parented to them, so a
            // removed card cannot leave orphaned children behind.
            $newEls = array_values(array_filter($newEls, static function ($e) use ($dropIds) {
                return !in_array((string) ($e['id'] ?? ''), $dropIds, true)
                    && !in_array((string) ($e['parent_id'] ?? ''), $dropIds, true);
            }));
        }

        // Solo: the team section's whisper wall says THE TEAM; over one person
        // it becomes THE OWNER (same canon styling, honest words).
        if ($solo) {
            foreach ($newEls as &$el2) {
                if (($el2['type'] ?? '') === 'section' && trim((string) ($el2['settings']['tw_text'] ?? '')) === 'THE TEAM') {
                    $el2['settings']['tw_text'] = 'THE OWNER';
                }
            }
            unset($el2);
        }
        // The gallery band's whisper wall echoes its voiced eyebrow (INSIDE, THE
        // WORK...) instead of shouting OUR WORK over a band that no longer says
        // that (Sergei 2026-07-29: walls restored, words adapted).
        if ($workWallByParent) {
            foreach ($newEls as &$el3) {
                $sid = (string) ($el3['id'] ?? '');
                if (isset($workWallByParent[$sid]) && ($el3['type'] ?? '') === 'section'
                    && !in_array($sid, $profWallIds, true)
                    && trim((string) ($el3['settings']['tw_text'] ?? '')) !== '') {
                    $el3['settings']['tw_text'] = $workWallByParent[$sid];
                }
            }
            unset($el3);
        }
        // ── One heading per band ────────────────────────────────────────────
        // 'YOUR INSTRUCTOR' over 'Meet your teacher' says the same thing twice, and
        // the eyebrow is the one that survives as the whisper wall anyway, so the
        // kinetic keeps the words and the eyebrow goes (Sergei 2026-07-29: "it's
        // just noise"). Only when they genuinely restate each other.
        $dupEyebrows = [];
        foreach ($newEls as $eH) {
            if (($eH['settings']['demo_role'] ?? '') !== 'team_heading') continue;
            $secH = (string) $eH['parent_id'];
            $headWords = preg_split('/\s+/', mb_strtolower(trim(strip_tags((string) ($eH['settings']['text'] ?? '')))));
            foreach ($newEls as $eE) {
                if ((string) $eE['parent_id'] !== $secH || ($eE['type'] ?? '') !== 'text') continue;
                $eyeT = mb_strtolower(trim(strip_tags((string) ($eE['settings']['content'] ?? ''))));
                if ($eyeT === '') continue;
                $shared = array_intersect(preg_split('/\s+/', $eyeT), $headWords);
                // 'your instructor' vs 'meet your teacher': both name the role.
                if (count($shared) > 0 || preg_match('/instructor|teacher|owner|team/', $eyeT)) {
                    $dupEyebrows[] = (string) $eE['id'];
                }
                break;
            }
        }

        // ── The solo card says whose photo it wants ──────────────────────────
        // The hero shows the owner and the card showed a stock stranger, with
        // nothing saying the picture is a placeholder: 'some Daniel a guitarist in
        // hero, some math teacher in instructor section' (Sergei 2026-07-29).
        if ($solo) {
            foreach ($newEls as &$eP) {
                if (($eP['settings']['demo_role'] ?? '') !== 'team_1') continue;
                $sP = &$eP['settings'];
                $whoP = trim((string) ($sP['front_content'] ?? ''));
                $sP['back_title']   = 'Your photo goes here';
                $sP['back_content'] = 'Tap this card to upload a picture of yourself. Until then it shows a placeholder'
                                    . ($whoP !== '' ? ', not ' . $whoP : '') . '.';
                unset($sP);
            }
            unset($eP);
        }

        // ── One card, one column ────────────────────────────────────────────
        // A solo business keeps a single team card, and in a three-column grid it
        // sat in cell 1: hard left of an otherwise empty band (Sergei 2026-07-29).
        // Collapsing the track count centres it without touching the card.
        $teamCards = [];
        foreach ($newEls as $eT) {
            if (preg_match('/^team_[0-9]+$/', (string) ($eT['settings']['demo_role'] ?? ''))) {
                $teamCards[(string) $eT['parent_id']][] = (string) $eT['id'];
            }
        }
        foreach ($newEls as &$eG) {
            $gidS = (string) $eG['id'];
            if (!isset($teamCards[$gidS])) continue;
            $nCards = count($teamCards[$gidS]);
            if ($nCards >= 3) continue;
            $eG['settings']['columns'] = $nCards;
            $eG['settings']['rows'] = 1;
            $eG['settings']['grid_halign'] = 'center';
            // The per-column width hints describe a 3-track grid; they would
            // stretch one card across the band.
            foreach (array_keys($eG['settings']) as $kG) {
                if (strpos($kG, 'col_width_') === 0) unset($eG['settings'][$kG]);
            }
            if ($nCards === 1) $eG['settings']['section_max_width'] = 420;
        }
        unset($eG);

        // Whisper walls are placed AND coloured by whoever authored the template.
        // A contrast pass here overrode a deliberate 5% with 10% and turned the wall
        // into a visual dominant (Sergei 2026-07-30: "I never set that obvious
        // colour"). The generator adapts a wall's WORDS to the business and nothing
        // else: not its placement, not its colour, not its size.

        // ── One card, one column ────────────────────────────────────────────
        // A solo business keeps a single team card, and in a three-column grid it
        // sat in cell 1: hard left of an otherwise empty band (Sergei 2026-07-29).
        // Collapsing the track count centres it without touching the card.
        $teamCards = [];
        foreach ($newEls as $eT) {
            if (preg_match('/^team_[0-9]+$/', (string) ($eT['settings']['demo_role'] ?? ''))) {
                $teamCards[(string) $eT['parent_id']][] = (string) $eT['id'];
            }
        }
        foreach ($newEls as &$eG) {
            $gidS = (string) $eG['id'];
            if (!isset($teamCards[$gidS])) continue;
            $nCards = count($teamCards[$gidS]);
            if ($nCards >= 3) continue;
            $eG['settings']['columns'] = $nCards;
            $eG['settings']['rows'] = 1;
            $eG['settings']['grid_halign'] = 'center';
            // The per-column width hints describe a 3-track grid; they would
            // stretch one card across the band.
            foreach (array_keys($eG['settings']) as $kG) {
                if (strpos($kG, 'col_width_') === 0) unset($eG['settings'][$kG]);
            }
            if ($nCards === 1) $eG['settings']['section_max_width'] = 420;
        }
        unset($eG);

        // Whisper walls are placed AND coloured by whoever authored the template.
        // A contrast pass here overrode a deliberate 5% with 10% and made the wall a
        // visual dominant (Sergei 2026-07-30: "I never set that obvious colour").
        // The generator adapts a wall's WORDS to the business and nothing else:
        // not its placement, not its colour, not its size.

        // The dedupe above only collects; this is what removes them. Appending to
        // $dropIds cannot work here: that filter ran further up the function.
        if ($dupEyebrows) {
            $newEls = array_values(array_filter($newEls, static function ($e) use ($dupEyebrows) {
                return !in_array((string) ($e['id'] ?? ''), $dupEyebrows, true);
            }));
        }

        // SOLO SPEAKS AS I, EVERYWHERE THE PAGE SPEAKS AS WE. The visitor clicked
        // 'Just me' on camera and then read What WE do, How WE help, Who WE are on
        // every band (Sergei 2026-07-29). Short display strings only: kinetic
        // headings, eyebrows, whisper walls. Body copy stays the generator's.
        if ($solo) {
            foreach ($newEls as &$el4) {
                $S4 = &$el4['settings'];
                if (!is_array($S4)) continue;
                foreach (['text', 'content', 'tw_text'] as $k4) {
                    if (!isset($S4[$k4]) || !is_string($S4[$k4])) continue;
                    $plain = trim(strip_tags($S4[$k4]));
                    if ($plain === '' || mb_strlen($plain) > 42) continue;             // headings, not paragraphs
                    if (!preg_match('/\b(we|our|us)\b/i', $plain)) continue;
                    $out = self::soloVoice($plain);
                    if ($out === $plain) continue;
                    if ($k4 === 'text' && ($el4['type'] ?? '') === 'kinetic') self::setHeading($S4, $el4, $out);
                    else $S4[$k4] = ($k4 === 'content') ? str_replace($plain, $out, $S4[$k4]) : $out;
                }
            }
            unset($el4, $S4);
        }

        // ── Settings: keep the template's cinematic config, add demo flags ──
        $settings = is_array($src['settings'] ?? null) ? $src['settings'] : [];
        $settings['is_demo'] = 1;
        // Attribution travels with the demo from birth: copied onto the owner at
        // claim, so acquisition source survives the demo's own deletion.
        if (!empty($facts['utm']) && is_array($facts['utm'])) $settings['demo_utm'] = $facts['utm'];
        $settings['demo_trade'] = $trade;
        $settings['demo_business_name'] = $name;
        $settings['demo_city'] = $city;
        $settings['demo_url'] = $url;
        // Optional recovery contact: if the visitor told us where to send their site,
        // store it so runDemoDunning() can nudge them to publish before the demo is
        // purged. Never required (the demo works anonymously).
        $email = strtolower(trim($email));
        if ($email !== '' && filter_var($email, FILTER_VALIDATE_EMAIL)) $settings['contact_email'] = $email;
        $phone = trim($phone);
        if ($phone !== '') $settings['contact_phone'] = preg_replace('/[^0-9+]/', '', $phone);
        // 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;
        // Business archetype (face/place/product/digital) drives the hero + gallery on
        // verticals that define archetype sets (the generic builder). Stored so the demo
        // overlay can show a switcher pre-selected to the current pick.
        $archeSets = (array) V::cfg($vertical, 'heroes.sets', []);
        if ($archetype !== '' && (!empty($archeSets['face']) || !empty($archeSets['place']) || !empty($archeSets['product']) || !empty($archeSets['digital']))) {
            $settings['demo_archetype'] = $archetype;
        }

        // Seeded look pack (palette + reveal rotation) — after every role transform
        // so it recolors the FINAL tree, before the insert so the stored page and
        // the overlay's palette baseline agree from the first render.
        // crc32 recomputed here on purpose: the staff-seeding branch reuses the
        // $seed VARIABLE as its rows array (line ~901), so by this point $seed is
        // an int only on quote-first verticals. Same derivation as the photo seed.
        [$newEls, $settings] = self::applyLookPack($newEls, $settings, $vertical, crc32($name), V::professionFor($trade . ' ' . $name));

        // 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.
        // CARRY THE STAFFING ANSWER FORWARD. "Just me" was collected on the entry
        // form and then used only to decide how many portraits to seed, so by the
        // time the dashboard rendered there was nothing left to ask and it showed a
        // solo guitar teacher a "Your team" tile promising names and photos for a
        // team of one (Sergei 2026-07-31). Stamped here, read at publish, stored
        // against the owner.
        $settings['demo_solo'] = $solo ? 1 : 0;

        // Build-time correctness gate (generate mode: silent auto-fix). Runs
        // AFTER applyLookPack because the hex swap + band reorder are exactly
        // what introduce same-tone text, broken zebra, and dead CTAs; the
        // 2026-08 fleet audit measured 4592 rendered contrast violations that
        // existed only because nothing checked the final tree before insert.
        $lint = PageLinter::run($newEls, $settings, ['fix' => true, 'vertical' => $vertical, 'trade' => $trade]);
        $newEls = $lint['elements'];
        if ($lint['violations']) {
            $settings['lint_report'] = ['n' => count($lint['violations']), 'fixed' => $lint['fixed'], 'at' => gmdate('c')];
        }

        $t = DB::prefix() . 'instinctor_pages';
        $slug = 'demo-' . substr(md5($name . '|' . $base . '|' . random_int(1, PHP_INT_MAX)), 0, 12);
        // role='tenant': a demo is somebody's site-to-be, and the admin grid
        // files demos with the tenants. Stamped here because this insert
        // deliberately bypasses PageService::create() (and therefore its
        // deriveRole), which is exactly how demos kept landing unclassified.
        DB::query(DB::prepare(
            "INSERT INTO {$t} (owner_id, name, slug, status, type, role, elements, settings, created_at, updated_at)
             VALUES (NULL, %s, %s, 'draft', 'page', 'tenant', %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'];

        // Opportunistic, cron-free cleanup: sweep abandoned demos at most once an
        // hour so unpublished drafts don't pile up and inflate the DB. Only touches
        // is_demo drafts with no owner; published/claimed pages are never affected.
        if (!get_transient('instinctor_demo_purge_ran')) {
            set_transient('instinctor_demo_purge_ran', 1, 3600);
            self::purgeOldDemos(1);   // 24h: an unpublished preview is a tire kicker
        }

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

    /**
     * Re-pick a demo's hero + gallery for a chosen business archetype (the switcher on
     * the demo overlay). Deterministic (seeded by the business name), so it matches what
     * a fresh build with that archetype would produce. Only meaningful on verticals with
     * archetype hero sets (the generic builder). Returns the new hero + gallery URLs.
     */
    public static function applyArchetype(int $pageId, string $archetype): array
    {
        if (!in_array($archetype, ['face', 'place', 'product', 'digital'], true)) return ['error' => 'bad_archetype'];
        $src = PageService::getById($pageId);
        if (!$src) return ['error' => 'not_found'];
        $settings = is_array($src['settings'] ?? null) ? $src['settings'] : [];
        if (empty($settings['is_demo'])) return ['error' => 'not_demo'];

        $name     = (string) ($settings['demo_business_name'] ?? '');
        $trade    = (string) ($settings['demo_trade'] ?? '');
        $vertical = self::verticalFor($trade);
        $seed     = crc32($name);

        $heroUrl      = self::heroUrlFor($vertical, $trade, $seed, $name, $archetype, $archForced);
        $heroPhoto    = self::poolPick($vertical, 'interiors', $seed ^ 0x1234, 1);
        // Same rule as the other build path: an interior is never a hero. Two places
        // computed this independently, so fixing one left the other serving room shots.
        $heroBackdrop = self::heroBackdropFor($vertical, $heroUrl, (array) $heroPhoto, (int) $seed);
        $gallery      = self::galleryPhotosFor($trade, $seed ^ 0x2ab1, $vertical, $archetype);

        $els = is_array($src['elements'] ?? null) ? $src['elements'] : [];
        foreach ($els as &$e) {
            $role = $e['settings']['demo_role'] ?? '';
            $S = &$e['settings'];
            if ($role === 'hero_bg' && $heroBackdrop !== '') {
                $S['bg_image'] = $heroBackdrop;
                unset($S['bg_image_tablet']);
                $mob = self::mobileVariantFor($heroBackdrop);
                if ($mob !== '') $S['bg_image_mobile'] = $mob; else unset($S['bg_image_mobile']);
                $S['bg_position'] = '50% 20%';   // face-safe top anchor (see hero_bg writer above)
                $ta = self::heroAlignTagFor($vertical, $heroBackdrop);
                if ($ta) { $S['content_align'] = $ta['h']; $S['valign'] = $ta['v']; }
                else     { $S['content_align'] = self::heroAlignFor($vertical, $heroBackdrop); }
                $dm = self::depthMapFor($heroBackdrop);
                if ($dm !== '') {
                    $S['bg_depth_image'] = $heroBackdrop; $S['bg_depth_map'] = $dm;
                    if ((int) ($S['bg_depth_strength'] ?? 0) <= 0) $S['bg_depth_strength'] = 45;   // keep master's tuning
                } else { unset($S['bg_depth_image'], $S['bg_depth_map']); }
                // Same rule as the hero_bg writer above: the depth canvas covers
                // bg_image_mobile, so mobile needs its own -m pair or the phone
                // keeps showing the landscape crop.
                $mdm = ($mob !== '') ? self::depthMapFor($mob) : '';
                if ($mob !== '' && $mdm !== '' && $dm !== '') {
                    $S['bg_depth_image_mobile'] = $mob; $S['bg_depth_map_mobile'] = $mdm;
                } else { unset($S['bg_depth_image_mobile'], $S['bg_depth_map_mobile']); }
            } elseif ($role === 'gallery' && $gallery) {
                $S['images'] = wp_json_encode(array_map(fn($u) => ['url' => $u, 'full_url' => $u], $gallery));
            } elseif (str_starts_with((string) $role, 'svc_flip_') && $gallery) {
                $idx = (int) substr((string) $role, 9) - 1;
                $S['front_image'] = (string) $gallery[$idx % count($gallery)];
            }
            unset($S);
        }
        unset($e);

        PageService::saveElements($pageId, $els);
        $settings['demo_archetype']    = $archetype;
        $settings['cinematic_backdrop'] = $heroBackdrop;   // pinned hero backdrop mirror
        PageService::saveSettings($pageId, $settings);

        return ['success' => true, 'archetype' => $archetype, 'hero' => $heroBackdrop, 'gallery' => array_values($gallery)];
    }

    /**
     * Delete abandoned demo pages: is_demo drafts with no owner, older than $days
     * (default 1 = 24h). A demo that was claimed/published has owner_id set (or is a
     * separate published page), so this can never remove a real site. The demo token
     * is just a signed page id, so removing the row fully invalidates it. Returns
     * rows deleted. Run hourly from bin/purge-demos.php so the 24h window holds even
     * when no new demos are being created to trigger the opportunistic sweep above.
     */
    /**
     * The curated palette packs. SINGLE SOURCE: templates/demo-overlay.php emits
     * these to the browser as the palette chips, and applyLookPack() pre-bakes one
     * of them at generation. Ten slots per theme, matching demo_palette exactly.
     * Curated, never generated: a pack that is not on this list cannot ship.
     */
    public const THEMES = [
        ['name' => 'Warm',  'hex' => ['#f6f1ea', '#f5f5f5', '#efe7de', '#2a231e', '#b06a4f', '#95573f', '#8a7d72', '#5a534c', '#e6dccf', '#eee6db']],
        ['name' => 'Sage',  'hex' => ['#eef1ea', '#f1f3ee', '#e9ede3', '#262b24', '#7c8a6a', '#616e52', '#86917c', '#565f4e', '#dbe1d3', '#e8ede2']],
        ['name' => 'Slate', 'hex' => ['#eef1f4', '#f1f4f6', '#e6ebef', '#1f2833', '#4f6d84', '#3b5468', '#7c8894', '#4d5966', '#d6dde4', '#e6ebf0']],
        ['name' => 'Rose',  'hex' => ['#f8f0f1', '#f9f3f4', '#f1e6ea', '#2e2427', '#b16678', '#965262', '#9a838a', '#5f4f54', '#ecdce0', '#f3e9ec']],
        ['name' => 'Noir',  'hex' => ['#f2f2f0', '#f5f5f5', '#eae8e4', '#161616', '#2e2e2e', '#000000', '#777777', '#4a4a4a', '#dedede', '#eaeaea']],
        ['name' => 'Gold',  'hex' => ['#f7f3ea', '#f8f5ee', '#efe7d6', '#26221a', '#ad8a4c', '#927138', '#8b8171', '#574f3f', '#e7dfcd', '#efe9db']],
    ];

    /** Reveal presets a look pack may deal, with each effect's tuned intensity. */
    // No mosaic here: Sergei allows it on a carousel only, and this pack poses
    // whole sections (the reviews band kept drawing it, 2026-07-29).
    private const REVEAL_PACK = ['cards' => 45, 'focus' => 70, 'orbit' => 55, 'blinds' => 50];

    /**
     * Curated band orders (Phase 2, 2026-08-01). Each pack RANKS the movable
     * bands; a kind the master does not have simply never appears. Pack 0 is the
     * master's own order, so the authored composition still ships on ~1 in 5.
     * Curated, never generated: same contract as THEMES.
     *
     * WHAT IS NOT HERE IS THE POINT. The hero is never ranked (it opens the page),
     * neither is the lead form (quote/booking is the conversion point and belongs
     * where the template author put it, at the foot), and neither is anything this
     * classifier cannot positively name. Those bands keep their exact slot.
     */
    private const ORDER_PACKS = [
        [],                                                                                        // 0 = master's own
        ['services' => 10, 'reviews' => 20, 'work' => 30, 'about' => 40, 'team' => 50, 'hours' => 60],
        ['about' => 10, 'services' => 20, 'work' => 30, 'reviews' => 40, 'team' => 50, 'hours' => 60],
        ['services' => 10, 'work' => 20, 'reviews' => 30, 'team' => 40, 'about' => 50, 'hours' => 60],
        ['work' => 10, 'services' => 20, 'about' => 30, 'reviews' => 40, 'team' => 50, 'hours' => 60],
    ];

    /** demo_role markers that name a movable band, innermost role => band kind. */
    private const BAND_ROLES = [
        'service_list' => 'services', 'about_text' => 'about', 'reviews'  => 'reviews',
        'gallery'      => 'work',     'map'        => 'hours',  'hours_address' => 'hours',
        'team_heading' => 'team',
    ];

    /**
     * Seeded look pack: same seed, same look; different business, different look.
     *
     * Twenty-eight videos on one template read as twenty-eight copies unless the
     * generator varies the LOOK the way it already varies the words (Sergei
     * 2026-07-29). Two axes here, both curated:
     *
     *  - Palette: one of THEMES, pre-baked through the exact remap the overlay's
     *    palette chips perform client-side (hex + rgb() forms, alpha kept), then
     *    stored as demo_palette so the chips keep working from this baseline.
     *    Pick 0 keeps the master's own palette, so the canon look still appears.
     *  - Reveals: each root section that already opted into a 3D reveal draws its
     *    effect from REVEAL_PACK. Bands the template left plain stay plain.
     *
     * Gated by the registry's 'variants' flag: salon stays on the 526 canon.
     * Heading treatment and band order are Phase 2.
     */
    private static function applyLookPack(array $els, array $settings, string $vertical, int $seed, string $profession = ''): array
    {
        // PINS RUN FOR EVERY VERTICAL, before the variants gate. They are a
        // correctness rule, not a look-pack feature: salon has variants off (526 is
        // locked canon) and its booking band kept the template's effect, so the one
        // Sergei named was the one place the rule did not reach (2026-07-29).
        $pinned = [];                       // ROOT section id => effect
        $parentOf = [];
        foreach ($els as $eP) $parentOf[(string) $eP['id']] = (string) ($eP['parent_id'] ?? '');
        // Walk to the ROOT section: a form can sit inside a grid or a nested
        // container (526's booking does), and keying on its immediate parent left
        // that band on whatever the template carried.
        $rootOf = function (string $id) use ($parentOf): string {
            for ($hop = 0; $hop < 6; $hop++) {
                $up = $parentOf[$id] ?? '';
                if ($up === '') return $id;
                $id = $up;
            }
            return $id;
        };
        foreach ($els as $eR) {
            $role = $eR['settings']['demo_role'] ?? '';
            if ($role === 'reviews')                                $pinned[$rootOf((string) $eR['id'])] = 'focus';
            if ($role === 'quote_form' || $role === 'booking_form')  $pinned[$rootOf((string) $eR['id'])] = 'orbit';
        }
        foreach ($els as &$el) {
            if (($el['type'] ?? '') !== 'section') continue;
            if (!empty($el['parent_id'])) continue;
            if (empty($el['settings']['ovfx'])) continue;               // plain by design
            $want = $pinned[(string) $el['id']] ?? '';
            if ($want === '') continue;                                 // template's own choice stands
            $el['settings']['ovfx']       = $want;
            $el['settings']['ovfx_p1']    = self::REVEAL_PACK[$want];
            $el['settings']['ovfx_speed'] = 5;
            $el['settings']['ovfx_ease']  = 'spring';
        }
        unset($el);
        if (!V::cfg($vertical, 'variants', false)) return [$els, $settings];
        // A mono profession is not a colour choice, it is a convention: a
        // photographer's site is black and white. Lock the palette to Noir and
        // tell the overlay to hide the chips (Sergei 2026-07-29).
        $profPal = $profession !== '' ? (string) (V::prof($profession, 'palette') ?? '') : '';
        $mono    = $profPal === 'mono';

        // NO REVEAL ROTATION (Sergei 2026-07-29: 'never rotate 3d reveal settings').
        // Rotating the effect kept dealing the wrong one into the wrong band: mosaic
        // everywhere, then the reviews band, then the quote form. Each section keeps
        // the reveal its template author chose, and the two bands Sergei named are
        // pinned: reviews FOCUS (defocus to sharp), the lead form ORBIT. Look variety
        // now comes from the palette pack and the profession's own content, not from
        // shuffling effects.

        // Palette pre-bake. Slot count is the contract: no 10-slot base, no swap.
        $base = $settings['demo_palette'] ?? null;
        $pick = $seed % (count(self::THEMES) + 1);                      // 0 = master's own palette
        if ($mono) {
            foreach (self::THEMES as $ti => $t) { if ($t['name'] === 'Noir') { $pick = $ti + 1; break; } }
            $settings['demo_palette_locked'] = 1;
        } elseif ($profPal !== '') {
            // A NAMED THEME PINS THE PROFESSION. Some trades have a colour the customer
            // expects and the seeded rotation cannot know: an HVAC company reads as navy,
            // not sage green (Sergei 2026-07-31). Same mechanism as mono, without the
            // chip lock, so the visitor can still recolour if they disagree.
            foreach (self::THEMES as $ti => $t) {
                if (strcasecmp($t['name'], $profPal) === 0) { $pick = $ti + 1; break; }
            }
        }

        if (is_array($base) && count($base) === 10 && $pick > 0) {
            $theme = self::THEMES[$pick - 1]['hex'];
            $json  = (string) wp_json_encode($els);
            foreach ($base as $slot => $from) {
                $to = $theme[$slot];
                if (strcasecmp((string) $from, $to) === 0) continue;
                $json = (string) preg_replace('/' . preg_quote((string) $from, '/') . '/i', $to, $json);
                $fr = sscanf((string) $from, '#%02x%02x%02x');
                $tr = sscanf($to, '#%02x%02x%02x');
                if (count($fr) === 3 && count($tr) === 3) {
                    $json = (string) preg_replace(
                        '/(rgba?\()\s*' . $fr[0] . '\s*,\s*' . $fr[1] . '\s*,\s*' . $fr[2] . '\s*(,[^)]*)?(\))/i',
                        '${1}' . $tr[0] . ', ' . $tr[1] . ', ' . $tr[2] . '${2}${3}',
                        $json
                    );
                }
            }
            // EXTRA SLOTS. Mesh-gradient stops and other hand-picked colours are not
            // in the 10-slot palette, so the swap above leaves them exactly as
            // authored: template 751's HOW IT WORKS band carries a slate-blue mesh
            // stop, and it stayed blue on a green plumber (Sergei 2026-07-30). The
            // demo overlay's chip swap already solves this client-side by keeping a
            // colour's own S/L and rotating its hue by the delta the ACCENT moved;
            // this is that rule, applied server-side so the pre-baked page agrees.
            // Greys and whites (S < 0.08) are left alone, which is what keeps text,
            // borders and paper white from drifting.
            $hueOf = static function (string $hex): ?array {
                if (!preg_match('/^#([0-9a-f]{6})$/i', $hex)) return null;
                [$r, $g, $b] = array_map(static fn($c) => $c / 255, sscanf(substr($hex, 1), '%2x%2x%2x'));
                $mx = max($r, $g, $b); $mn = min($r, $g, $b); $l = ($mx + $mn) / 2;
                if ($mx === $mn) return [0.0, 0.0, $l];
                $dd = $mx - $mn;
                $s = $l > 0.5 ? $dd / (2 - $mx - $mn) : $dd / ($mx + $mn);
                $h = $mx === $r ? (($g - $b) / $dd + ($g < $b ? 6 : 0)) : ($mx === $g ? ($b - $r) / $dd + 2 : ($r - $g) / $dd + 4);
                return [$h * 60, $s, $l];
            };
            $toHex = static function (float $h, float $s, float $l): string {
                $h = fmod(fmod($h, 360) + 360, 360) / 360;
                $f = static function ($p, $q, $t) {
                    if ($t < 0) $t += 1; if ($t > 1) $t -= 1;
                    if ($t < 1 / 6) return $p + ($q - $p) * 6 * $t;
                    if ($t < 1 / 2) return $q;
                    if ($t < 2 / 3) return $p + ($q - $p) * (2 / 3 - $t) * 6;
                    return $p;
                };
                if ($s == 0.0) { $r = $g = $b = $l; }
                else {
                    $q = $l < 0.5 ? $l * (1 + $s) : $l + $s - $l * $s;
                    $pp = 2 * $l - $q;
                    $r = $f($pp, $q, $h + 1 / 3); $g = $f($pp, $q, $h); $b = $f($pp, $q, $h - 1 / 3);
                }
                return sprintf('#%02x%02x%02x', (int) round($r * 255), (int) round($g * 255), (int) round($b * 255));
            };
            $fromA = $hueOf((string) ($base[4] ?? ''));
            $toA   = $hueOf($theme[4] ?? '');
            if ($fromA && $toA) {
                $delta = $toA[0] - $fromA[0];
                // Noir's accent is #2e2e2e, which has no hue at all, so hueOf reports 0
                // and the delta becomes "rotate everything to red": the HOW IT WORKS mesh
                // came out warm brown on a strictly greyscale site. When the target accent
                // is achromatic the palette has no hue to track, so the honest move is to
                // drain the extra colours to match rather than aim them at hue zero.
                $mono  = $toA[1] < 0.08;
                $known = array_map('strtolower', array_merge($base, $theme));
                $json  = (string) preg_replace_callback('/#[0-9a-fA-F]{6}/', static function ($m) use ($known, $hueOf, $toHex, $delta, $mono) {
                    $hex = strtolower($m[0]);
                    if (in_array($hex, $known, true)) return $m[0];      // a slot: already swapped
                    $hsl = $hueOf($hex);
                    if (!$hsl || $hsl[1] < 0.08) return $m[0];           // grey/white: leave it
                    if ($mono) return $toHex($hsl[0], 0.0, $hsl[2]);     // greyscale palette: drain, do not rotate
                    return $toHex($hsl[0] + $delta, $hsl[1], $hsl[2]);
                }, $json);
            }

            $decoded = json_decode($json, true);
            if (is_array($decoded)) {                                   // never trade a working tree for a color
                $els = $decoded;
                $settings['demo_palette'] = $theme;
                $settings['demo_theme']   = $pick - 1;
            }
        }

        // ── Band order (Phase 2). Colour alone was not enough: twenty-eight sites
        // that differ only by palette still read as one template. This permutes only
        // the bands the classifier can NAME, and only within the slots those bands
        // already own, so every other section keeps its exact sort_order. The hero,
        // the lead form and anything unnamed are structurally untouched.
        $bandPick = $seed % count(self::ORDER_PACKS);
        $settings['demo_band_order'] = $bandPick;
        $pack = self::ORDER_PACKS[$bandPick];
        if ($pack) {
            $kindOf = [];                                               // ROOT section id => band kind
            foreach ($els as $eB) {
                $r = (string) ($eB['settings']['demo_role'] ?? '');
                if ($r === '') continue;
                $k = self::BAND_ROLES[$r] ?? (str_starts_with($r, 'svc_flip_') ? 'services' : '');
                if ($k === '') continue;
                // Same rootOf walk the reveal pins use: a marker can sit several
                // containers deep, and keying on the immediate parent misses it.
                $root = $rootOf((string) $eB['id']);
                if (!isset($kindOf[$root])) $kindOf[$root] = $k;        // first namer wins
            }
            $movable = [];
            $seq = 0;
            foreach ($els as $i => $eS) {
                if (($eS['type'] ?? '') !== 'section' || !empty($eS['parent_id'])) continue;
                $k = $kindOf[(string) $eS['id']] ?? '';
                if ($k === '' || !isset($pack[$k])) continue;
                $movable[] = ['so' => (int) ($eS['sort_order'] ?? 0), 'rank' => $pack[$k], 'seq' => $seq++, 'i' => $i];
            }
            $slots = array_column($movable, 'so');
            // DISTINCT SLOTS ARE THE WHOLE MECHANISM. If a master never set
            // sort_order on its root sections they all read 0, the page falls back to
            // id order, and reassigning would scramble rather than reorder. Skip
            // instead: a demo that looks like the master beats a demo that is broken.
            if (count($movable) > 1 && count(array_unique($slots)) === count($slots)) {
                sort($slots);
                usort($movable, static fn($a, $b) => [$a['rank'], $a['seq']] <=> [$b['rank'], $b['seq']]);
                foreach ($movable as $n => $m) $els[$m['i']]['sort_order'] = $slots[$n];
            } else {
                $settings['demo_band_order'] = 0;                        // record what actually shipped
            }
        }

        return [$els, $settings];
    }

    public static function purgeOldDemos(int $days = 1): int
    {
        $t      = DB::prefix() . 'instinctor_pages';
        $cutoff = gmdate('Y-m-d H:i:s', time() - max(0, $days) * 86400);
        // Triple fence: draft + ownerless + is_demo. The is_demo marker is the ONLY thing
        // separating an abandoned demo from a VENDOR draft (also ownerless): never write
        // is_demo:1 into any page that is not a DemoService-minted demo, or this DELETE
        // becomes eligible to eat it two days later.
        // Registry templates are excluded by ID, not by trusting their flags. Three
        // of them sat in trash because they were built via the demo cloner and kept
        // its is_demo marker, which made them indistinguishable from abandoned demos
        // to every sweep. The flags are cleaned now, but a guard that survives the
        // next accidentally-stamped template is cheaper than the next recovery
        // (Sergei 2026-07-29).
        $keep   = \Instinctor\Core\Verticals::templateIds();
        $keepIn = $keep ? ' AND id NOT IN (' . implode(',', array_map('intval', $keep)) . ')' : '';
        $n = DB::query(DB::prepare(
            "DELETE FROM {$t}
              WHERE status = 'draft' AND owner_id IS NULL
                AND settings LIKE %s
                AND created_at < %s" . $keepIn,
            '%"is_demo":1%',
            $cutoff
        ));
        return $n === false ? 0 : (int) $n;
    }

    /**
     * Publish guard. A claimed demo whose owner never filled in a real address still
     * carries the muted "Add your business address" prompt in its Find-us block. When the
     * demo is baked into a live site we must not leak that prompt to visitors, so replace
     * the placeholder span with the city (the map already centers there) — or drop its
     * text if no city is known. No-op when the demo has a real address. Wording-agnostic:
     * it matches the imp-addr-ph marker, not the exact prompt copy.
     */
    /**
     * Empty every demo-seeded gallery on the way to a published site.
     *
     * Only touches elements carrying demo_role=gallery, so a photo a visitor added
     * themselves during the demo is untouched: those arrive through demoPersist with
     * their own upload URLs, not from the preset pool.
     */
    private static function stripStockGallery(string $elsJson): string
    {
        if (strpos($elsJson, '"gallery"') === false) return $elsJson;
        $els = json_decode($elsJson, true);
        if (!is_array($els)) return $elsJson;

        $presets = '#/storage/uploads/[a-z0-9-]+-presets/#i';
        $walk = function (&$nodes) use (&$walk, $presets) {
            foreach ($nodes as &$n) {
                if (!is_array($n)) continue;
                $s = $n['settings'] ?? [];
                if (is_array($s) && (string) ($s['demo_role'] ?? '') === 'gallery') {
                    // BOTH shapes. A carousel gallery stores `images`; a marquee stores
                    // `cards`, and only `images` was ever stripped, so an installer
                    // demo published OUR preset photographs onto a real tradesman's
                    // live site as their own finished work (found 2026-07-30 while
                    // testing the publish path, not reported).
                    foreach (['images', 'cards'] as $key) {
                        if (empty($s[$key])) continue;
                        $rows = is_string($s[$key]) ? json_decode($s[$key], true) : $s[$key];
                        if (!is_array($rows)) continue;
                        $kept = [];
                        foreach ($rows as $im) {
                            $u = '';
                            if (is_array($im)) {
                                $u = (string) ($im['url'] ?? $im['image'] ?? '');
                            } else {
                                $u = (string) $im;
                            }
                            // A preset path is ours; anything else the visitor supplied.
                            if ($u !== '' && !preg_match($presets, $u)) $kept[] = $im;
                        }
                        $n['settings'][$key] = $kept ? wp_json_encode(array_values($kept)) : '[]';
                    }
                }
                foreach (['children', 'elements'] as $ck) {
                    if (!empty($n[$ck]) && is_array($n[$ck])) $walk($n[$ck]);
                }
            }
            unset($n);
        };
        $walk($els);
        return (string) wp_json_encode($els);
    }

    public static function bakePublishedElements(string $elsJson, bool $keepStock = false): string
    {
        // STOCK WORK PHOTOS ARE FOR THE DEMO, NOT FOR THEIR SITE. The pooled gallery
        // shots exist so a recording has a portfolio to show and so the demo does not
        // look half-built. Carrying them onto a published site means every contractor
        // launches with the same photographs, which is the template smell we spent the
        // day removing everywhere else (Sergei 2026-07-28).
        //
        // The carousel renders nothing when empty on a published page, and the work
        // strips show titled placeholders, so what the owner gets is an obvious empty
        // shelf with their service names on it, which is what makes them upload.
        // Recording tenants (autopilot+<slug>@instinctor.com) keep the stock
        // photos: the take publishes and then films the live site, and an empty
        // gallery shelf on camera reads as a broken product, not an invitation
        // (Sergei 2026-07-29). Real owners still publish clean.
        if (!$keepStock) {
            $elsJson = self::stripStockGallery($elsJson);
        } else {
            // Recording tenants keep their photos, so the band has content and the
            // empty-band guard must not hide it (Sergei 2026-07-30: 'recent jobs,
            // we completely hide that section?'). The guard reads the services
            // table, which is empty on a fresh tenant whatever the marquee holds.
            $elsK = json_decode($elsJson, true);
            if (is_array($elsK)) {
                foreach ($elsK as &$eK) {
                    if (($eK['settings']['requires'] ?? '') === 'service_photos') unset($eK['settings']['requires']);
                }
                unset($eK);
                $elsJson = (string) wp_json_encode($elsK);
            }
        }

        if (strpos($elsJson, 'imp-addr-ph') === false) return $elsJson;  // fast path: no prompt
        $els = json_decode($elsJson, true);
        if (!is_array($els)) return $elsJson;

        // City = the map element's query (DemoService set it to the city on the fallback).
        $city = '';
        $findCity = function ($nodes) use (&$findCity, &$city) {
            foreach ($nodes as $n) {
                if ($city !== '') return;
                if (($n['type'] ?? '') === 'map') {
                    $s = $n['settings'] ?? [];
                    foreach (['map_query', 'address_query', 'address'] as $k) {
                        if ($city === '' && !empty($s[$k])) $city = trim((string) $s[$k]);
                    }
                }
                if (!empty($n['children'])) $findCity($n['children']);
                if (!empty($n['elements'])) $findCity($n['elements']);
            }
        };
        $findCity($els);

        $cityHtml = $city !== '' ? htmlspecialchars($city, ENT_QUOTES, 'UTF-8') : '';
        $repl = function (&$nodes) use (&$repl, $cityHtml) {
            foreach ($nodes as &$n) {
                if (isset($n['settings']) && is_array($n['settings'])) {
                    foreach (['content', 'text', 'content_mobile', 'content_tablet'] as $k) {
                        if (isset($n['settings'][$k]) && is_string($n['settings'][$k])
                            && strpos($n['settings'][$k], 'imp-addr-ph') !== false) {
                            // Match the exact prompt TEXT so only UNTOUCHED placeholders are
                            // swapped — an owner who edited the line keeps their own value.
                            // Address -> the city; hours -> "By appointment" (safe default).
                            $n['settings'][$k] = preg_replace(
                                '#<span data-demo-address class="imp-addr-ph"[^>]*>Add your business address</span>#',
                                '<span data-demo-address>' . $cityHtml . '</span>',
                                $n['settings'][$k]
                            );
                            $n['settings'][$k] = preg_replace(
                                '#<div class="imp-addr-ph"[^>]*data-demo-hours[^>]*>Add your opening hours</div>#',
                                '<div style="color:#5a534c;font-size:15px;line-height:1.6">By appointment</div>',
                                $n['settings'][$k]
                            );
                        }
                    }
                }
                if (!empty($n['children'])) $repl($n['children']);
                if (!empty($n['elements'])) $repl($n['elements']);
            }
            unset($n);
        };
        $repl($els);

        return function_exists('wp_json_encode') ? wp_json_encode($els) : json_encode($els);
    }

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

    /**
     * The hours_address text-block HTML for OLD text-element templates (salon 526).
     *
     * An editable hours prompt plus the address line: the city from the demo form, or
     * the muted add-your-address prompt. Owners fill both in on the demo; the publish
     * guard swaps an untouched hours prompt for "By appointment". (This used to overlay
     * real Google Places hours and phone, an integration that never ran in production
     * and was removed 2026-07-17: what this renders now is what it always rendered.)
     */
    /** "Mon to Fri, 9:00am to 5:00pm" from the answered days, collapsing runs. */
    public static function hoursLine(array $hours): string
    {
        $days = (array) ($hours['days'] ?? []);
        if (!$days) return '';
        $order = ['mon' => 'Mon', 'tue' => 'Tue', 'wed' => 'Wed', 'thu' => 'Thu',
                  'fri' => 'Fri', 'sat' => 'Sat', 'sun' => 'Sun'];
        $keys = array_values(array_filter(array_keys($order), static fn($k) => in_array($k, $days, true)));
        if (!$keys) return '';
        // Consecutive days read as a range; anything else is listed.
        $idx = array_map(static fn($k) => array_search($k, array_keys($order), true), $keys);
        $runs = []; $start = $idx[0]; $prev = $idx[0];
        foreach (array_slice($idx, 1) as $i) {
            if ($i === $prev + 1) { $prev = $i; continue; }
            $runs[] = [$start, $prev]; $start = $prev = $i;
        }
        $runs[] = [$start, $prev];
        $names = array_values($order);
        $parts = array_map(
            static fn($r) => $r[0] === $r[1] ? $names[$r[0]] : $names[$r[0]] . ' to ' . $names[$r[1]],
            $runs
        );
        $fmt = static function (string $t): string {
            $ts = strtotime('2000-01-01 ' . $t);
            return $ts ? strtolower(date('g:ia', $ts)) : $t;
        };
        return implode(', ', $parts) . ', ' . $fmt((string) ($hours['open'] ?? ''))
             . ' to ' . $fmt((string) ($hours['close'] ?? ''));
    }

    private static function buildHoursHtml(array $gen, array $hours = []): string
    {
        $address = (string) ($gen['address'] ?? '');
        if ($address === '') return '';

        $html  = '<div style="text-align:left">';
        // Owners are non-technical: always show the hours prompt so they know it exists.
        $html .= '<div style="font-weight:700;color:#2a231e;font-size:16px;margin-bottom:8px">Hours</div>';
        $line = self::hoursLine($hours);
        $html .= $line !== ''
            ? '<div data-demo-hours style="color:#5a534c;font-size:15px;line-height:1.6">' . self::escName($line) . '</div>'
            : '<div class="imp-addr-ph" data-ph="Add your opening hours" data-demo-hours style="color:#5a534c;font-size:15px;line-height:1.6">Add your opening hours</div>';
        // Address is its OWN leaf element (no <br> inside) tagged data-demo-address: the
        // demo overlay makes it inline-editable and re-keys the map from it, so the
        // address is edited ONCE here.
        if (!empty($gen['address_is_placeholder'])) {
            // Only the city is known: a muted, editable "add your address" prompt rather
            // than a bare city. The map still centers on the city; editing re-keys it.
            $html .= '<div style="margin-top:20px;color:#5a534c;font-size:15px;line-height:1.6"><span data-demo-address class="imp-addr-ph" data-ph="Add your business address">Add your business address</span></div>';
        } else {
            $html .= '<div style="margin-top:20px;color:#5a534c;font-size:15px;line-height:1.6"><span data-demo-address>' . self::escName($address) . '</span></div>';
        }
        return $html . '</div>';
    }

    /**
     * Single-source business data for a PUBLISHED owner page. Called at frontend
     * render time (not demos, not the editor) so the address/hours/map always reflect
     * the owner's dashboard BusinessProfile — the dashboard is the one place a tenant
     * edits these. Mirrors the demo_role injection, sourced from BusinessProfile and
     * WITHOUT the inline-edit markers (data-demo-address / imp-addr-ph), so the address
     * is never editable on the page.
     *
     * @param array $elements Element tree from PageService::getElements().
     * @return array The same tree with hours_address + map filled from the profile.
     */
    public static function applyOwnerBusinessData(array $elements, int $ownerId): array
    {
        if ($ownerId <= 0 || !class_exists('\\Instinctor\\Services\\BusinessProfile')) return $elements;

        $bp      = \Instinctor\Services\BusinessProfile::all($ownerId);
        $bizName = trim((string) ($bp['business_name'] ?? ''));
        $address = trim((string) ($bp['address'] ?? ''));
        $city    = trim((string) ($bp['city'] ?? ''));
        $phone   = trim((string) ($bp['phone'] ?? ''));
        $hoursRaw = trim((string) ($bp['hours'] ?? ''));
        $loc     = $address !== '' ? $address : $city;                 // street wins, else city
        $hours   = $hoursRaw !== ''
            ? array_values(array_filter(array_map('trim', preg_split('/\r\n|\r|\n/', $hoursRaw))))
            : [];

        foreach ($elements as &$el) {
            $role = $el['settings']['demo_role'] ?? '';
            $type = $el['type'] ?? '';
            if ($role === 'hours_address' && ($el['type'] ?? '') !== 'hours') {
                // Text-generation elements only. The hours BLOCK reads BusinessProfile
                // live at render, which is the whole point of it: painting HTML into its
                // content here would write a value the block never reads.
                $html = self::buildOwnerHoursHtml($hours, $loc, $phone);
                if ($html !== '') self::setContent($el['settings'], $html);
            } elseif (($role === 'business_name' || $role === 'footer_name') && $bizName !== '') {
                // The published owner page must show the OWNER's name, not the template's
                // demo salon ("Belle & Blush"). Hero headline + footer both carry a role.
                self::setContent($el['settings'], self::escName($bizName));
            } elseif ($role === 'copyright' && $bizName !== '') {
                $cityShort = $city !== '' ? trim(explode(',', $city)[0]) : '';
                self::setContent($el['settings'], '&copy; ' . self::escName($bizName) . ($cityShort !== '' ? ' &middot; ' . self::escName($cityShort) : ''));
            } elseif (($type === 'map' || $role === 'map') && $loc !== '') {
                // No lat/lng on these template maps, so the address string drives the
                // embed query (see element-renderers/map.php). Keep any existing lat/lng.
                $el['settings']['address'] = $loc;
            }
        }
        unset($el);
        return $elements;
    }

    /**
     * Hours/address/phone block for a live owner page: real values only, no editable
     * placeholders and no data-demo-* markers (address lives in the dashboard now).
     * Returns '' when there is nothing real to show, so the template default stays.
     */
    private static function buildOwnerHoursHtml(array $hours, string $address, string $phone): string
    {
        if (!$hours && $address === '' && $phone === '') return '';

        $html  = '<div style="text-align:left">';
        $html .= '<div style="font-weight:700;color:#2a231e;font-size:16px;margin-bottom:8px">Hours</div>';
        if ($hours) {
            // Owners type free-form lines (one per day, e.g. "Tue-Fri 9am-7pm"), so render
            // each line as-is rather than forcing a day/time two-column split.
            foreach ($hours as $line) {
                $html .= '<div style="color:#5a534c;font-size:15px;line-height:1.7">' . self::escName((string) $line) . '</div>';
            }
        } else {
            $html .= '<div style="color:#5a534c;font-size:15px;line-height:1.6">By appointment</div>';
        }
        if ($address !== '') {
            $html .= '<div style="margin-top:20px;color:#5a534c;font-size:15px;line-height:1.6">' . self::escName($address) . '</div>';
        }
        if ($phone !== '') {
            $html .= '<div style="color:#5a534c;font-size:15px;line-height:1.6">' . self::escName($phone) . '</div>';
        }
        return $html . '</div>';
    }

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