<?php
declare(strict_types=1);

namespace Instinctor\Core;

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

/**
 * The vertical registry: ONE place that knows what a vertical is.
 *
 * Adding a vertical used to mean editing four files (DemoService constants + its
 * seeding branch, demo-entry.php's $VMAP, and a hand-written route in Plugin.php),
 * and the code was hardcoded two-deep: verticalFor() literally answered "instructor"
 * or else "salon". Three verticals could not land on that without a merge fight.
 *
 * Now: add an entry here, drop in a template page and an image pool, and the route,
 * the entry screen, the trade matching, the image seeding, the eyebrow and the gift
 * phrase all follow. No new branches anywhere.
 *
 * SHAPE of an entry:
 *   template  int     page id of the hand-built, demo_role-tagged template
 *   route     string  URL of the entry screen ("type your name, watch it build")
 *   seed      string  seeding profile: 'team' (gender-matched portraits, multi-card)
 *                     or 'solo' (one portrait). This is the only BEHAVIOUR switch;
 *                     everything else below is data.
 *   family    array   trade keywords that resolve here. Matched in registry order,
 *                     so earlier verticals win a tie (salon before instructor keeps
 *                     "beauty school" out of the lessons template).
 *   pool      array   base, layout ('subdir' = base/<cat>/<n>.webp, 'flat' =
 *                     base/<n>.webp), cats (category => numbers), team (curated
 *                     distinct-person subset, 'team' profile only)
 *   heroes    array   layout ('subdir' = base/heroes/<sub>/<sub>-<n>.webp, 'flat' =
 *                     base/<n>.webp), sets (subtype => numbers), map (keyword => subtype)
 *   gallery   array   mode 'trade_map' (map trade keyword => category sequence) or
 *                     'category' (always pull N from one category)
 *   eyebrow   array   default + keyword => LABEL
 *   gift      array   default + keyword => phrase (completes "Give the gift of ___")
 *   entry     array   copy for the entry screen (brand, title, placeholders)
 *
 * @package Instinctor
 */
final class Verticals
{
    /** Trade with no family match, and the launch default. */
    public const FALLBACK = 'generic';

    private const REG = [
        'salon' => [
            'template' => 526,
            'route'    => '/instudio',
            'seed'     => 'team',

            'family' => [
                'salon', 'hair', 'hairdresser', 'hairstylist', 'stylist', 'barber', 'barbershop',
                'nail', 'nails', 'manicure', 'pedicure', 'lash', 'lashes', 'brow', 'brows',
                'makeup', 'beauty', 'spa', 'facial', 'wax', 'waxing', 'esthetic', 'esthetician', 'tan', 'tanning',
                    // Massage is a booking business with fixed prices: it was
                    // falling through to the generic quote-first template, which
                    // asked massage clients to request a QUOTE (Sergei 2026-07-29).
                    'massage', 'masseuse', 'masseur', 'bodywork',
                // Beauty trades that were not recognised as salons at all and therefore
                // got no vertical, no template and no imagery (2026-07-26).
                'colorist', 'colourist', 'braid', 'braids', 'braiding', 'blow dry', 'blowout',
                'updo', 'extensions', 'weave', 'locs', 'blowdry', 'grooming lounge',
            ],

            'pool' => [
                'base'   => '/storage/uploads/salon-presets/',
                'layout' => 'subdir',
                'cats'   => [
                    // 24 removed 2026-07-25: the carousel crops it to a bare
                    // shoulder, which is not what 'recent looks' should show.
                    'women'     => [2,3,5,6,9,11,12,13,15,16,17,19,21,23],
                    'men'       => [25,28,30,31,32,33,35,36,37,38,39,40,42],
                    'nails'     => [44,45,48,50,51,53,55,58,59,60,62,63],
                    'brows'     => [64,66,68,70,72,74,76,78,79,80,81,83,84],
                    'makeup'    => [86,87,88,89,90,91,92,93,94,96,97,99,100,102],
                    'interiors' => [103,105,106,107,110,111,113,114],
                ],
                // Curated DISTINCT people: the full pool has near-duplicate AI shots of
                // the same model, which read as "the same person" across team cards.
                'team' => [
                    // 7 dropped with 24: same model, and the team card is where she
                    // surfaced most (Sergei 2026-07-26).
                    'women' => [2, 5, 9, 12, 13],
                    'men'   => [25, 28, 32, 35, 38],
                ],
            ],

            'heroes' => [
                'layout' => 'subdir',
                'sets'   => [
                    // Registry vs disk, reconciled 2026-07-26. These ids are not only
                    // what heroSubtypeFor may pick: imagePool() builds the swap pool
                    // from them, and catOf() gates the whole photo swapper on a URL
                    // being IN that pool. A hero on disk but missing here therefore
                    // renders fine and cannot be changed by the visitor: nails-4 was
                    // live on demos with no way to swap it (Sergei found it asking for
                    // the hero gallery).
                    // brow-4 is the brow bar's own PAIRED hero (2026-07-27). Until it
                    // existed the brow bar borrowed makeup-4, because brow-1..3 have no
                    // -m mobile file and so are not heroes at all, only swap-pool photos.
                    'nails' => [1,2,3,4], 'lash' => [1,3,4], 'brow' => [1,2,3,4],
                    'makeup' => [1,2,3,4], 'hair' => [1,2,3,4,5], 'spa' => [1,2,3,4],
                    // 2026-07-25 batch: dedicated barber/tanning/esthetics heroes
                    // (tags.json is authoritative; these ids gate heroSubtypeFor).
                    'barber' => [1], 'tanning' => [1], 'esthetics' => [1],
                    // The general salon hero: an owner on her own floor, for a business
                    // that is a beauty salon rather than any one treatment. Paired, so it
                    // counts as a real hero (2026-07-27).
                    'salon' => [1],
                ],
                // Per-hero headline alignment (DemoService::heroAlignFor). Default is
                // 'center'; list only the images whose copyspace is clearly off to one
                // side so the headline lands in the open area, not on the subject.
                // First pass (verify visually): hair-2/3 have a clean right column;
                // lash-1's client reclines left, leaving the right open.
                'align' => [
                    'hair' => [2 => 'right', 3 => 'right'],
                    'lash' => [1 => 'right'],
                ],
                'map' => [
                    'nail' => 'nails', 'lash' => 'lash', 'brow' => 'brow', 'makeup' => 'makeup',
                    // Before 'facial': a men's grooming lounge that mentions facials is
                    // still a barber shop, and first match wins by MAP ORDER.
                    'mens grooming' => 'barber', 'grooming lounge' => 'barber',
                    'mens cuts' => 'barber', 'beard' => 'barber',
                    'facial' => 'spa', 'spa' => 'spa', 'massage' => 'spa',
                    'wax' => 'esthetics', 'esthetic' => 'esthetics',
                    'tan' => 'tanning',
                    // Men's grooming is a BARBER, not a spa. 'grooming' alone belongs to
                    // pet grooming in another vertical, so match the fuller phrases.
                    'barber' => 'barber',
                    // LASER HAIR REMOVAL IS NOT A HAIR SALON. It matched 'hair' and showed
                    // a stylist's chair to a clinic, so it has to be caught first
                    // (2026-07-26). Same for the trades that had no route at all:
                    // colourists, braiders and blow dry bars are all hair salons.
                    'laser hair' => 'esthetics', 'hair removal' => 'esthetics',
                    'colorist' => 'hair', 'colourist' => 'hair', 'braid' => 'hair',
                    'blow dry' => 'hair', 'blowout' => 'hair', 'updo' => 'hair',
                    'extensions' => 'hair', 'weave' => 'hair', 'locs' => 'hair',
                    'hair' => 'hair', 'stylist' => 'hair', 'hairdresser' => 'hair',
                    // CATCH-ALL, and it must stay LAST: first match wins by map order, so
                    // "nail salon" reaches 'nail' long before this. A plain beauty salon
                    // falls through to here and gets the general owner-on-her-floor hero
                    // instead of borrowing another treatment's (2026-07-27).
                    'beauty salon' => 'salon', 'beauty' => 'salon', 'salon' => 'salon',
                ],
            ],

            'gallery' => [
                'mode'    => 'trade_map',
                'default' => ['women', 'makeup', 'interiors', 'women'],
                'map' => [
                    'nail'   => ['nails', 'nails', 'nails', 'interiors'],
                    'lash'   => ['brows', 'makeup', 'brows', 'interiors'],
                    'brow'   => ['brows', 'brows', 'makeup', 'interiors'],
                    'makeup' => ['makeup', 'makeup', 'makeup', 'interiors'],
                    'barber' => ['men', 'men', 'interiors', 'women'],
                    'hair'   => ['women', 'women', 'interiors', 'women'],
                    'salon'  => ['women', 'women', 'interiors', 'makeup'],
                    'spa'    => ['interiors', 'women', 'makeup', 'interiors'],
                    'facial' => ['interiors', 'makeup', 'women', 'interiors'],
                    'wax'    => ['interiors', 'women', 'makeup', 'interiors'],
                ],
            ],

            'eyebrow' => [
                'default' => 'BEAUTY STUDIO',
                // First key wins: SPECIFIC before generic, or "tanning salon" reads
                // HAIR STUDIO off the 'salon' key. matchMap is whole-word, so
                // 'barbershop' needs its own key ('barber' never matches it).
                'map' => [
                    'barbershop' => 'BARBERSHOP', 'barber' => 'BARBERSHOP',
                    'tan' => 'TANNING STUDIO', 'tanning' => 'TANNING STUDIO',
                    'wax' => 'WAXING STUDIO', 'waxing' => 'WAXING STUDIO', 'esthetic' => 'SKIN STUDIO',
                    'nail' => 'NAIL SALON', 'lash' => 'LASH STUDIO', 'brow' => 'BROW STUDIO',
                    'makeup' => 'MAKEUP STUDIO', 'hair' => 'HAIR STUDIO',
                    'salon' => 'HAIR STUDIO', 'spa' => 'DAY SPA', 'facial' => 'SKIN STUDIO',
                ],
            ],

            'gift' => [
                'default' => 'a little self-care',
                'map' => [
                    'barbershop' => 'a sharp new look', 'barber' => 'a sharp new look',
                    'tan' => 'a healthy glow', 'tanning' => 'a healthy glow',
                    'wax' => 'silky smooth skin', 'waxing' => 'silky smooth skin', 'esthetic' => 'glowing skin',
                    'nail' => 'a fresh new set', 'lash' => 'a beautiful set of lashes',
                    'brow' => 'perfect brows', 'makeup' => 'a flawless look',
                    'hair' => 'a good hair day', 'salon' => 'a good hair day', 'spa' => 'a day of calm',
                    'facial' => 'glowing skin',
                ],
            ],

            // Card on the /free orientation screen. Paid ads (Craigslist) carry ONE
            // link, and this is where it lands: the visitor picks their trade and we
            // hand them to the right builder. blurb is deliberately a list of TRADES,
            // not a description: people scan for their own job title, not for prose.
            'chooser' => [
                'label' => 'Salon and beauty',
                'blurb' => 'Hair, nails, lashes, brows, barbering, makeup, spa, waxing, tattoo, massage',
            ],

            'entry' => [
                'trade' => 'salon', 'brand' => 'InStudio',
                'title' => 'InStudio: see your salon site | Instinctor',
                'eyebrow' => 'for your <b>salon</b>',
                'name_ph' => 'Your business name',
                'trade_ph' => 'What you do and what makes you you. e.g. balayage &amp; lived-in color, a warm little East Austin studio, walk-ins welcome. The more you tell us, the more your site sounds like you.',
                'trade_aria' => 'Describe your salon: your services, your style, what makes you different. The more detail, the more unique your site copy.',
                'domain_ph' => 'yoursalon.com',
            ],
        ],

        'instructor' => [
            // Seeded look packs (palette + reveal rotation) apply to this
            // vertical's demos. Salon stays on the 526 canon; ingine/instate
            // join once their masters are reviewed (Sergei 2026-07-29).
            'variants' => true,
            'template' => 713,
            'route'    => '/instructor',
            'seed'     => 'solo',

            // Checked AFTER salon, so beauty trades never fall through here. The raw
            // field still flows to generation, so the copy stays field-specific.
            'family' => [
                'instructor', 'teacher', 'teach', 'tutor', 'tutoring', 'lesson', 'lessons', 'class', 'classes',
                'coach', 'coaching', 'trainer', 'training', 'mentor', 'academy', 'school',
                'music', 'piano', 'guitar', 'violin', 'cello', 'drum', 'vocal', 'singing', 'voice',
                'math', 'maths', 'science', 'english', 'language', 'spanish', 'french', 'chess', 'coding', 'reading',
                'art', 'painting', 'drawing', 'pottery', 'ceramics', 'photography',
                'dance', 'ballet', 'yoga', 'pilates', 'fitness', 'gym', 'crossfit', 'personal trainer', 'strength',
                'martial', 'karate', 'judo', 'taekwondo', 'jiu', 'boxing', 'kickbox', 'mma', 'self-defense', 'self defense',
                // 'skiing', never bare 'ski': it starts "skilled", so "skilled trades"
                // resolved to a lessons site even with the leading-boundary rule.
                'swim', 'swimming', 'tennis', 'golf', 'skiing', 'surf', 'skate',
                // 'driving' (school/lessons/instructor), never bare 'driver': an auto
                // shop writing "for daily drivers" resolved to a lessons site (the
                // registry tests instructor before ingine, so 'driver' outranked
                // 'brake'). The lessons business says driving/drivers-ed, not driver.
                'driving', 'driver education', "driver's ed", 'drivers ed', 'driver training',
                'flight', 'sailing',
            ],

            // Flat pool: ONE directory numbered 1..38 (no subfolders). A category just
            // selects the number RANGE. Sergei will not use subfolders here.
            'pool' => [
                'base'   => '/storage/uploads/instructor-presets/',
                'layout' => 'flat',
                'cats'   => [
                    'portraits' => [1, 2, 3, 4, 5, 6, 7, 8],
                    'lessons'   => [9, 10, 11, 12, 13, 14, 15, 16, 17, 18, '9a', '12a', '16a'],
                    'interiors' => [19, 20, 21, 22, 23, 24, '19a', '23a'],   // hero fallback
                ],
                'team' => [],
            ],

            'heroes' => [
                'layout' => 'flat',
                'sets'   => [
                    'music' => [25, 26, '25a'], 'fitness' => [27, 28], 'dance' => [29, 30, '29a', '30a'],
                    'martial-arts' => [31, 32], 'academic' => [33, 34, '33a', '34a'], 'art' => [35, 36],
                    'driving' => [37, 38, '37a'],
                    // 2026-07-25 batch: dedicated subs (tags.json authoritative).
                    'yoga' => [1], 'piano' => [1], 'swim' => [1], 'coding' => [1], 'vocal' => [1], 'gym' => [1],
                ],
                'map' => [
                    // Specific subs FIRST (prefix matching, first key wins).
                    'yoga'=>'yoga','pilates'=>'yoga',
                    'piano'=>'piano','keyboard'=>'piano',
                    'swim'=>'swim',
                    'coding'=>'coding','programming'=>'coding','computer science'=>'coding',
                    'vocal'=>'vocal','sing'=>'vocal','voice'=>'vocal',
                    'guitar'=>'music','violin'=>'music','cello'=>'music','drum'=>'music','music'=>'music',
                    'gym'=>'gym','crossfit'=>'gym',
                    'fitness'=>'fitness','trainer'=>'fitness','strength'=>'fitness',
                    'dance'=>'dance','ballet'=>'dance',
                    'karate'=>'martial-arts','judo'=>'martial-arts','taekwondo'=>'martial-arts','martial'=>'martial-arts','boxing'=>'martial-arts','kickbox'=>'martial-arts','jiu'=>'martial-arts','mma'=>'martial-arts',
                    'math'=>'academic','tutor'=>'academic','science'=>'academic','english'=>'academic','language'=>'academic','chess'=>'academic','read'=>'academic','academic'=>'academic',
                    'art'=>'art','paint'=>'art','draw'=>'art','pottery'=>'art','ceramic'=>'art','photo'=>'art',
                    'driving'=>'driving','driver'=>'driving',
                ],
            ],

            'gallery' => ['mode' => 'category', 'cat' => 'lessons', 'count' => 6],

            'eyebrow' => [
                'default' => 'LESSONS',
                'map' => [
                    'piano'=>'PIANO LESSONS','guitar'=>'GUITAR LESSONS','violin'=>'VIOLIN LESSONS','music'=>'MUSIC LESSONS','sing'=>'VOICE LESSONS','vocal'=>'VOICE LESSONS',
                    'yoga'=>'YOGA STUDIO','pilates'=>'PILATES STUDIO','trainer'=>'PERSONAL TRAINING','fitness'=>'PERSONAL TRAINING','gym'=>'GYM & FITNESS','crossfit'=>'GYM & FITNESS',
                    'dance'=>'DANCE STUDIO','ballet'=>'BALLET STUDIO',
                    'karate'=>'MARTIAL ARTS','judo'=>'MARTIAL ARTS','taekwondo'=>'MARTIAL ARTS','martial'=>'MARTIAL ARTS','boxing'=>'BOXING GYM','mma'=>'MARTIAL ARTS',
                    'math'=>'TUTORING','tutor'=>'TUTORING','science'=>'TUTORING','english'=>'TUTORING','language'=>'LANGUAGE LESSONS','chess'=>'CHESS COACHING','coding'=>'CODING LESSONS',
                    'art'=>'ART STUDIO','paint'=>'ART STUDIO','draw'=>'ART STUDIO','pottery'=>'POTTERY STUDIO','photo'=>'PHOTOGRAPHY',
                    'driving'=>'DRIVING SCHOOL','swim'=>'SWIM LESSONS','tennis'=>'TENNIS COACHING','golf'=>'GOLF COACHING','coach'=>'COACHING',
                ],
            ],

            'gift' => [
                'default' => 'learning something new',
                'map' => [
                    'piano'=>'learning piano','guitar'=>'learning guitar','music'=>'learning to play','sing'=>'finding their voice','vocal'=>'finding their voice',
                    'yoga'=>'a calmer practice','fitness'=>'a stronger start','trainer'=>'a stronger start','gym'=>'a stronger start',
                    'dance'=>'learning to dance','martial'=>'confidence and discipline','karate'=>'confidence and discipline','boxing'=>'confidence and discipline',
                    'math'=>'a head start','tutor'=>'a head start','language'=>'a new language','chess'=>'a sharper mind','coding'=>'a new skill',
                    'art'=>'creating something','paint'=>'creating something','pottery'=>'creating something','photo'=>'a new skill',
                    'driving'=>'the road ahead','swim'=>'confidence in the water','tennis'=>'a new skill','golf'=>'a better swing',
                ],
            ],

            'chooser' => [
                'label' => 'Teachers and coaches',
                'blurb' => 'Piano, guitar, voice, math, science, languages, chess, coding, yoga, personal training, dance, martial arts, art, driving',
            ],

            'entry' => [
                'trade' => 'instructor', 'brand' => 'Instructor',
                'title' => 'Instructor: see your lesson site | Instinctor',
                'eyebrow' => 'for <b>teachers &amp; coaches</b>',
                'name_ph' => 'Your business name',
                'trade_ph' => 'What you teach and what makes you you. e.g. classical &amp; jazz piano for all ages, patient and encouraging, first lesson free. The more you tell us, the more your site sounds like you.',
                'trade_aria' => 'Describe what you teach: your lessons, your style, what makes you different. The more detail, the more unique your site copy.',
                'domain_ph' => 'yourstudio.com',
            ],
        ],

        'installer' => [
            // Seeded look packs (palette + reveal rotation) apply to this
            // vertical's demos. Salon stays on the 526 canon; ingine/instate
            // join once their masters are reviewed (Sergei 2026-07-29).
            'variants' => true,
            // Salon's structure minus the two things a trade does not have: gift cards
            // (plumbers do not sell them) and the team section (no crew portraits exist,
            // so it would open on grey monograms). Hero, services, recent jobs, about,
            // reviews, quote form, service area, footer.
            'template' => 751,
            'route'    => '/installer',
            'books'    => false,   // quote-first: a trade prices a job, it does not sell a slot
            // 'solo' skips the gender-matched team-portrait logic, which needs a
            // women/men pool this vertical has no images for.
            'seed'     => 'solo',

            // Checked AFTER salon and instructor, so a beauty or lessons trade never falls
            // through here. Two deliberate omissions, because the earlier verticals own the
            // ambiguous word and a bare word cannot be resolved even by a human:
            //   'painting' -> instructor (an art class). Installer takes 'painter' instead.
            //   'swim'     -> instructor (swim lessons). Installer takes 'pool' instead.
            'family' => [
                'hvac', 'heating', 'cooling', 'furnace', 'air conditioning', 'ac repair',
                'plumb', 'plumber', 'drain', 'boiler', 'water heater',
                'electric', 'electrician', 'wiring',
                'roof', 'roofer', 'gutter', 'siding',
                'painter', 'house paint',
                'landscap', 'lawn', 'garden', 'tree service', 'snow removal',
                'clean', 'cleaner', 'maid', 'janitor', 'carpet',
                'handyman', 'handy man', 'contractor', 'remodel', 'renovation', 'carpenter',
                // The vertical's own name: "smart home installer" matched nothing,
                // because the exact-key check only fires on the bare word 'installer'.
                'install', 'installer', 'installation',
                // 'pest control', never bare 'pest': it hides inside "cheapest", which
                // routed a card-payments article to a pest company. Same trap as 'duct'
                // in "products". Any keyword under ~5 letters needs saying out loud
                // inside a sentence before it goes in.
                'pest control', 'exterminator', 'termite',
                'garage door', 'fence', 'fencing', 'deck',
                'pool service', 'pool cleaning', 'spa service',
                'appliance repair', 'junk removal', 'hauling', 'moving', 'mover',
                'locksmith', 'glazier', 'window', 'flooring', 'tiling', 'mason',
                // Heavy site work (gap sweep 2026-07-24): same quote-form skeleton.
                'excavat', 'concrete', 'paving', 'asphalt', 'demolition',
                'welding', 'welder', 'pressure wash', 'power wash',
                // 'ductwork' / 'air duct', never bare 'duct': it is a substring of
                // "products", so 'duct' routed "how to sell digital products" to a
                // plumber's template. Matching is substring-based, so every keyword here
                // has to survive being inside an unrelated word.
                // 'well' is out for the same reason (farewell, wellness, well-known);
                // a well-water trade is rare enough to lose.
                'septic', 'insulation', 'solar', 'chimney', 'ductwork', 'air duct',
            ],

            // ZERO new images. These are the trade heroes already on disk and already
            // serving the SEO pages, reused per the standing rule. All 19 are real
            // 1920x1080 landscape, which is BETTER hero stock than the instructor pool's
            // upscaled squares. 'flat' layout + string ids means the id IS the filename,
            // so nothing had to be renamed or copied.
            'pool' => [
                // Dedicated curated library (migrated out of instinctor.com's general
                // instinctor-images/ dump 2026-07-23) so home-services images are
                // manageable in the platform image tool, like salon-presets. Heroes
                // use this same base (heroUrlFor reads pool.base).
                'base'   => '/storage/uploads/installer-presets/',
                'layout' => 'flat',
                'cats'   => [
                    // The work gallery: every trade shot. A demo shows the trade it is for
                    // first (heroes), and these fill the gallery strip.
                    'work' => [
                        // Curated multi-shot library (2026-07), one lowercase-NN set per
                        // trade, replacing the old single generic shots. Appliance/Junk keep
                        // their originals until their own sets are generated.
                        'hvac-01', 'hvac-02', 'hvac-05', 'hvac-06',
                        'plumbing-00', 'plumbing-01', 'plumbing-02',
                        'electrical-02', 'electrical-03', 'electrical-05', 'electrical-07',
                        'roofing-01', 'roofing-03', 'roofing-04', 'roofing-05', 'roofing-06', 'roofing-08', 'roofing-09',
                        'painting-01', 'painting-04', 'painting-05', 'painting-06', 'painting-08', 'painting-09',
                        'landscaping-01', 'landscaping-02', 'landscaping-03', 'landscaping-04', 'landscaping-05', 'landscaping-06', 'landscaping-x2',
                        'cleaning-01', 'cleaning-02', 'cleaning-03', 'cleaning-05', 'cleaning-06', 'cleaning-07', 'cleaning-08', 'cleaning-09',
                        'handyman-01', 'handyman-02', 'handyman-03', 'handyman-04', 'handyman-05',
                        'garage-01', 'garage-03',
                        'pest-02', 'pest-03', 'pest-04', 'pest-05',
                        'pool-01', 'pool-02', 'pool-03', 'pool-04', 'pool-05',
                        'fencing-01', 'fencing-02', 'fencing-05',
                        'Appliance', 'Junk',
                    ],
                    // Hero fallback when the trade matches no subtype (a locksmith, a
                    // glazier). handyman reads as "someone who fixes things", the safest
                    // generic for a trade we did not anticipate.
                    // Installer.webp is deliberately NOT here: it is on disk but 404s, and
                    // 31KB for 1920x1080 says it is broken. Listing it gave a locksmith a
                    // dead hero one seed in two.
                    'interiors' => ['handyman'],
                    'portraits' => [],   // no crew portraits yet: the team block renders monograms
                ],
                'team' => [],
            ],

            'heroes' => [
                'layout' => 'flat',
                'sets'   => [
                    'hvac'        => ['HVAC-1'],
                    'plumbing'    => ['Plumber-2'],
                    'electrical'  => ['electrician', 'electrician-1', 'electrician-2'],
                    'roofing'     => ['Roofer'],
                    'painting'    => ['Painting'],
                    'landscaping' => ['landscaping'],
                    'cleaning'    => ['cleaning'],
                    'handyman'    => ['installer'],
                    'garage'      => ['garage'],
                    'pest'        => ['pest-control'],
                    'pool'        => ['pool'],
                    'fencing'     => ['Fencing'],
                    'appliance'   => ['Appliance'],
                    'junk'        => ['Junk'],
                    // Gap-sweep heroes (2026-07-24 batch): one edge-subject pair each.
                    'locksmith'   => ['locksmith-hero'],
                    'pressure'    => ['pressure-washing-hero'],
                    'solar'       => ['solar-hero'],
                    'septic'      => ['septic-hero'],
                    'chimney'     => ['chimney-hero'],
                    // Heavy site work batch (2026-07-25): the last six Group 1 pairs.
                    'excavation'  => ['excavation-hero'],
                    'moving'      => ['moving-hero'],
                    'paving'      => ['paving-hero'],
                    'concrete'    => ['concrete-hero'],
                    'welding'     => ['welding-hero'],
                    'demolition'  => ['demolition-hero'],
                ],
                'map' => [
                    'hvac' => 'hvac', 'heating' => 'hvac', 'cooling' => 'hvac', 'furnace' => 'hvac',
                    'air conditioning' => 'hvac', 'ac repair' => 'hvac', 'duct' => 'hvac',
                    'plumb' => 'plumbing', 'drain' => 'plumbing', 'boiler' => 'plumbing', 'water heater' => 'plumbing', 'septic' => 'septic',
                    'electric' => 'electrical', 'wiring' => 'electrical', 'solar' => 'solar',
                    'roof' => 'roofing', 'gutter' => 'roofing', 'siding' => 'roofing', 'chimney' => 'chimney',
                    'locksmith' => 'locksmith', 'pressure wash' => 'pressure', 'power wash' => 'pressure',
                    'paint' => 'painting',
                    'landscap' => 'landscaping', 'lawn' => 'landscaping', 'garden' => 'landscaping', 'tree' => 'landscaping', 'snow' => 'landscaping',
                    'clean' => 'cleaning', 'maid' => 'cleaning', 'janitor' => 'cleaning', 'carpet' => 'cleaning',
                    'garage door' => 'garage',
                    'pest control' => 'pest', 'exterminat' => 'pest', 'termite' => 'pest',
                    'pool' => 'pool',
                    'fenc' => 'fencing', 'deck' => 'fencing',
                    'appliance' => 'appliance',
                    'junk' => 'junk', 'hauling' => 'junk',
                    'moving' => 'moving', 'mover' => 'moving',
                    'excavat' => 'excavation', 'grading' => 'excavation',
                    'paving' => 'paving', 'asphalt' => 'paving',
                    'concrete' => 'concrete',
                    'welding' => 'welding', 'welder' => 'welding',
                    'demolition' => 'demolition',
                    'handyman' => 'handyman', 'contractor' => 'handyman', 'remodel' => 'handyman', 'carpenter' => 'handyman',
                    'installer' => 'handyman', 'installation' => 'handyman',   // generic installer -> the contractor owner hero
                ],
            ],

            'gallery' => ['mode' => 'category', 'cat' => 'work', 'count' => 6],

            'eyebrow' => [
                'default' => 'HOME SERVICES',
                'map' => [
                    'hvac' => 'HEATING & COOLING', 'heating' => 'HEATING & COOLING', 'cooling' => 'HEATING & COOLING',
                    'furnace' => 'HEATING & COOLING', 'air conditioning' => 'HEATING & COOLING',
                    'plumb' => 'PLUMBING', 'drain' => 'PLUMBING', 'water heater' => 'PLUMBING',
                    'electric' => 'ELECTRICAL', 'wiring' => 'ELECTRICAL', 'solar' => 'SOLAR',
                    'roof' => 'ROOFING', 'gutter' => 'GUTTERS', 'siding' => 'SIDING',
                    'paint' => 'PAINTING', 'landscap' => 'LANDSCAPING', 'lawn' => 'LAWN CARE', 'tree' => 'TREE SERVICE',
                    'clean' => 'CLEANING', 'maid' => 'CLEANING', 'carpet' => 'CARPET CLEANING',
                    'handyman' => 'HANDYMAN', 'contractor' => 'CONTRACTING', 'remodel' => 'REMODELLING', 'carpenter' => 'CARPENTRY',
                    'pest control' => 'PEST CONTROL', 'exterminat' => 'PEST CONTROL', 'termite' => 'PEST CONTROL',
                    'garage door' => 'GARAGE DOORS', 'fenc' => 'FENCING', 'deck' => 'DECKS',
                    'pool' => 'POOL SERVICE', 'appliance' => 'APPLIANCE REPAIR',
                    'junk' => 'JUNK REMOVAL', 'hauling' => 'HAULING', 'moving' => 'MOVING', 'mover' => 'MOVING',
                    'locksmith' => 'LOCKSMITH', 'window' => 'WINDOWS', 'floor' => 'FLOORING', 'insulation' => 'INSULATION',
                    'excavat' => 'EXCAVATION', 'paving' => 'PAVING', 'asphalt' => 'PAVING',
                    'concrete' => 'CONCRETE', 'welding' => 'WELDING', 'welder' => 'WELDING', 'demolition' => 'DEMOLITION',
                ],
            ],

            // Home services do not sell gift cards. The salon's "give the gift of ___" slot
            // becomes the thing a homeowner actually wants to hear: you will turn up, and
            // the price will not move.
            'gift' => [
                'default' => 'a job done right',
                'map' => [
                    'hvac' => 'a warm house again', 'heating' => 'a warm house again', 'cooling' => 'a cool house again',
                    'plumb' => 'a leak that stays fixed', 'drain' => 'water that drains',
                    'electric' => 'power you can trust', 'roof' => 'a roof that holds',
                    'paint' => 'a place that feels new', 'landscap' => 'a yard you enjoy', 'lawn' => 'a yard you enjoy',
                    'clean' => 'a home that gleams', 'handyman' => 'the list finally done',
                    'pest control' => 'your home back', 'pool' => 'a pool that is ready',
                ],
            ],

            'chooser' => [
                'label' => 'Home services and trades',
                'blurb' => 'HVAC, plumbing, electrical, roofing, painting, landscaping, cleaning, handyman, pest control, garage doors, fencing, pool service',
            ],

            'entry' => [
                'trade' => 'installer', 'brand' => 'Installer',
                'title' => 'Installer: see your trade site | Instinctor',
                'eyebrow' => 'for <b>trades &amp; home services</b>',
                'name_ph' => 'Your business name',
                'trade_ph' => 'What you do and what makes you you. e.g. HVAC repair and installs, 20 years in the valley, same-day callouts, no charge for the quote. The more you tell us, the more your site sounds like you.',
                'trade_aria' => 'Describe your trade: your services, your area, what makes you different. The more detail, the more unique your site copy.',
                'domain_ph' => 'yourtrade.com',
            ],
        ],

        'ingine' => [
            // Auto repair. The estimate engine already speaks vehicles (VIN decode, the
            // year/make/model block, and job-status SMS that says "your vehicle is ready
            // for pickup"), so this vertical reuses the whole quote -> invoice pipeline
            // Installer proved, with the quote form carrying the vehicle. Same shape as
            // Installer: quote-first, no bookings, no crew-portrait pool.
            'template' => 829,
            'route'    => '/ingine',
            'seed'     => 'solo',
            'books'    => false,
            // Auto shops price by the vehicle: the tenant quote editor shows a vehicle
            // block (year/make/model + VIN decode) for this vertical, and the demo
            // template's quote form asks for it (show_vehicle on the quote_form element).
            'quote_vehicle' => true,

            // Look packs ON as of 2026-08-03 ("automotive is a promising field"):
            // the auto profession palette pins (Slate repair, Noir detailing) need
            // variants to bake, and the 829 master carries a valid 10-slot
            // demo_palette. This lifts the 2026-07-29 hold for ingine only;
            // instate still waits on master review.
            'variants' => true,

            // Checked LAST (after salon, instructor, installer). Automotive words are clear
            // of the earlier verticals: installer owns 'window'/'glazier' but not
            // 'windshield', and 'garage door' but not bare 'garage'; no earlier vertical
            // claims 'auto', 'car', 'brake', 'mechanic'. kwHit is word-boundary + optional
            // plural, so 'car' matches "car"/"cars" but never "carpet", and 'tire' matches
            // "tire"/"tires" but never "entire". Bare 'engine' is deliberately OUT (search
            // engine, game engine); 'engine repair' and 'check engine' carry it instead.
            'family' => [
                'auto repair', 'auto shop', 'auto service', 'automotive', 'car repair',
                'mechanic', 'garage', 'body shop', 'auto body', 'collision',
                'brake', 'transmission', 'oil change', 'lube', 'tune-up', 'tune up',
                'tire', 'tyre', 'wheel alignment', 'alignment', 'muffler', 'exhaust',
                'radiator', 'suspension', 'engine repair', 'check engine', 'diagnostic',
                'smog', 'emissions', 'detailing', 'car detailing', 'windshield', 'auto glass',
                // Auto-adjacent (gap sweep 2026-07-24): same shop skeleton.
                'towing', 'tow truck', 'car wash', 'carwash',
            ],

            // Curated ChatGPT set replaced the 97 Pexels stock images 2026-07-22 (Sergei):
            // the stock read as garbage to any real mechanic. House style = one tool in a
            // regular fabric work glove (fingertips covered, no nails), background blurred
            // to near-black. 17 work + 3 interiors + 5 portraits + reused heroes. The old
            // ingine-<cat>-NN.webp files are orphaned on disk (unreferenced), safe to delete.
            'pool' => [
                // Dedicated curated library (migrated out of instinctor-images/ 2026-07-23)
                // so ingine images are manageable in the platform image tool.
                'base'   => '/storage/uploads/ingine-presets/',
                'layout' => 'flat',
                'cats'   => [
                    'work'      => [
                        'ingine-generic-wrench', 'ingine-generic-oil', 'ingine-generic-scan', 'ingine-generic-tire',
                        'ingine-engine', 'ingine-electrics', 'ingine-brakes', 'ingine-exhaust', 'ingine-body',
                        'ingine-detailing', 'ingine-cooling', 'ingine-transmission', 'ingine-tiremount',
                        'ingine-oilfilter', 'ingine-airfilter', 'ingine-belt', 'ingine-battery',
                    ],
                    'interiors' => ['ingine-shop-01', 'ingine-shop-02', 'ingine-shop-03'],
                    'portraits' => [
                        'ingine-tech-01', 'ingine-tech-02', 'ingine-tech-03', 'ingine-tech-04', 'ingine-tech-05',
                    ],
                ],
                'team' => [],
            ],

            'heroes' => [
                'layout' => 'flat',
                'sets'   => [
                    'general' => ['ingine-generic-wrench', 'ingine-generic-oil', 'ingine-generic-scan', 'ingine-engine', 'ingine-electrics', 'ingine-brakes', 'ingine-exhaust', 'ingine-body'],
                    'tire'    => ['ingine-generic-tire', 'ingine-tiremount'],
                    // Gap-sweep heroes (2026-07-24 batch).
                    'towing'  => ['towing-hero'],
                    'carwash' => ['car-wash-hero'],
                    // 2026-07-25 batch.
                    'detailing' => ['detailing-hero'],
                    'bodyshop'  => ['bodyshop-hero'],
                    'oilchange' => ['oilchange-hero'],
                ],
                'map' => [
                    // Specific trades first: heroSubtypeFor returns the FIRST keyword
                    // match, so towing/car-wash resolve before 'car'/'service' below.
                    'tow' => 'towing', 'car wash' => 'carwash', 'carwash' => 'carwash',
                    'detail' => 'detailing', 'ceramic' => 'detailing',
                    'body shop' => 'bodyshop', 'auto body' => 'bodyshop', 'collision' => 'bodyshop',
                    'oil change' => 'oilchange', 'oil' => 'oilchange', 'lube' => 'oilchange',
                    'tire' => 'tire', 'tyre' => 'tire', 'wheel' => 'tire', 'alignment' => 'tire',
                    // Everything else that reads as auto work gets the general action hero
                    // (7 shots). Only a trade matching none of these falls through to the
                    // interiors (shop) fallback.
                    'auto' => 'general', 'car' => 'general', 'mechanic' => 'general', 'garage' => 'general',
                    'brake' => 'general', 'engine' => 'general', 'oil' => 'general', 'lube' => 'general',
                    'transmission' => 'general', 'exhaust' => 'general', 'muffler' => 'general', 'radiator' => 'general',
                    'diagnostic' => 'general', 'repair' => 'general', 'service' => 'general', 'shop' => 'general',

                    'smog' => 'general', 'emissions' => 'general',
                ],
            ],

            'gallery' => ['mode' => 'category', 'cat' => 'work', 'count' => 6],

            'eyebrow' => [
                'default' => 'AUTO REPAIR',
                'map' => [
                    'brake' => 'BRAKES & ROTORS', 'transmission' => 'TRANSMISSION',
                    'tire' => 'TIRES & WHEELS', 'tyre' => 'TIRES & WHEELS', 'alignment' => 'WHEEL ALIGNMENT',
                    'oil change' => 'OIL & LUBE', 'lube' => 'OIL & LUBE',
                    'engine repair' => 'ENGINE REPAIR', 'diagnostic' => 'DIAGNOSTICS', 'check engine' => 'DIAGNOSTICS',
                    'muffler' => 'EXHAUST', 'exhaust' => 'EXHAUST', 'radiator' => 'COOLING SYSTEM',
                    'body shop' => 'AUTO BODY', 'auto body' => 'AUTO BODY', 'collision' => 'COLLISION REPAIR',
                    'detailing' => 'AUTO DETAILING', 'smog' => 'SMOG & EMISSIONS', 'emissions' => 'SMOG & EMISSIONS',
                    'windshield' => 'AUTO GLASS', 'auto glass' => 'AUTO GLASS',
                ],
            ],

            // The auto-shop version of the reassurance slot: the thing a driver actually
            // fears is the surprise bill and the car held hostage.
            'gift' => [
                'default' => 'a fair price, agreed before we start',
                'map' => [
                    'brake' => 'brakes that stop when you need them', 'transmission' => 'a smooth shift again',
                    'tire' => 'tires you can trust at speed', 'oil change' => 'an engine that lasts',
                    'engine repair' => 'an engine that runs right', 'diagnostic' => 'a straight answer on what is wrong',
                    'body shop' => 'your car looking like new', 'collision' => 'your car looking like new',
                ],
            ],

            'chooser' => [
                'label' => 'Auto repair and service',
                'blurb' => 'oil changes, brakes, tires, engine and transmission, diagnostics, auto body, detailing',
            ],

            'entry' => [
                'trade' => 'auto repair', 'brand' => 'Ingine',
                'title' => 'Ingine: see your auto shop site | Instinctor',
                'eyebrow' => 'for <b>auto shops &amp; mechanics</b>',
                'name_ph' => 'Your business name',
                'trade_ph' => 'What you fix and what makes you you. e.g. brakes, tires and full diagnostics, 15 years on the same corner, honest quotes before any work starts, most jobs same day. The more you tell us, the more your site sounds like your shop.',
                'trade_aria' => 'Describe your shop: your services, your area, what makes you different. The more detail, the more unique your site copy.',
                'domain_ph' => 'yourshop.com',
            ],
        ],

        'instate' => [
            // Real estate. A solo agent IS the brand, so the profile is 'solo' (one
            // portrait, single-category listing gallery). No bookings (a sale is a
            // conversation, not a fixed slot) and no vehicle. The estimate block is
            // reused as an inquiry: "tell me what you are looking for" -> the lead
            // lands in the same quote/inquiry pipeline Installer proved, minus price.
            'template' => 852,
            'route'    => '/instate',
            'seed'     => 'solo',
            'books'    => false,
            // No quote_vehicle: real estate has no year/make/model. The template's
            // estimate block ships with show_vehicle=0 and an inquiry sub-line.

            // Checked LAST (after salon, instructor, installer, ingine). Real-estate
            // words are clear of every earlier vertical: none claim 'realtor', 'realty',
            // 'listing', 'broker', 'condo', 'property', 'relocation'. kwHit is
            // word-boundary + optional plural, so 'agent' is deliberately OUT (insurance
            // agent, booking agent); 'real estate agent' and 'realtor' carry it instead.
            'family' => [
                'real estate', 'realtor', 'realty', 'real estate agent', 'real estate broker',
                'realtor associate', 'listing agent', 'buyer agent', "buyer's agent", "seller's agent",
                'homes for sale', 'home sales', 'residential real estate', 'luxury real estate',
                'luxury homes', 'property', 'condo', 'townhome', 'relocation', 'first-time buyer',
                'investment property', 'ranch', 'acreage', 'land for sale', 'broker', 'brokerage',
            ],

            // Real-estate photo batch (ChatGPT, native 1672x941 webp, instate-* namespace)
            // landed 2026-07-21: 17 listing shots (work: interiors + exteriors), 5 interior
            // establishing shots, 1 agent portrait. Filenames are stems; poolUrl appends
            // .webp on the flat base.
            'pool' => [
                // Dedicated curated library (migrated out of instinctor-images/ 2026-07-23)
                // so instate images are manageable in the platform image tool.
                'base'   => '/storage/uploads/instate-presets/',
                'layout' => 'flat',
                'cats'   => [
                    'work'      => [
                        'instate-living-01', 'instate-living-02', 'instate-living-03',
                        'instate-kitchen-01', 'instate-kitchen-02', 'instate-kitchen-03',
                        'instate-kitchen-04', 'instate-kitchen-05',
                        'instate-bedroom-01', 'instate-bedroom-02', 'instate-bedroom-03',
                        'instate-dining-01', 'instate-bath-01',
                        'instate-exterior-01', 'instate-exterior-02', 'instate-exterior-03',
                        'instate-patio-01',
                    ],
                    // Establishing "space" the hero falls back to when a subtype hero is
                    // absent: warm, wide living/kitchen shots read as "a beautiful home".
                    'interiors' => [
                        'instate-living-01', 'instate-living-02', 'instate-living-03',
                        'instate-kitchen-01', 'instate-kitchen-02',
                    ],
                    // One agent portrait (solo profile picks index 0).
                    'portraits' => ['instate-agent-01'],
                ],
                'team' => [],
            ],

            'heroes' => [
                'layout' => 'flat',
                'sets'   => [
                    // Cinematic dusk/sunset home exteriors: the money shot of a listing site.
                    'general' => ['instate-hero-01', 'instate-hero-02', 'instate-hero-03', 'instate-hero-04'],
                    // Aerial neighborhood shots for agents who sell the AREA (land, ranch,
                    // relocation, community-first). heroSubtypeFor returns the first match.
                    'aerial'  => ['instate-aerial-01', 'instate-aerial-02'],
                ],
                'map' => [
                    // Aerial hero ONLY for genuinely land-first trades. "neighborhood",
                    // "community", "relocation" are too common (every agent says "I know
                    // the neighborhoods") and would starve the far stronger dusk-home hero,
                    // so they are deliberately NOT here and fall through to general.
                    'land' => 'aerial', 'acreage' => 'aerial', 'ranch' => 'aerial', 'farm' => 'aerial',
                    'lot' => 'aerial', 'aerial' => 'aerial', 'rural' => 'aerial',
                    // Everything else that reads as real estate gets the dusk-home hero.
                    'real estate' => 'general', 'realtor' => 'general', 'realty' => 'general',
                    'luxury' => 'general', 'condo' => 'general', 'townhome' => 'general',
                    'listing' => 'general', 'buyer' => 'general', 'seller' => 'general',
                    'home' => 'general', 'property' => 'general', 'broker' => 'general',
                    'investment' => 'general', 'residential' => 'general',
                ],
            ],

            'gallery' => ['mode' => 'category', 'cat' => 'work', 'count' => 6],

            // The estimate block is an inquiry here, not a job quote: no price promised.
            'quote' => ['sub' => 'Describe what you are looking for and {name} will come back with a price.'],

            'eyebrow' => [
                'default' => 'REAL ESTATE',
                'map' => [
                    'luxury' => 'LUXURY HOMES', 'condo' => 'CONDOS & LOFTS', 'townhome' => 'TOWNHOMES',
                    'land' => 'LAND & LOTS', 'acreage' => 'LAND & ACREAGE', 'ranch' => 'RANCH & LAND',
                    'farm' => 'FARM & RANCH', 'relocation' => 'RELOCATION', 'first-time' => 'FIRST-TIME BUYERS',
                    'investment' => 'INVESTMENT PROPERTY', 'buyer' => 'BUYER AGENT', 'listing' => 'LISTING AGENT',
                    'commercial' => 'COMMERCIAL REAL ESTATE', 'property management' => 'PROPERTY MANAGEMENT',
                    'waterfront' => 'WATERFRONT HOMES', 'new construction' => 'NEW CONSTRUCTION',
                ],
            ],

            // Reassurance slot: what a buyer or seller actually fears is a bad deal and an
            // agent who pushes. (No gift screen on this template today; kept for parity.)
            'gift' => [
                'default' => 'a straight answer on what your home is really worth',
                'map' => [
                    'seller' => 'top dollar for your home', 'listing' => 'top dollar for your home',
                    'buyer' => 'the right home at the right price', 'first-time' => 'a first purchase that goes smoothly',
                    'luxury' => 'a discreet, white-glove sale', 'relocation' => 'a smooth landing in a new city',
                    'investment' => 'a property that actually pencils out',
                ],
            ],

            'chooser' => [
                'label' => 'Real estate agent or broker',
                'blurb' => 'buying, selling, first-time buyers, luxury, investment, relocation',
            ],

            'entry' => [
                'trade' => 'real estate', 'brand' => 'Instate',
                'title' => 'Instate: see your real estate site | Instinctor',
                'eyebrow' => 'for <b>real estate agents &amp; brokers</b>',
                'name_ph' => 'Your business name',
                'trade_ph' => 'The areas you sell and who you help. e.g. listing and buyer agent across the east side, first-time buyers and move-up families, 12 years and 200+ closings, honest pricing and no pressure. The more you tell us, the more your site sounds like you.',
                'trade_aria' => 'Describe your real estate work: your areas, who you help, what makes you different. The more detail, the more unique your site copy.',
                'domain_ph' => 'yourname.com',
            ],
        ],

        'generic' => [
            // Seeded look packs (palette + reveal rotation) apply to this
            // vertical's demos. Salon stays on the 526 canon; ingine/instate
            // join once their masters are reviewed (Sergei 2026-07-29).
            'variants' => true,
            // The engine builds ANY business, so a trade that matches no specific
            // vertical lands here: a neutral template, a neutral photo pool, a plain
            // inquiry (no bookings, no vehicle). This is ALSO Verticals::FALLBACK, so an
            // unmatched demo uses this neutral site instead of a mismatched salon one.
            'template' => 867,
            'route'    => '/make-a-site',
            'seed'     => 'solo',
            'books'    => false,

            // Checked LAST of all, so a specific vertical always wins first. This is a
            // light net for obviously-generic words; anything else still falls back here.
            'family' => [
                'small business', 'boutique', 'consultant', 'agency', 'freelance',
                'freelancer', 'local business', 'shop', 'store', 'services',
            ],

            // Neutral business batch (ChatGPT, native 1672x941 webp, generic-* namespace)
            // landed 2026-07-21: 13 work/service shots, 6 interiors, 6 owner portraits,
            // 3 scene heroes (storefront/interior/lobby — composed centered so the demo's
            // single-image hero crops right on any device). Archetype batch landed
            // 2026-07-22: 15 product shots (4 hero lifestyle + 11 gallery still-lifes),
            // 6 digital shots (device mockups + abstract tech), 2 extra shop interiors.
            'pool' => [
                // Dedicated curated library (migrated out of instinctor-images/ 2026-07-24)
                // so generic images are manageable in the platform image tool.
                'base'   => '/storage/uploads/generic-presets/',
                'layout' => 'flat',
                'cats'   => [
                    'work' => [
                        'generic-work-01', 'generic-work-02', 'generic-work-03', 'generic-work-04', 'generic-work-05',
                        'generic-work-06', 'generic-work-07', 'generic-work-08', 'generic-work-09', 'generic-work-10',
                        'generic-work-11', 'generic-work-12', 'generic-work-13',
                    ],
                    'interiors' => [
                        'generic-interior-01', 'generic-interior-02', 'generic-interior-03',
                        'generic-interior-04', 'generic-interior-05', 'generic-interior-06',
                        'generic-interior-07', 'generic-interior-08',
                    ],
                    'portraits' => [
                        'generic-portrait-01', 'generic-portrait-02', 'generic-portrait-03',
                        'generic-portrait-04', 'generic-portrait-05', 'generic-portrait-06',
                    ],
                    // Archetype galleries (product + digital batch landed 2026-07-22).
                    'products' => [
                        'generic-product-05', 'generic-product-06', 'generic-product-07', 'generic-product-08',
                        'generic-product-09', 'generic-product-10', 'generic-product-11', 'generic-product-12',
                        'generic-product-13', 'generic-product-14', 'generic-product-15',
                    ],
                    'digital'  => [
                        'generic-digital-01', 'generic-digital-02', 'generic-digital-03',
                        'generic-digital-04', 'generic-digital-05', 'generic-digital-06',
                    ],
                ],
                'team' => [],
            ],

            'heroes' => [
                'layout' => 'flat',
                // Four business archetypes. The demo picks by hero_archetype (classified
                // by the LLM: face/place/product/digital) and falls back to the keyword
                // map, then the general set, when it is absent or a set is still empty.
                // All four sets are populated (product + digital batch landed 2026-07-22).
                'sets'   => [
                    'face'    => ['generic-portrait-01', 'generic-portrait-02', 'generic-portrait-03', 'generic-portrait-04', 'generic-portrait-05', 'generic-portrait-06', 'professional-hero', 'consult-hero'],
                    'place'   => ['generic-hero-01', 'generic-hero-02', 'generic-hero-03'],
                    'product' => ['generic-product-01', 'generic-product-02', 'generic-product-03', 'generic-product-04'],
                    'digital' => ['generic-digital-01', 'generic-digital-03', 'generic-digital-05', 'generic-digital-06'],
                    'general' => ['generic-hero-01', 'generic-hero-02', 'generic-hero-03'],
                    // Trade-matched heroes for the gap clusters (2026-07-24 batch):
                    // generic still hosts these trades, but the hero is THEIRS. Each
                    // becomes a dedicated vertical only if the volume proves out.
                    'massage'   => ['massage-hero'],
                    'physio'    => ['physio-hero'],
                    'dental'    => ['dental-hero', 'dental-2-hero'],
                    'grooming'  => ['dog-grooming-hero'],
                    'dogwalk'   => ['dog-walking-hero'],
                    'chauffeur' => ['chauffeur-hero'],
                    'dryclean'  => ['dry-cleaning-hero'],
                    'photo'     => ['photographer-hero', 'photographer-2-hero'],
                    // 2026-07-25 batch (events / pets / repair / care).
                    'florist'    => ['florist-hero'],
                    'dj'         => ['dj-hero'],
                    'wedding'    => ['wedding-planner-hero'],
                    'video'      => ['videographer-hero'],
                    'tattoo'     => ['tattoo-hero'],
                    'vet'        => ['vet-hero'],
                    'petcare'    => ['pet-daycare-hero'],
                    'bikerepair' => ['bike-repair-hero'],
                    'phonerepair'=> ['phone-repair-hero'],
                    'tailor'     => ['tailor-hero'],
                    'seniorcare' => ['senior-care-hero'],
                    'courier'    => ['courier-hero'],
                    // 2026-07-25 batch 4 (professional / care / retail).
                    'daycare'     => ['daycare-hero'],
                    'doctor'      => ['doctor-hero'],
                    'lawyer'      => ['lawyer-hero'],
                    'accountant'  => ['accountant-hero'],
                    'insurance'   => ['insurance-hero'],
                    'counselor'   => ['counselor-hero'],
                    'optometrist' => ['optometrist-hero'],
                    'designer'    => ['interior-designer-hero'],
                    'inspector'   => ['home-inspector-hero'],
                    'musician'    => ['musician-hero'],
                    'jeweler'     => ['jeweler-hero'],
                    // A print shop was falling to the 'product' archetype, whose hero is
                    // an artisan product still life: nothing to do with printing
                    // (Sergei 2026-08-01). Its own shopfront, day and night.
                    'print'       => ['print-shop-hero'],
                ],
                'map' => [
                    // Trade-specific FIRST: heroSubtypeFor returns the first keyword hit,
                    // and 'massage therapist' must beat the bare 'therapist' -> face rule.
                    'massage' => 'massage',
                    'chiropract' => 'physio', 'physical therap' => 'physio', 'physio' => 'physio',
                    'dentist' => 'dental', 'dental' => 'dental',
                    'groom' => 'grooming',
                    'dog walk' => 'dogwalk', 'pet sit' => 'dogwalk', 'dog sit' => 'dogwalk',
                    'chauffeur' => 'chauffeur', 'limo' => 'chauffeur', 'car service' => 'chauffeur',
                    'dry clean' => 'dryclean', 'laundr' => 'dryclean', 'wash and fold' => 'dryclean',
                    'photograph' => 'photo',
                    'videograph' => 'video', 'video production' => 'video', 'film' => 'video',
                    'florist' => 'florist', 'flower' => 'florist',
                    'dj' => 'dj', 'disc jockey' => 'dj',
                    'wedding plan' => 'wedding', 'event plan' => 'wedding', 'wedding coordinat' => 'wedding',
                    'tattoo' => 'tattoo', 'piercing' => 'tattoo',
                    'veterinar' => 'vet', 'vet clinic' => 'vet', 'animal hospital' => 'vet',
                    // 'pet daycare'/'kennel' only - bare 'daycare' is CHILDCARE and
                    // must never pull dog imagery.
                    'pet daycare' => 'petcare', 'pet boarding' => 'petcare', 'dog boarding' => 'petcare', 'kennel' => 'petcare',
                    'bike' => 'bikerepair', 'bicycle' => 'bikerepair',
                    'phone repair' => 'phonerepair', 'screen repair' => 'phonerepair', 'cell phone' => 'phonerepair',
                    'tailor' => 'tailor', 'alteration' => 'tailor', 'seamstress' => 'tailor',
                    // Print and sign shop. Kept off bare 'sign', which appears in
                    // ordinary copy ('sign up', 'signage consultant'), and off 'copy'.
                    'print shop' => 'print', 'print and sign' => 'print', 'printing' => 'print',
                    'sign shop' => 'print', 'signage' => 'print', 'print & sign' => 'print',
                    'senior care' => 'seniorcare', 'home care' => 'seniorcare', 'caregiv' => 'seniorcare', 'elder' => 'seniorcare',
                    'courier' => 'courier', 'delivery service' => 'courier', 'parcel' => 'courier',
                    // Batch 4 (2026-07-25): these professions now carry their OWN hero,
                    // so the old catch-all 'face' rules for them are REMOVED above all
                    // (duplicate keys: last one wins, a leftover would shadow these).
                    // 'pet daycare'/'kennel' (above) must still beat bare 'daycare'.
                    'daycare' => 'daycare', 'childcare' => 'daycare', 'child care' => 'daycare', 'preschool' => 'daycare',
                    'doctor' => 'doctor', 'physician' => 'doctor', 'family medicine' => 'doctor', 'family practice' => 'doctor', 'pediatric' => 'doctor', 'medical' => 'doctor', 'clinic' => 'doctor',
                    'lawyer' => 'lawyer', 'attorney' => 'lawyer', 'law firm' => 'lawyer', 'law office' => 'lawyer', 'legal' => 'lawyer', 'notary' => 'lawyer', 'paralegal' => 'lawyer',
                    'accountant' => 'accountant', 'accounting' => 'accountant', 'bookkeep' => 'accountant', 'tax prep' => 'accountant', 'tax service' => 'accountant', 'cpa' => 'accountant',
                    'insurance' => 'insurance',
                    'counsel' => 'counselor', 'therapist' => 'counselor', 'therapy' => 'counselor', 'psycholog' => 'counselor', 'mental health' => 'counselor',
                    'optometr' => 'optometrist', 'optician' => 'optometrist', 'eye exam' => 'optometrist', 'eye care' => 'optometrist', 'eyewear' => 'optometrist',
                    'interior design' => 'designer', 'interior decorat' => 'designer', 'home staging' => 'designer', 'decorat' => 'designer',
                    'home inspect' => 'inspector', 'property inspect' => 'inspector', 'building inspect' => 'inspector',
                    'musician' => 'musician', 'singer' => 'musician', 'live music' => 'musician', 'entertain' => 'musician', 'vocalist' => 'musician',
                    'jewel' => 'jeweler', 'watch repair' => 'jeweler', 'goldsmith' => 'jeweler',
                    'shop' => 'place', 'store' => 'place', 'boutique' => 'place', 'cafe' => 'place', 'gym' => 'place',
                    'consultant' => 'face', 'coach' => 'face', 'freelance' => 'face',
                    'business' => 'general', 'office' => 'general', 'service' => 'general', 'agency' => 'general',
                ],
            ],

            'gallery' => [
                'mode' => 'category', 'cat' => 'work', 'count' => 6,
                // Archetype -> which gallery photos fit. All four cats are populated;
                // an unknown archetype still falls back to 'work'.
                'by_archetype' => ['face' => 'work', 'place' => 'interiors', 'product' => 'products', 'digital' => 'digital'],
                // WHAT THEY DO BEATS WHAT THE CLASSIFIER GUESSED. Four archetypes over
                // three pools meant a print shop, a soap maker and a bike mechanic all
                // came back 'product' and all drew from `products`, which is artisan
                // flatlays: Field & Main opened on handmade soap and leather bracelets
                // (Sergei 2026-08-01). Matched against the typed description, first hit
                // wins, and only where a pool genuinely exists for that kind of work.
                'by_trade' => [
                    'print' => 'print', 'sign' => 'print', 'signage' => 'print',
                    'banner' => 'print', 'engrav' => 'print', 'copy shop' => 'print',
                    'repair' => 'workshop', 'workshop' => 'workshop', 'tailor' => 'workshop',
                    'alteration' => 'workshop', 'upholster' => 'workshop', 'cobbler' => 'workshop',
                    'bike' => 'workshop', 'bicycle' => 'workshop', 'phone repair' => 'workshop',
                    'shop' => 'retail', 'store' => 'retail', 'boutique' => 'retail',
                    'florist' => 'retail', 'bakery' => 'retail', 'grocer' => 'retail',
                    'gift' => 'retail', 'deli' => 'retail',
                ],
            ],

            // Plain inquiry, no price promised (same shape as Instate).
            'quote' => ['sub' => 'Describe the job and {name} will come back with a price.'],

            'eyebrow' => [
                'default' => 'SMALL BUSINESS',
                'map' => [
                    // 'physical therapy' spelled OUT: kwHit has no -y suffix, so the
                    // old 'physical therap' key never matched anything.
                    'massage' => 'MASSAGE THERAPY', 'chiropract' => 'CHIROPRACTIC', 'physical therapy' => 'PHYSICAL THERAPY', 'physical therapist' => 'PHYSICAL THERAPY', 'physiotherapy' => 'PHYSICAL THERAPY', 'rehab' => 'PHYSICAL THERAPY',
                    'dentist' => 'DENTAL CARE', 'dental' => 'DENTAL CARE',
                    'groom' => 'PET GROOMING', 'dog walk' => 'DOG WALKING', 'pet sit' => 'PET SITTING',
                    'chauffeur' => 'CAR SERVICE', 'limo' => 'CAR SERVICE',
                    'dry clean' => 'DRY CLEANING', 'laundr' => 'LAUNDRY',
                    'photograph' => 'PHOTOGRAPHY', 'videograph' => 'VIDEO PRODUCTION',
                    'florist' => 'FLORIST', 'flower' => 'FLORIST',
                    'dj' => 'DJ SERVICES', 'wedding' => 'EVENT PLANNING', 'event plan' => 'EVENT PLANNING',
                    'tattoo' => 'TATTOO STUDIO', 'piercing' => 'PIERCING STUDIO',
                    'veterinary' => 'VETERINARY CARE', 'veterinarian' => 'VETERINARY CARE', 'vet clinic' => 'VETERINARY CARE', 'animal hospital' => 'VETERINARY CARE', 'pet daycare' => 'PET CARE', 'pet boarding' => 'PET CARE', 'kennel' => 'PET CARE',
                    'bike' => 'BIKE SHOP', 'bicycle' => 'BIKE SHOP',
                    'phone repair' => 'PHONE REPAIR', 'tailor' => 'ALTERATIONS', 'alteration' => 'ALTERATIONS',
                    'senior care' => 'HOME CARE', 'home care' => 'HOME CARE', 'caregiv' => 'HOME CARE',
                    'courier' => 'DELIVERY', 'delivery' => 'DELIVERY',
                    'daycare' => 'CHILDCARE', 'childcare' => 'CHILDCARE', 'preschool' => 'PRESCHOOL',
                    'lawyer' => 'LAW OFFICE', 'attorney' => 'LAW OFFICE', 'law firm' => 'LAW OFFICE', 'legal' => 'LEGAL SERVICES', 'notary' => 'NOTARY',
                    'accountant' => 'ACCOUNTING', 'bookkeep' => 'BOOKKEEPING', 'tax' => 'TAX SERVICES', 'cpa' => 'ACCOUNTING',
                    'doctor' => 'FAMILY MEDICINE', 'physician' => 'FAMILY MEDICINE', 'pediatric' => 'PEDIATRICS', 'medical' => 'MEDICAL CLINIC', 'clinic' => 'MEDICAL CLINIC',
                    'insurance' => 'INSURANCE',
                    'counseling' => 'COUNSELING', 'counselling' => 'COUNSELING', 'counselor' => 'COUNSELING', 'therapist' => 'COUNSELING', 'therapy' => 'COUNSELING',
                    'optometrist' => 'EYE CARE', 'optometry' => 'EYE CARE', 'optician' => 'EYE CARE', 'eye exam' => 'EYE CARE', 'eyewear' => 'EYE CARE',
                    'interior design' => 'INTERIOR DESIGN', 'decorat' => 'INTERIOR DESIGN', 'home staging' => 'HOME STAGING',
                    'home inspect' => 'HOME INSPECTIONS', 'property inspect' => 'HOME INSPECTIONS',
                    'musician' => 'LIVE MUSIC', 'singer' => 'LIVE MUSIC', 'live music' => 'LIVE MUSIC', 'entertain' => 'ENTERTAINMENT',
                    'jewel' => 'JEWELRY', 'jewelry' => 'JEWELRY', 'jewellery' => 'JEWELRY', 'watch repair' => 'WATCH REPAIR', 'goldsmith' => 'JEWELRY',
                    'shop' => 'LOCAL SHOP', 'store' => 'LOCAL STORE', 'boutique' => 'BOUTIQUE',
                    'consultant' => 'CONSULTING', 'agency' => 'AGENCY', 'studio' => 'STUDIO',
                    'freelance' => 'FREELANCE', 'service' => 'SERVICES',
                ],
            ],

            'gift' => [
                'default' => 'honest work and a fair price',
                'map' => [],
            ],

            'chooser' => [
                'label' => 'Any other business',
                'blurb' => 'whatever you do, get a real website free',
            ],

            'entry' => [
                'trade' => 'small business', 'brand' => 'Instinctor',
                'title' => 'Get your free business website | Instinctor',
                'eyebrow' => 'for <b>any business</b>',
                'name_ph' => 'Your business name',
                'trade_ph' => 'What you do and what makes you you. e.g. a neighborhood bakery, ten years on the same street, everything made fresh daily, known for custom cakes. The more you tell us, the more your site sounds like you.',
                'trade_aria' => 'Describe your business: what you do, your area, what makes you different. The more detail, the more unique your site copy.',
                'domain_ph' => 'yourbusiness.com',
            ],
        ],
    ];

    /** Every vertical key, in registry (match-precedence) order. */
    public static function keys(): array
    {
        return array_keys(self::REG);
    }

    public static function has(string $vertical): bool
    {
        return isset(self::REG[$vertical]);
    }

    /**
     * Does this vertical's template take bookings, or does it quote first?
     *
     * Registry-driven (a `books` flag, default true) rather than a `!== 'installer'`
     * blocklist: adding a quote-first vertical is one registry entry, not a new branch in
     * every consumer. Installer and Ingine set books=false; salon and instructor default
     * true.
     */
    public static function books(string $vertical): bool
    {
        return (bool) self::cfg($vertical, 'books', true);
    }

    /** Whole config for a vertical; falls back rather than fatal on an unknown key. */
    public static function get(string $vertical): array
    {
        return self::REG[$vertical] ?? self::REG[self::FALLBACK];
    }

    /** One config value: cfg('salon', 'pool.base'). Dot path, null-safe. */
    /**
     * Every registered master template page id. The registry templates are the one
     * set of pages no sweep may ever touch: three of them sat in trash for days
     * because they looked like abandoned demos to the cleanup passes (is_demo
     * markers left over from being built via the demo cloner, owner_id 0 instead
     * of NULL). Guards in purgeOldDemos and the owner-page sweeps read this list
     * (Sergei 2026-07-29).
     */
    /**
     * Profession profiles: the approved relevant/irrelevant feature matrix
     * (Sergei 2026-07-29). A vertical decides the template; the PROFESSION
     * decides what survives on it. Missing key = leave the template alone,
     * so an unprofiled trade behaves exactly as before.
     *
     *   hours    false drops the hours band (on-demand professions).
     *   address  false drops the address/map band entirely (dashboard can re-add).
     *   gallery  block:grid converts the portfolio to a grid gallery with
     *            lightbox; wall:X renders ONLY the typewall over it, no
     *            eyebrow, no title ("Inside / A look inside / INSIDE" x3 was
     *            the template showing).
     *   palette  mono locks the demo to the Noir scheme and hides the colour
     *            chips: photographers' sites are black and white.
     *   hero     pinned stops hero rotation: one profession, one hero.
     *   form     email/zip toggles on the quote form. ZIP stays only where
     *            the trade travels to the property.
     *   lead     booking swaps the quote form for the booking form.
     *   about    third moves ABOUT directly after the hero slogan.
     *   reviews  the profession's review pool: a photographer's clients do
     *            not praise cleanliness.
     */
    private const PROFESSIONS = [
        'photographer' => [
            'kw'      => ['photographer', 'photography', 'photo studio'],
            'hours'   => false,
            'address' => false,
            'gallery' => ['block' => 'grid', 'wall' => 'PORTFOLIO', 'tag' => 'photographer'],
            'palette' => 'mono',
            'hero'    => 'pinned',
            'form'    => ['email' => false, 'zip' => false],
            'about'   => 'third',
            'reviews' => 'photographer',
        ],
        'massage' => [
            'kw'      => ['massage', 'masseur', 'masseuse'],
            'hours'   => false,
            'address' => false,
            'gallery' => ['block' => 'grid', 'wall' => 'THE STUDIO', 'tag' => 'massage'],
            'form'    => ['email' => false, 'zip' => false],
            'lead'    => 'booking',
            'about'   => 'third',
            'reviews' => 'massage',
        ],
        'handyman'    => ['kw' => ['handyman', 'handywoman', 'odd jobs'], 'gallery' => ['tag' => 'handyman'],
                          'hours' => false, 'form' => ['email' => false, 'zip' => true], 'reviews' => 'handyman'],
        'plumber'     => ['kw' => ['plumber', 'plumbing'], 'gallery' => ['tag' => 'plumbing'],
                          'hours' => false, 'form' => ['email' => false, 'zip' => true], 'reviews' => 'plumber'],
        'electrician' => ['kw' => ['electrician', 'electrical'], 'gallery' => ['tag' => 'electrical'],
                          'hours' => false, 'form' => ['email' => false, 'zip' => true], 'reviews' => 'electrician'],
        // palette: a named THEMES entry pins the look pack for this profession, the
        // way 'mono' does for a photographer. HVAC reads navy, not sage green
        // (Sergei 2026-07-31). The chips stay unlocked, so a visitor can still change it.
        'hvac'        => ['kw' => ['hvac', 'heating', 'air conditioning', 'furnace'], 'gallery' => ['tag' => 'hvac'],
                          'palette' => 'Slate',
                          'hours' => false, 'form' => ['email' => false, 'zip' => true], 'reviews' => 'hvac'],
        'cleaning'    => ['kw' => ['cleaning', 'cleaner', 'maid'], 'gallery' => ['tag' => 'cleaning'],
                          'hours' => false, 'form' => ['email' => false, 'zip' => true], 'reviews' => 'cleaning'],
        'roofing'     => ['kw' => ['roofing', 'roofer', 'roof'], 'gallery' => ['tag' => 'roofing'],
                          'hours' => false, 'form' => ['email' => false, 'zip' => true], 'reviews' => 'roofing'],
        // Auto professions (2026-08-03, "automotive is a promising field"). Fixed
        // premises: hours + address stay on (customers drive in), so neither flag
        // is set. zip=false for the same reason: the shop does not travel. The
        // hero SPECIALTY routing (tire/bodyshop/detailing/carwash) already lives
        // in the ingine registry heroes.map; these profiles add the review voice
        // and the palette where the trade has an expected look. No 'garage' kw:
        // professionFor is cross-vertical and it would claim garage-DOOR trades.
        'auto-repair' => ['kw' => ['auto repair', 'auto shop', 'car repair', 'mechanic', 'brakes',
                                   'transmission', 'oil change', 'engine repair', 'muffler', 'exhaust', 'diagnostic'],
                          'palette' => 'Slate',
                          'form' => ['email' => false, 'zip' => false], 'reviews' => 'auto'],
        // Slate for these two as well: the seed rotation dealt Rose to a tire
        // shop on the first probe, the handyman-in-green mistake again. Cars
        // read industrial; only detailing earns its own look (Noir, below).
        'tire-shop'   => ['kw' => ['tire', 'tyre', 'wheel alignment', 'alignment'],
                          'palette' => 'Slate',
                          'form' => ['email' => false, 'zip' => false], 'reviews' => 'auto'],
        'body-shop'   => ['kw' => ['body shop', 'auto body', 'collision', 'dent repair'],
                          'palette' => 'Slate',
                          'form' => ['email' => false, 'zip' => false], 'reviews' => 'auto'],
        // Detailing sells shine: Noir reads as the glossy black car every
        // detailing brand already uses. Chips stay unlocked.
        'detailing'   => ['kw' => ['detailing', 'ceramic coating', 'car wash', 'carwash', 'window tint'],
                          'palette' => 'Noir',
                          'form' => ['email' => false, 'zip' => false], 'reviews' => 'detailing'],
        // Lessons professions keep hours and booking (a calendar genuinely
        // exists); the profile contributes the review voice.
        'music-teacher' => ['kw' => ['guitar', 'piano', 'violin', 'music school', 'music lessons', 'voice studio', 'singing'],
                            'reviews' => 'music'],
        'tutor'         => ['kw' => ['tutor', 'tutoring', 'test prep'],
                            'address' => false, 'reviews' => 'tutor'],
        'driving'       => ['kw' => ['driving school', 'driving lessons', 'driving instructor'],
                            'reviews' => 'driving'],
    ];

    /**
     * The profession a trade description belongs to, by word-start keyword,
     * same matching rule as forTrade(). '' when nothing matches: most trades
     * have no profile yet, and that must stay a safe answer.
     */
    /** Keywords of professions whose hero is pinned, for the entry page's
     *  live gate: the style question hides when there is nothing to choose. */
    public static function pinnedHeroKeywords(): array
    {
        $out = [];
        foreach (self::PROFESSIONS as $p) {
            if (($p['hero'] ?? '') !== 'pinned') continue;
            foreach ((array) ($p['kw'] ?? []) as $kw) $out[] = $kw;
        }
        return $out;
    }

    public static function professionFor(string $trade): string
    {
        $t = ' ' . strtolower($trade) . ' ';
        foreach (self::PROFESSIONS as $slug => $p) {
            foreach ((array) ($p['kw'] ?? []) as $kw) {
                if (preg_match('/\b' . preg_quote($kw, '/') . '/', $t)) return $slug;
            }
        }
        return '';
    }

    /** A profile value, or $default when the profession or key is absent. */
    public static function prof(string $profession, string $path, mixed $default = null): mixed
    {
        $node = self::PROFESSIONS[$profession] ?? null;
        if ($node === null) return $default;
        foreach (explode('.', $path) as $k) {
            if (!is_array($node) || !array_key_exists($k, $node)) return $default;
            $node = $node[$k];
        }
        return $node;
    }

    public static function templateIds(): array
    {
        $ids = [];
        foreach (self::REG as $cfg) {
            $t = (int) ($cfg['template'] ?? 0);
            if ($t > 0) $ids[] = $t;
        }
        return array_values(array_unique($ids));
    }

    public static function cfg(string $vertical, string $path, mixed $default = null): mixed
    {
        $node = self::get($vertical);
        foreach (explode('.', $path) as $k) {
            if (!is_array($node) || !array_key_exists($k, $node)) return $default;
            $node = $node[$k];
        }
        return $node;
    }

    /** vertical => template page id, for callers that just want the map. */
    public static function templates(): array
    {
        $out = [];
        foreach (self::REG as $k => $v) $out[$k] = $v['template'];
        return $out;
    }

    /**
     * Which vertical a trade resolves to. An exact vertical key wins, then family
     * keywords in registry order. Unknown trades fall back rather than 404, which is
     * what templateFor() has always done.
     */
    public static function forTrade(string $trade): string
    {
        $trade = strtolower(trim($trade));
        if ($trade === '') return self::FALLBACK;              // launch default
        if (isset(self::REG[$trade])) return $trade;
        // Disambiguation guards, checked before the family loop (same spirit as
        // the painting/ski precedents): a dry cleaner or laundromat is NOT a
        // cleaning-services trade, but installer's 'clean' stem would claim it.
        // A dentist mentioning "cleanings" fell into the same trap.
        if (preg_match('/dry\s*clean|laundr|wash\s*(and|&|-)?\s*fold/', $trade)) return self::FALLBACK;
        if (preg_match('/dent(ist|al)/', $trade)) return self::FALLBACK;
        // Pet services are not beauty salons ("nail trims") or cleaners ("bath").
        // Dog TRAINING stays clear of this guard and still reaches instructor.
        if (preg_match('/\b(dog|pet|puppy|cat)s?\b/', $trade)
            && preg_match('/groom|sitt|walk|board|daycare/', $trade)) return self::FALLBACK;
        foreach (self::REG as $key => $cfg) {
            foreach (($cfg['family'] ?? []) as $kw) {
                if (self::kwHit($trade, $kw)) return $key;
            }
        }
        return '';                                             // no match: caller decides
    }

    /**
     * Does $kw start a word in $subject?
     *
     * A plain substring test routes real trades to the wrong template, because short
     * keywords hide inside ordinary words. All of these shipped:
     *   "start a plumbing business" -> instructor  ('art' in st-ART)
     *   "instant quote hvac"        -> salon       ('tan' in ins-TAN-t)
     *   "skilled trades"            -> instructor  ('ski' in SKI-lled)
     *   "cheapest way to pay"       -> installer   ('pest' in chea-PEST)
     *   "sell digital products"     -> installer   ('duct' in pro-DUCT-s)
     *
     * The trailing side is bounded too, but through a SPELLED-OUT suffix list rather
     * than a bare \b or an open prefix. Both extremes shipped broken:
     *   - open prefix: 'install' hid inside "installment financing" (a loan shop became
     *     an installer) and 'window' inside "we fix Windows PCs" (a computer shop
     *     became a glazier).
     *   - bare trailing \b: killed every prefix key the maps are BUILT on: 'plumb' lost
     *     plumbing AND plumber, 'fenc' lost fencing and fence, 'exterminat' lost
     *     exterminator. Every single-word trade noun fell to the default badge (caught
     *     in the 2342 audit).
     * The suffix list is trade morphology: -e(s), -s, -ed, -er(s), -ing(s), -ian(s),
     * -al, -or(s), -ion(s), -work. 'plumb' reaches plumbing/plumber; 'install' still
     * cannot reach "installment" (-ment is deliberately absent).
     */
    private static function kwHit(string $subject, string $kw): bool
    {
        if ($kw === '') return false;
        if (preg_match('/\b' . preg_quote($kw, '/') . '(?:e|es|s|ed|er|ers|ing|ings|ian|ians|al|or|ors|ion|ions|work)?\b/iu', $subject) !== 1) return false;
        // "windows" the product, not the fitting: a description about computers must
        // never route to a glazier no matter how many windows it fixes.
        if (strcasecmp($kw, 'window') === 0
            && preg_match('/\b(pc|pcs|computer|computers|laptop|laptops|software|microsoft)\b/iu', $subject) === 1) {
            return false;
        }
        // "musician" the performer, not the teacher: the -ian suffix above lets
        // 'music' reach "musician" and 'voice'/'vocal'/'singing' reach singers, so
        // a live act booking gigs resolved to a lessons site. Performer wording
        // wins unless the description actually says it teaches.
        if (in_array(strtolower($kw), ['music', 'voice', 'vocal', 'singing'], true)
            && preg_match('/\b(musician|musicians|singer|singers|band|bands|gig|gigs|wedding|weddings|event|events|entertainer|entertainers|entertainment|performer|performers|performance|performances)\b/iu', $subject) === 1
            && preg_match('/\b(lesson|lessons|teach|teacher|teaching|tutor|tutoring|class|classes|school|academy|instructor|coach|coaching)\b/iu', $subject) === 0) {
            return false;
        }
        return true;
    }

    /** Template page id for a trade, or 0 when nothing matches. */
    public static function templateFor(string $trade): int
    {
        $v = self::forTrade($trade);
        return $v === '' ? 0 : (int) self::REG[$v]['template'];
    }

    /** Entry-screen routes: path => vertical. Plugin registers one route per entry. */
    public static function routes(): array
    {
        $out = [];
        foreach (self::REG as $k => $v) {
            if (!empty($v['route'])) $out[$v['route']] = $k;
        }
        return $out;
    }

    /**
     * First-keyword-wins lookup against a vertical's keyword map (eyebrow, gift, heroes).
     *
     * Uses the same leading-boundary rule as forTrade(): these maps are matched against
     * the SAME free-typed trade string, so a plain substring test mis-picks here too
     * ('pest' in "cheapest" would have labelled a page PEST CONTROL).
     */
    public static function matchMap(string $vertical, string $path, string $subject, string $default = ''): string
    {
        $map = self::cfg($vertical, $path, []);
        if (!is_array($map)) return $default;
        $s = strtolower($subject);
        foreach ($map as $kw => $val) {
            if (self::kwHit($s, (string) $kw)) return (string) $val;
        }
        return $default;
    }
}
