<?php

declare(strict_types=1);

namespace Instinctor\Api;

use Instinctor\Core\Config;
use Instinctor\Core\DB;
use Instinctor\Core\Storage;
use Instinctor\Core\Auth;
use Instinctor\Core\Router;
use Instinctor\Core\CartSession;

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

/**
 * REST API Controller - provides headless commerce capability
 */
class RestController
{

    public function __construct()
    {
        $this->registerRoutes();
    }

    public function registerRoutes(): void
    {
        // Page health diagnostic + repair
        Router::restRaw('/pages/(?P<id>\d+)/health', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'pageHealth'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        // Per-page revision history (right-click "History" in page grid)
        Router::restRaw('/pages/(?P<id>\d+)/revisions', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'listPageRevisions'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        // /pages/by-designer/:id removed 2.1.413 — fed the platform's
        // "create gig from page" picker, which no longer exists.

        // Solo-signup slug check — public, answers "is this URL available
        // under instinctor.com/instinctor/?". Used by the /start landing
        // form for live validation before the user commits to payment.
        Router::restRaw('/solo/slug-available', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'soloSlugAvailable'],
                'permission_callback' => '__return_true',
            ],
        ]);
        // Demo lead capture — public. The /pizza sales demo's quote form posts here
        // and we email the enquiry to the Instinctor contact inbox. Stores nothing.
        Router::restRaw('/demo-lead', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'submitDemoLead'],
                'permission_callback' => '__return_true',
            ],
        ]);
        // AI domain finder — public SUGGEST only (read-only, spends nothing).
        // Returns AI-generated candidates checked for availability with retail
        // prices. Inert until instinctor_namesilo.api_key is set (isEnabled).
        // Registration is deliberately NOT a public endpoint: DomainService::
        // register() is called server-side only after a confirmed domain
        // purchase, so no endpoint can spend money on demand. (Checkout wiring: TODO.)
        Router::restRaw('/domains/suggest', [
            [
                'methods'             => \WP_REST_Server::CREATABLE,
                'permission_callback' => '__return_true',
                'callback'            => function (\WP_REST_Request $r) {
                    if (!\Instinctor\Services\DomainService::isEnabled()) {
                        return new \WP_REST_Response(['error' => 'domain_finder_unconfigured'], 503);
                    }
                    $biz = [
                        'name'        => (string) ($r->get_param('name') ?? ''),
                        'trade'       => (string) ($r->get_param('trade') ?? ''),
                        'city'        => (string) ($r->get_param('city') ?? ''),
                        'description' => (string) ($r->get_param('description') ?? ''),
                    ];
                    if (trim($biz['name'] . $biz['trade']) === '') {
                        return new \WP_REST_Response(['error' => 'need_name_or_trade'], 422);
                    }
                    $count = max(5, min(30, (int) ($r->get_param('count') ?: 20)));
                    return new \WP_REST_Response(\Instinctor\Services\DomainService::suggest($biz, $count), 200);
                },
            ],
        ]);
        // Personalized instant demo — public. Visitor POSTs {trade,name}; we
        // clone the trade's cinematic template, inject the name, and return a
        // signed /demo/<token> URL that renders anonymously. Rate-limited in
        // the handler (public write). No stored PII beyond the demo page itself.
        Router::restRaw('/demo/start', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'demoStart'],
                'permission_callback' => '__return_true',
            ],
        ]);
        // Recording tooling: the track recorder uploads the pass it captured so it
        // can be read straight off the server instead of copied out of a console.
        // Public by necessity (the recorder runs in a plain visitor session) but
        // write-only, size-capped, and it lands in one fixed file that nothing
        // renders. Not part of the product; it exists so a recorded pass can be
        // turned into a replay track.
        Router::restRaw('/track/save', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'trackSave'],
                'permission_callback' => '__return_true',
            ],
        ]);
        // The player reads the track back. storage/ is not web-served (nginx aliases
        // only /storage/uploads/), and tracks should stay out of the public webroot
        // anyway, so reading goes through the API like writing does.
        Router::restRaw('/track/load', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'trackLoad'],
                'permission_callback' => '__return_true',
            ],
        ]);
        // Persist a visitor's overlay edits (photo swaps) onto their demo page
        // so they survive reload and bake into the claimed site. Public but the
        // signed demo token gates writes to THAT page; swaps validated to local
        // image paths. Rate-limited in the handler.
        Router::restRaw('/demo/persist', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'demoPersist'],
                'permission_callback' => '__return_true',
            ],
        ]);
        // Switch a demo's business archetype (face/place/product/digital) -> re-picks its
        // hero + gallery. Same token/page_id gate as demoPersist; rate-limited.
        Router::restRaw('/demo/archetype', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'demoArchetype'],
                'permission_callback' => '__return_true',
            ],
        ]);
        // Upload a visitor's OWN photo into the demo/edit overlay (their real hero,
        // team, gallery images instead of presets). Same token/page_id gate as
        // demoPersist; validates real images with a size cap; rate-limited.
        Router::restRaw('/demo/upload', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'demoUpload'],
                'permission_callback' => '__return_true',
            ],
        ]);
        // Provision a solo-signup site — called by Platform from the
        // Stripe checkout success handler after the subscription is
        // created. Platform-token authenticated. Creates the user and
        // the blank Impulse page under owner_id = instinctor-team.
        Router::restRaw('/solo/provision', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'soloProvision'],
                'permission_callback' => [$this, 'canPlatformCall'],
            ],
        ]);
        // Set/clear a tenant owner's Pro flag. Platform-token authenticated: the
        // platform calls this on Pro checkout (pro=1) and on cancel/lapse (pro=0).
        // This is the ONLY writer of instinctor_owner_pro (TenantPlan reads it).
        Router::restRaw('/solo/set-pro', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'soloSetPro'],
                'permission_callback' => [$this, 'canPlatformCall'],
            ],
        ]);
        // AI Domain Finder — platform payment callback. After an owner completes
        // a domain purchase in the platform's Stripe Checkout, the platform calls
        // this (X-Platform-Token) to drive NameSilo registration. Registered on
        // instinctor.com ONLY: this is a money-path callback, and payments run
        // through instinctor.com + platform.instinctor.com exclusively (Academy /
        // Studio accept none).
        if (\Instinctor\Core\Plugin::isInstinctorApp()) {
            Router::restRaw('/solo/domain-fulfill', [
                [
                    'methods' => \WP_REST_Server::CREATABLE,
                    'callback' => [$this, 'soloDomainFulfill'],
                    'permission_callback' => [$this, 'canPlatformCall'],
                ],
            ]);
        }
        // Deprovision a solo/tenant site — the cascade side of the platform's
        // "delete customer". Trashes every page the customer's account owns so the
        // site leaves the grid, the sitemap, and instinctor.com/<slug>. Platform-
        // token authenticated; guarded to site_owner accounts only.
        Router::restRaw('/solo/deprovision', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'soloDeprovision'],
                'permission_callback' => [$this, 'canPlatformCall'],
            ],
        ]);
        // Directory admin bridge — the curation UI lives on the platform
        // (platform.instinctor.com/directory); the registry rows live HERE,
        // next to owners and the public /directory renderer. Platform-token
        // auth, and registered on instinctor.com only: a client standalone
        // must not expose even an empty directory registry.
        if (\Instinctor\Core\Plugin::isInstinctorApp()) {
            Router::restRaw('/directory/admin/list', [
                [
                    'methods' => \WP_REST_Server::READABLE,
                    'callback' => [$this, 'directoryAdminList'],
                    'permission_callback' => [$this, 'canPlatformCall'],
                ],
            ]);
            Router::restRaw('/directory/admin/save', [
                [
                    'methods' => \WP_REST_Server::CREATABLE,
                    'callback' => [$this, 'directoryAdminSave'],
                    'permission_callback' => [$this, 'canPlatformCall'],
                ],
            ]);
            Router::restRaw('/directory/admin/delete', [
                [
                    'methods' => \WP_REST_Server::CREATABLE,
                    'callback' => [$this, 'directoryAdminDelete'],
                    'permission_callback' => [$this, 'canPlatformCall'],
                ],
            ]);
            Router::restRaw('/directory/admin/config', [
                [
                    'methods' => \WP_REST_Server::CREATABLE,
                    'callback' => [$this, 'directoryAdminConfig'],
                    'permission_callback' => [$this, 'canPlatformCall'],
                ],
            ]);
            Router::restRaw('/directory/admin/messages', [
                [
                    'methods' => \WP_REST_Server::READABLE,
                    'callback' => [$this, 'directoryAdminMessages'],
                    'permission_callback' => [$this, 'canPlatformCall'],
                ],
            ]);
            Router::restRaw('/directory/admin/messages/resend', [
                [
                    'methods' => \WP_REST_Server::CREATABLE,
                    'callback' => [$this, 'directoryAdminResend'],
                    'permission_callback' => [$this, 'canPlatformCall'],
                ],
            ]);
            Router::restRaw('/directory/admin/rotate-token', [
                [
                    'methods' => \WP_REST_Server::CREATABLE,
                    'callback' => [$this, 'directoryAdminRotateToken'],
                    'permission_callback' => [$this, 'canPlatformCall'],
                ],
            ]);
        }
        // Free-publish: turn a live demo into a real published FREE site, no payment.
        // Public route — the handler authenticates the SIGNED demo token itself (only
        // DemoService can mint it), so no platform token is required here.
        Router::restRaw('/demo/publish-free', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'publishFree'],
                'permission_callback' => '__return_true',
            ],
        ]);
        // Passwordless login: a returning owner enters their email and we email a
        // magic link back into /dashboard. Public; ALWAYS responds success so the
        // page can't be used to enumerate which emails have accounts. Rate-limited.
        Router::restRaw('/login/magic', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'loginMagic'],
                'permission_callback' => '__return_true',
            ],
        ]);
        // Own-domain request: custom domains are a done-for-you setup (DNS + SSL),
        // not self-serve, so the visitor asks and we handle it. Stored in
        // form_submissions (admin inbox) so a request survives even if mail is down.
        Router::restRaw('/domain/request', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'domainRequest'],
                'permission_callback' => '__return_true',
            ],
        ]);
        // Account password change — authenticated users only. The
        // handler verifies the current password before writing the new
        // bcrypt hash; routes to wp_set_password() in WordPress mode
        // when the user lives in wp_users. v2.1.1312.
        Router::restRaw('/account/password', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'changeAccountPassword'],
                'permission_callback' => [$this, 'isLoggedIn'],
            ],
        ]);
        Router::restRaw('/pages/(?P<id>\d+)/revisions/(?P<rev>\d+)/restore', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'restorePageRevision'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        Router::restRaw('/pages/(?P<id>\d+)/repair', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'pageRepair'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Products
        Router::restRaw('/products', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getProducts'],
                'permission_callback' => '__return_true',
            ],
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'createProduct'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/products/(?P<id>\d+)', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getProduct'],
                'permission_callback' => '__return_true',
            ],
            [
                'methods' => \WP_REST_Server::EDITABLE,
                'callback' => [$this, 'updateProduct'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::DELETABLE,
                'callback' => [$this, 'deleteProduct'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Cart
        Router::restRaw('/cart', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getCart'],
                'permission_callback' => '__return_true',
            ],
        ]);

        Router::restRaw('/cart/add', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'addToCart'],
                'permission_callback' => '__return_true',
            ],
        ]);

        Router::restRaw('/cart/update', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'updateCart'],
                'permission_callback' => '__return_true',
            ],
        ]);

        Router::restRaw('/cart/remove', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'removeFromCart'],
                'permission_callback' => '__return_true',
            ],
        ]);

        Router::restRaw('/authorizenet/webhook', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'authorizenetWebhook'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Coupon endpoints
        Router::restRaw('/coupon/apply', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'applyCoupon'],
                'permission_callback' => '__return_true',
            ],
        ]);

        Router::restRaw('/coupon/remove', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'removeCoupon'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Gift Card endpoints
        Router::restRaw('/gift-card/apply', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'applyGiftCard'],
                'permission_callback' => '__return_true',
            ],
        ]);

        Router::restRaw('/gift-card/remove', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'removeGiftCard'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Loyalty Points endpoints
        Router::restRaw('/loyalty/apply', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'applyLoyaltyPoints'],
                'permission_callback' => [$this, 'isLoggedIn'],
            ],
        ]);

        Router::restRaw('/loyalty/remove', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'removeLoyaltyPoints'],
                'permission_callback' => [$this, 'isLoggedIn'],
            ],
        ]);

        // Shipping endpoints
        Router::restRaw('/shipping/calculate', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'calculateShipping'],
                'permission_callback' => '__return_true',
            ],
        ]);

        Router::restRaw('/shipping/free-threshold', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => [$this, 'getFreeShippingThreshold'],
                'permission_callback' => '__return_true',
            ],
        ]);

        Router::restRaw('/shipping/select', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'selectShippingMethod'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Built-in analytics — single endpoint returns the full dashboard
        // payload for a date range. range = today | 7d | 30d | 90d | all,
        // page_id optional (filters viewsOverTime + topReferrers).
        Router::restRaw('/analytics/summary', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => [$this, 'analyticsSummary'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Google Search Console summary (vendor property, service-account
        // auth, 6h server cache). Panel hides itself when unavailable.
        Router::restRaw('/analytics/gsc', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => function (\WP_REST_Request $r) { return new \WP_REST_Response(\Instinctor\Services\GscService::summary((string) ($r->get_param('range') ?: '28d')), 200); },
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Referrer exclusion list. GET returns the configured hosts, POST replaces
        // them. A reporting filter only: nothing is deleted, so a host removed
        // from the list brings its history back on the next load.
        Router::restRaw('/analytics/exclusions', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => function () {
                    return new \WP_REST_Response([
                        'hosts' => \Instinctor\Services\AnalyticsService::excludedReferrers(),
                        'raw'   => (string) \Instinctor\Core\Config::get('analytics_excluded_referrers', ''),
                    ], 200);
                },
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods'             => \WP_REST_Server::CREATABLE,
                'callback'            => function (\WP_REST_Request $r) {
                    $raw = (string) $r->get_param('hosts');
                    // Cap the stored blob: this is a hand-typed shortlist, not a feed.
                    $raw = substr(sanitize_textarea_field($raw), 0, 2000);
                    \Instinctor\Core\Config::set('analytics_excluded_referrers', $raw);
                    return new \WP_REST_Response([
                        'ok'    => true,
                        'hosts' => \Instinctor\Services\AnalyticsService::excludedReferrers(),
                    ], 200);
                },
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Public behavior-tracker beacon — frontend visitors POST engagement
        // (active time / scroll depth / exit screen) on page exit via
        // navigator.sendBeacon. Unauthenticated by design; the handler clamps
        // every value and only updates a row by its random view_token.
        Router::restRaw('/analytics/beacon', [
            [
                'methods'             => \WP_REST_Server::CREATABLE,
                'callback'            => [$this, 'analyticsBeacon'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Order bump endpoints
        Router::restRaw('/cart/add-bump', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'addOrderBump'],
                'permission_callback' => '__return_true',
            ],
        ]);

        Router::restRaw('/cart/remove-bump', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'removeOrderBump'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Checkout
        Router::restRaw('/checkout', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'processCheckout'],
                'permission_callback' => [$this, 'verifyCheckoutNonce'],
            ],
        ]);

        Router::restRaw('/checkout/payment-intent', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'createPaymentIntent'],
                'permission_callback' => [$this, 'verifyCheckoutNonce'],
            ],
        ]);

        Router::restRaw('/checkout/confirm', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'confirmPayment'],
                'permission_callback' => [$this, 'verifyCheckoutNonce'],
            ],
        ]);

        // Orders
        Router::restRaw('/orders', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getOrders'],
                'permission_callback' => [$this, 'canViewOrders'],
            ],
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'createOrder'],
                'permission_callback' => [$this, 'canManageOrders'],
            ],
        ]);

        Router::restRaw('/orders/(?P<id>\d+)', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getOrder'],
                'permission_callback' => [$this, 'canViewOrder'],
            ],
            [
                'methods' => \WP_REST_Server::EDITABLE,
                'callback' => [$this, 'updateOrder'],
                'permission_callback' => [$this, 'canManageOrders'],
            ],
        ]);

        // Customers (admin only)
        Router::restRaw('/customers', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getCustomers'],
                'permission_callback' => [$this, 'canManageCustomers'],
            ],
        ]);

        Router::restRaw('/customers/(?P<id>\d+)', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getCustomer'],
                'permission_callback' => [$this, 'canManageCustomers'],
            ],
        ]);

        // Customer login (public)
        Router::restRaw('/customer/login', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'customerLogin'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Categories
        Router::restRaw('/categories', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getCategories'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Settings (admin only)
        Router::restRaw('/settings', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getSettings'],
                'permission_callback' => [$this, 'canManageSettings'],
            ],
            [
                'methods' => \WP_REST_Server::EDITABLE,
                'callback' => [$this, 'updateSettings'],
                'permission_callback' => [$this, 'canManageSettings'],
            ],
        ]);

        // Stats (admin only)
        Router::restRaw('/stats', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getStats'],
                'permission_callback' => [$this, 'canViewStats'],
            ],
        ]);


        // Demo data
        Router::restRaw('/demo/generate', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'generateDemoData'],
                'permission_callback' => [$this, 'canManageSettings'],
            ],
        ]);

        // PayPal
        Router::restRaw('/paypal/create-order', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'createPayPalOrder'],
                'permission_callback' => '__return_true',
            ],
        ]);

        Router::restRaw('/paypal/capture', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'capturePayPalOrder'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Template Editor API
        Router::restRaw('/templates', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getTemplates'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'createTemplate'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/templates/(?P<id>\d+)', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getTemplate'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::EDITABLE,
                'callback' => [$this, 'updateTemplate'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::DELETABLE,
                'callback' => [$this, 'deleteTemplate'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/templates/(?P<id>\d+)/sections', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getTemplateSections'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'createTemplateSection'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/templates/(?P<id>\d+)/sections/(?P<section_id>\d+)', [
            [
                'methods' => \WP_REST_Server::EDITABLE,
                'callback' => [$this, 'updateTemplateSection'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::DELETABLE,
                'callback' => [$this, 'deleteTemplateSection'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/templates/(?P<id>\d+)/reorder', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'reorderTemplateSections'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Impulse Page Builder API (aliases for templates with element support)
        Router::restRaw('/pages', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getImpulsePages'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'createImpulsePage'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/pages/(?P<id>\d+)', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getImpulsePage'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::EDITABLE,
                'callback' => [$this, 'updateImpulsePage'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::DELETABLE,
                'callback' => [$this, 'deleteImpulsePage'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/pages/(?P<id>\d+)/settings', [
            [
                'methods' => 'POST',
                'callback' => [$this, 'patchImpulsePageSettings'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/pages/(?P<id>\d+)/restore', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'restoreImpulsePage'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/pages/(?P<id>\d+)/permanent', [
            [
                'methods' => \WP_REST_Server::DELETABLE,
                'callback' => [$this, 'permanentDeleteImpulsePage'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);


        Router::restRaw('/pages/(?P<id>\\d+)/thumbnail', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'saveImpulsePageThumbnail'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        // Batch-capture target list — published pages with no thumbnail yet.
        // Used by the admin's batch-capture sequencer to drive the existing
        // html2canvas pipeline across every empty page in one walk.
        Router::restRaw('/pages/empty-thumbnails', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'listEmptyThumbnailPages'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        Router::restRaw('/pages/reorder', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'reorderImpulsePages'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        Router::restRaw('/pages/(?P<id>\d+)/duplicate', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'duplicateImpulsePage'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/pages/(?P<id>\d+)/export', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'exportImpulsePage'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/pages/import', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'importImpulsePage'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // ── Full-site export / import ─────────────────────────────────────────
        Router::restRaw('/site/export', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => [$this, 'exportSite'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        Router::restRaw('/site/import', [
            [
                'methods'             => \WP_REST_Server::CREATABLE,
                'callback'            => [$this, 'importSite'],
                'permission_callback' => [$this, 'canImportSite'],
            ],
        ]);

        Router::restRaw('/pages/(?P<id>\d+)/elements', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getImpulseElements'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'createImpulseElement'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/pages/(?P<id>\d+)/section-template', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'createImpulseSectionFromTemplate'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // GET /pages/{id}/complexity — endpoint removed 2026-05-09 along
        // with the topbar VU meter. PageComplexity::compute() and the
        // getPageComplexity handler below are kept (cheap, no-side-effect)
        // so existing callers don't 500; nothing in the editor calls them now.

        Router::restRaw('/pages/(?P<id>\d+)/wrap-orphans', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'wrapOrphanElements'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/pages/(?P<id>\d+)/expand-sliders', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'expandSliders'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/pages/(?P<id>\d+)/set-section-min-heights', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'setSectionMinHeights'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/elements/(?P<id>\d+)', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getImpulseElement'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::EDITABLE,
                'callback' => [$this, 'updateImpulseElement'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::DELETABLE,
                'callback' => [$this, 'deleteImpulseElement'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/elements/(?P<id>\d+)/duplicate', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'duplicateImpulseElement'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/elements/(?P<id>\d+)/duplicate-slide', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'duplicateSlide'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/elements/(?P<id>\d+)/duplicate-row', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'duplicateGridRow'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/elements/(?P<id>\d+)/duplicate-cell', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'duplicateGridCell'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Basic grid: duplicate ONE cell's content + per-cell styling into the
        // first empty cell (grows a column only when the grid is full).
        Router::restRaw('/elements/(?P<id>\d+)/duplicate-grid-cell', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'duplicateBasicGridCell'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Paste a copied subtree (editor clipboard) onto a page under a target
        // parent, with fresh IDs. Powers cross-page Ctrl+C / Ctrl+V of whole
        // sections. No {id} in the path — the source tree is in the body.
        Router::restRaw('/elements/paste-tree', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'pasteImpulseElementTree'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/elements/(?P<id>\d+)/tree', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getImpulseElementTree'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/elements/(?P<id>\d+)/reorder', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'reorderImpulseElement'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/elements/(?P<id>\d+)/move-into', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'moveImpulseElementInto'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Swap cell content in rows
        Router::restRaw('/elements/(?P<id>\d+)/swap-cells', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'swapCellGridCells'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Impulse Global Pages (Header/Footer)
        Router::restRaw('/impulse/global-page', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'createGlobalPage'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        
        // Global Header/Footer Settings (save/load)
        Router::restRaw('/impulse/global-settings', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getGlobalSettings'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'saveGlobalSettings'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Product Add-Ons (public read). The frontend product card calls
        // this on init to know whether to render an add-on options form;
        // a missing route was leaving a 404 in every console even though
        // the .catch() swallowed the error functionally.
        Router::restRaw('/products/(?P<id>\d+)/addons', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => [$this, 'getProductAddonsApi'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Volume discount tiers for a product (public read)
        Router::restRaw('/products/(?P<id>\d+)/volume-tiers', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => [$this, 'getProductVolumeTiers'],
                'permission_callback' => '__return_true',
            ],
        ]);

        // Newsletter routes
        Router::restRaw('/newsletter/subscribe', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'newsletterSubscribe'],
                'permission_callback' => '__return_true',
            ],
        ]);

        Router::restRaw('/newsletter/unsubscribe', [
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'newsletterUnsubscribe'],
                'permission_callback' => '__return_true',
            ],
        ]);

        Router::restRaw('/newsletter/subscribers', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getNewsletterSubscribers'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::DELETABLE,
                'callback' => [$this, 'deleteNewsletterSubscribers'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/newsletter/stats', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getNewsletterStats'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/newsletter/export', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'exportNewsletterSubscribers'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        Router::restRaw('/newsletter/settings', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'getNewsletterSettings'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'saveNewsletterSettings'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // ── SEO Engine ───────────────────────────────────────────────────────
        Router::restRaw('/seo/overview', [
            'methods' => \WP_REST_Server::READABLE,
            'callback' => [$this, 'seoGetOverview'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/pages', [
            'methods' => \WP_REST_Server::READABLE,
            'callback' => [$this, 'seoGetPages'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/pages/(?P<id>\d+)', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'seoGetPageData'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'seoSavePageData'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        // PageSpeed Insights for one page. Runs a live Lighthouse render on
        // Google's side (10-30s), so it is user-triggered only and served from a
        // 24h cache; ?refresh=1 forces a new run.
        Router::restRaw('/seo/pages/(?P<id>\d+)/pagespeed', [
            'methods' => \WP_REST_Server::READABLE,
            'callback' => [$this, 'seoPageSpeed'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/pages/(?P<id>\d+)/analyze', [
            'methods' => \WP_REST_Server::READABLE,
            'callback' => [$this, 'seoAnalyzePage'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/redirects', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'seoGetRedirects'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'seoSaveRedirect'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        Router::restRaw('/seo/redirects/(?P<id>\d+)', [
            [
                'methods' => \WP_REST_Server::EDITABLE,
                'callback' => [$this, 'seoUpdateRedirect'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::DELETABLE,
                'callback' => [$this, 'seoDeleteRedirect'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        Router::restRaw('/seo/sitemap/generate', [
            'methods'             => \WP_REST_Server::CREATABLE,
            'callback'            => [$this, 'seoGenerateSitemap'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/schema-preview', [
            'methods'             => \WP_REST_Server::READABLE,
            'callback'            => [$this, 'seoSchemaPreview'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/robots-suggest', [
            'methods'             => \WP_REST_Server::CREATABLE,
            'callback'            => [$this, 'seoRobotsSuggest'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/settings', [
            [
                'methods' => \WP_REST_Server::READABLE,
                'callback' => [$this, 'seoGetSettings'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods' => \WP_REST_Server::CREATABLE,
                'callback' => [$this, 'seoSaveSettings'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        Router::restRaw('/seo/pages/search', [
            'methods'             => \WP_REST_Server::READABLE,
            'callback'            => [$this, 'seoSearchPages'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/robots', [
            'methods' => \WP_REST_Server::READABLE,
            'callback' => [$this, 'seoGetRobots'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/ai-batch-meta', [
            'methods'             => \WP_REST_Server::CREATABLE,
            'callback'            => [$this, 'seoAIBatchMeta'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/ai-generate', [
            'methods' => \WP_REST_Server::CREATABLE,
            'callback' => [$this, 'seoAIGenerate'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/images/(?P<id>\d+)/generate-alt', [
            'methods'             => \WP_REST_Server::CREATABLE,
            'callback'            => [$this, 'seoGenerateImageAlt'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);

        // ── Image optimization (called from SEO workspace) ───────────────────
        Router::restRaw('/seo/pages/(?P<id>\d+)/images', [
            'methods'             => \WP_REST_Server::READABLE,
            'callback'            => [$this, 'seoGetPageImages'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/pages/(?P<id>\d+)/images/optimize', [
            'methods'             => \WP_REST_Server::CREATABLE,
            'callback'            => [$this, 'seoOptimizePageImages'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/seo/images/(?P<id>\d+)/restore', [
            'methods'             => \WP_REST_Server::CREATABLE,
            'callback'            => [$this, 'seoRestoreImage'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);

        // Currencies
        Router::restRaw('/currencies', [
            'methods'             => \WP_REST_Server::READABLE,
            'callback'            => [$this, 'getCurrencies'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/currencies', [
            'methods'             => \WP_REST_Server::CREATABLE,
            'callback'            => [$this, 'saveCurrency'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/currencies/(?P<id>\d+)', [
            'methods'             => \WP_REST_Server::DELETABLE,
            'callback'            => [$this, 'deleteCurrency'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/currencies/(?P<id>\d+)/set-default', [
            'methods'             => \WP_REST_Server::CREATABLE,
            'callback'            => [$this, 'setDefaultCurrency'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);

        // Team
        Router::restRaw('/team', [
            'methods'             => \WP_REST_Server::READABLE,
            'callback'            => [$this, 'getTeam'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/team/invite', [
            'methods'             => \WP_REST_Server::CREATABLE,
            'callback'            => [$this, 'inviteTeamMember'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/team/(?P<id>\d+)', [
            'methods'             => \WP_REST_Server::EDITABLE,
            'callback'            => [$this, 'updateTeamMember'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/team/(?P<id>\d+)/role', [
            'methods'             => \WP_REST_Server::CREATABLE,
            'callback'            => [$this, 'setTeamRole'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);
        Router::restRaw('/team/(?P<id>\d+)/revoke', [
            'methods'             => \WP_REST_Server::CREATABLE,
            'callback'            => [$this, 'revokeTeamAccess'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);

        // Beta feedback
        Router::restRaw('/feedback', [
            'methods'             => \WP_REST_Server::CREATABLE,
            'callback'            => [$this, 'submitFeedback'],
            'permission_callback' => [$this, 'canManageProducts'],
        ]);

        // ─── Local library ─────────────────────────────────────────────────
        // Per-user saved sections & scenes. Admin-only per-user scope; each
        // user sees only their own items. Shape mirrors Platform's
        // {prefix}sections table so items can be promoted to the global
        // library without translation.
        Router::restRaw('/library/sections', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => [$this, 'listLibrarySections'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods'             => \WP_REST_Server::CREATABLE,
                'callback'            => [$this, 'createLibrarySection'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        Router::restRaw('/library/sections/(?P<id>\d+)', [
            [
                'methods'             => \WP_REST_Server::EDITABLE,
                'callback'            => [$this, 'updateLibrarySection'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods'             => \WP_REST_Server::DELETABLE,
                'callback'            => [$this, 'deleteLibrarySection'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Scene templates (whole scene-mode pages — background + scene root +
        // screens). Surfaced on the Page Grid, not in the editor palette,
        // because you can't insert a scene inside a scene.
        Router::restRaw('/library/scenes', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => [$this, 'listLibraryScenes'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods'             => \WP_REST_Server::CREATABLE,
                'callback'            => [$this, 'createLibraryScene'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        Router::restRaw('/library/scenes/(?P<id>\d+)', [
            [
                'methods'             => \WP_REST_Server::EDITABLE,
                'callback'            => [$this, 'updateLibraryScene'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
            [
                'methods'             => \WP_REST_Server::DELETABLE,
                'callback'            => [$this, 'deleteLibraryScene'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        Router::restRaw('/library/scenes/(?P<id>\d+)/insert', [
            [
                'methods'             => \WP_REST_Server::CREATABLE,
                'callback'            => [$this, 'insertLibraryScene'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);

        // Platform global library — read-only proxy with 5-min transient cache.
        // Plugin fetches server-side (not direct browser CORS) so caching
        // amortises across every editor session on the install.
        Router::restRaw('/library/platform/sections', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => [$this, 'listPlatformSections'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        Router::restRaw('/library/platform/scenes', [
            [
                'methods'             => \WP_REST_Server::READABLE,
                'callback'            => [$this, 'listPlatformScenes'],
                'permission_callback' => [$this, 'canManageProducts'],
            ],
        ]);
        // Admin upload — only this install (Instinctor admin) has the
        // INSTINCTOR_PLATFORM_TOKEN env set, so normal installs hit a 403.
        Router::restRaw('/library/platform/upload', [
            [
                'methods'             => \WP_REST_Server::CREATABLE,
                'callback'            => [$this, 'uploadToPlatformLibrary'],
                'permission_callback' => [$this, 'canUploadToPlatformLibrary'],
            ],
        ]);

        // Rolls REST endpoints removed 2026-05-23 (no scene mode anymore).
    }

    // Permission callbacks
    public function canManageProducts(): bool
    {
        return Auth::can('manage_options');
    }

    /**
     * Permission: admin session + presence of INSTINCTOR_PLATFORM_TOKEN.
     *
     * Gates "Upload to Platform Library" — only the Instinctor admin install
     * has the shared secret defined in its env, so designer/self-serve
     * installs hit this permission gate with 403 and the upload UI should
     * never be shown to them in the first place.
     */
    public function canUploadToPlatformLibrary(): bool
    {
        if (!Auth::can('manage_options')) return false;
        $token = defined('INSTINCTOR_PLATFORM_TOKEN') ? INSTINCTOR_PLATFORM_TOKEN : '';
        return $token !== '';
    }

    /**
     * Permission: valid X-Platform-Token header. For cross-system calls from
     * platform.instinctor.com (e.g. listing pages owned by a designer for the
     * "create gig from page" picker, SSO token verification).
     *
     * Defines INSTINCTOR_PLATFORM_TOKEN env constant; reject if not set.
     */
    public function canPlatformCall(): bool
    {
        $token = defined('INSTINCTOR_PLATFORM_TOKEN') ? INSTINCTOR_PLATFORM_TOKEN : '';
        if (!$token) return false;
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
        $header = sanitize_text_field($_SERVER['HTTP_X_PLATFORM_TOKEN'] ?? '');
        return $header !== '' && hash_equals($token, $header);
    }

    /**
     * Permission: admin session OR valid X-Deploy-Token header.
     * Used for site import endpoint so platform deploy job can call it
     * without an admin session.
     */
    public function canImportSite(): bool
    {
        // Accept admin session
        if (Auth::can('manage_options')) return true;

        // Accept deploy token
        $deployToken = defined('INSTINCTOR_DEPLOY_TOKEN') ? INSTINCTOR_DEPLOY_TOKEN : '';
        if ($deployToken) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- deploy token is a hex hash; sanitization would corrupt it
            $headerToken = sanitize_text_field( $_SERVER['HTTP_X_DEPLOY_TOKEN'] ?? '' );
            if ($headerToken && hash_equals($deployToken, $headerToken)) return true;
        }

        return false;
    }

    public function canManageOrders(): bool
    {
        return Auth::can('manage_options');
    }

    public function canViewOrders(\WP_REST_Request $request): bool
    {
        return Auth::check();
    }

    public function canViewOrder(\WP_REST_Request $request): bool
    {
        if (Auth::can('manage_options')) {
            return true;
        }
        // TODO: Check if order belongs to current user
        return Auth::check();
    }

    public function canManageCustomers(): bool
    {
        return Auth::can('manage_options');
    }

    public function canManageSettings(): bool
    {
        return Auth::can('manage_options');
    }

    public function canViewStats(): bool
    {
        return Auth::can('manage_options');
    }

    // Product endpoints
    public function getProducts(\WP_REST_Request $request): \WP_REST_Response
    {
        $table = DB::prefix() . 'instinctor_products';
        $cat_table = DB::prefix() . 'instinctor_product_categories';

        $page = (int) $request->get_param('page') ?: 1;
        $per_page = (int) $request->get_param('per_page') ?: 20;
        $offset = ($page - 1) * $per_page;
        $status = $request->get_param('status') ?: 'published';
        $search = $request->get_param('search');
        $category = $request->get_param('category');

        $where = "WHERE p.status = %s";
        $params = [$status];
        $join = "";

        if ($search) {
            $where .= " AND (p.name LIKE %s OR p.sku LIKE %s)";
            $params[] = '%' . DB::esc_like($search) . '%';
            $params[] = '%' . DB::esc_like($search) . '%';
        }

        if ($category) {
            $join = " INNER JOIN $cat_table pc ON p.id = pc.product_id";
            $where .= " AND pc.category_id = %d";
            $params[] = (int) $category;
        }

        $total = DB::get_var(DB::prepare(
            "SELECT COUNT(DISTINCT p.id) FROM $table p $join $where",
            ...$params
        ));

        $products = DB::get_results(DB::prepare(
            "SELECT DISTINCT p.* FROM $table p $join $where ORDER BY p.created_at DESC LIMIT %d OFFSET %d",
            ...array_merge($params, [$per_page, $offset])
        ));

        // Add image URLs to each product
        foreach ($products as &$product) {
            $product->image_url = '';
            if (!empty($product->featured_image)) {
                $url = wp_get_attachment_image_url($product->featured_image, 'thumbnail');
                if ($url) {
                    $product->image_url = $url;
                }
            }
            // Fallback to images JSON field (URL-based)
            if (empty($product->image_url) && !empty($product->images)) {
                $imagesData = json_decode($product->images, true);
                if (is_array($imagesData) && !empty($imagesData[0]['url'])) {
                    $product->image_url = $imagesData[0]['url'];
                }
            }
            // Fallback to gallery_images (attachment IDs)
            if (empty($product->image_url) && !empty($product->gallery_images)) {
                $galleryIds = array_filter(array_map('intval', explode(',', $product->gallery_images)));
                if (!empty($galleryIds)) {
                    $url = wp_get_attachment_image_url($galleryIds[0], 'thumbnail');
                    if ($url) {
                        $product->image_url = $url;
                    }
                }
            }
        }

        return new \WP_REST_Response([
            'data' => $products,
            'meta' => [
                'total' => (int) $total,
                'page' => $page,
                'per_page' => $per_page,
                'total_pages' => ceil($total / $per_page),
            ],
        ]);
    }

    public function getProduct(\WP_REST_Request $request): \WP_REST_Response
    {
        $table = DB::prefix() . 'instinctor_products';
        $id = (int) $request->get_param('id');

        $product = DB::get_row(DB::prepare(
            "SELECT * FROM $table WHERE id = %d",
            $id
        ));

        if (!$product) {
            return new \WP_REST_Response(['error' => 'Product not found'], 404);
        }
        
        // If images field is empty but gallery_images exists, convert to images format
        if (empty($product->images) && !empty($product->gallery_images)) {
            $galleryIds = json_decode($product->gallery_images, true);
            if (is_array($galleryIds)) {
                $images = [];
                // Add featured image first
                if ($product->featured_image) {
                    $url = wp_get_attachment_image_url($product->featured_image, 'large');
                    if ($url) {
                        $images[] = ['id' => $product->featured_image, 'url' => $url];
                    }
                }
                // Add gallery images
                foreach ($galleryIds as $attId) {
                    $url = wp_get_attachment_image_url($attId, 'large');
                    if ($url) {
                        $images[] = ['id' => $attId, 'url' => $url];
                    }
                }
                // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                $product->images = json_encode($images);
                // phpcs:enable WordPress.WP.AlternativeFunctions
            }
        } elseif (empty($product->images) && $product->featured_image) {
            // Just featured image, no gallery
            $url = wp_get_attachment_image_url($product->featured_image, 'large');
            if ($url) {
                // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                $product->images = json_encode([['id' => $product->featured_image, 'url' => $url]]);
                // phpcs:enable WordPress.WP.AlternativeFunctions
            }
        }

        return new \WP_REST_Response($product);
    }

    public function createProduct(\WP_REST_Request $request): \WP_REST_Response
    {
        $table = DB::prefix() . 'instinctor_products';

        // Build meta for booking options and currency prices
        $meta = [];
        if ($request->get_param('is_bookable')) {
            $meta['is_bookable'] = true;
            $meta['booking_duration'] = intval($request->get_param('booking_duration') ?: 60);
            $meta['booking_buffer_before'] = intval($request->get_param('booking_buffer_before') ?: 0);
            $meta['booking_buffer_after'] = intval($request->get_param('booking_buffer_after') ?: 15);
            $meta['booking_max_per_slot'] = intval($request->get_param('booking_max_per_slot') ?: 1);
        }
        
        // Handle per-currency pricing
        $currencyPrices = $request->get_param('currency_prices');
        if (!empty($currencyPrices) && is_array($currencyPrices)) {
            $sanitizedPrices = [];
            foreach ($currencyPrices as $code => $price) {
                if ($price !== '' && $price !== null) {
                    $sanitizedPrices[sanitize_text_field($code)] = floatval($price);
                }
            }
            if (!empty($sanitizedPrices)) {
                $meta['currency_prices'] = $sanitizedPrices;
            }
        }

        $data = [
            'name' => sanitize_text_field((string)($request->get_param('name') ?? '')),
            'slug' => sanitize_title($request->get_param('name')),
            'description' => wp_kses_post($request->get_param('description')),
            'short_description' => sanitize_textarea_field($request->get_param('short_description')),
            'product_type' => sanitize_text_field($request->get_param('product_type') ?: 'physical'),
            'status' => sanitize_text_field($request->get_param('status') ?: 'draft'),
            'price' => floatval($request->get_param('price')),
            'sale_price' => $request->get_param('sale_price') ? floatval($request->get_param('sale_price')) : null,
            'sku' => sanitize_text_field((string)($request->get_param('sku') ?? '')),
            'stock_quantity' => $request->get_param('stock_quantity') ? intval($request->get_param('stock_quantity')) : null,
            'manage_stock' => (bool) $request->get_param('manage_stock'),
            'featured_image' => $request->get_param('featured_image') ? intval($request->get_param('featured_image')) : null,
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            'meta' => !empty($meta) ? json_encode($meta) : null,
            // phpcs:enable WordPress.WP.AlternativeFunctions
        ];

        DB::insert($table, $data);
        $id = DB::insert_id();

        if (!$id) {
            return new \WP_REST_Response(['error' => 'Failed to create product'], 500);
        }

        $product = DB::get_row(DB::prepare(
            "SELECT * FROM $table WHERE id = %d",
            $id
        ));

        return new \WP_REST_Response(['data' => $product], 201);
    }

    public function updateProduct(\WP_REST_Request $request): \WP_REST_Response
    {
        $table = DB::prefix() . 'instinctor_products';
        $id = (int) $request->get_param('id');

        $existing = DB::get_row(DB::prepare(
            "SELECT * FROM $table WHERE id = %d",
            $id
        ));

        if (!$existing) {
            return new \WP_REST_Response(['error' => 'Product not found'], 404);
        }

        $data = [];
        $fields = ['name', 'description', 'short_description', 'product_type', 'status', 'price', 'sale_price', 'sku', 'stock_quantity', 'manage_stock', 'featured_image', 'gallery_images'];

        foreach ($fields as $field) {
            if ($request->has_param($field)) {
                $value = $request->get_param($field);
                
                switch ($field) {
                    case 'name':
                        $data['name'] = sanitize_text_field($value);
                        $data['slug'] = sanitize_title($value);
                        break;
                    case 'description':
                        $data['description'] = wp_kses_post($value);
                        break;
                    case 'price':
                    case 'sale_price':
                        $data[$field] = $value !== null ? floatval($value) : null;
                        break;
                    case 'stock_quantity':
                    case 'featured_image':
                        $data[$field] = $value !== null ? intval($value) : null;
                        break;
                    case 'manage_stock':
                        $data[$field] = (bool) $value;
                        break;
                    case 'gallery_images':
                        // Validate JSON array of image IDs
                        // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                        $data[$field] = is_string($value) ? $value : json_encode($value);
                        // phpcs:enable WordPress.WP.AlternativeFunctions
                        break;
                    default:
                        $data[$field] = sanitize_text_field($value);
                }
            }
        }

        if (!empty($data)) {
            DB::update($table, $data, ['id' => $id]);
        }

        // Handle categories
        if ($request->has_param('categories')) {
            $categories = $request->get_param('categories');
            if (is_array($categories)) {
                \Instinctor\Services\CategoryService::setProductCategories($id, $categories);
            }
        }

        // Handle booking meta
        if ($request->has_param('is_bookable')) {
            $existingMeta = $existing->meta ? json_decode($existing->meta, true) : [];
            
            if ($request->get_param('is_bookable')) {
                $existingMeta['is_bookable'] = true;
                $existingMeta['booking_duration'] = intval($request->get_param('booking_duration') ?: 60);
                $existingMeta['booking_buffer_before'] = intval($request->get_param('booking_buffer_before') ?: 0);
                $existingMeta['booking_buffer_after'] = intval($request->get_param('booking_buffer_after') ?: 15);
                $existingMeta['booking_max_per_slot'] = intval($request->get_param('booking_max_per_slot') ?: 1);
            } else {
                unset($existingMeta['is_bookable']);
                unset($existingMeta['booking_duration']);
                unset($existingMeta['booking_buffer_before']);
                unset($existingMeta['booking_buffer_after']);
                unset($existingMeta['booking_max_per_slot']);
            }
            
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            DB::update($table, ['meta' => json_encode($existingMeta)], ['id' => $id]);
            // phpcs:enable WordPress.WP.AlternativeFunctions
        }
        
        // Handle per-currency pricing
        if ($request->has_param('currency_prices')) {
            $existingMeta = $existing->meta ? json_decode($existing->meta, true) : [];
            // Re-fetch in case booking meta was just updated
            $refreshed = DB::get_row(DB::prepare("SELECT meta FROM $table WHERE id = %d", $id));
            if ($refreshed && $refreshed->meta) {
                $existingMeta = json_decode($refreshed->meta, true) ?: [];
            }
            
            $currencyPrices = $request->get_param('currency_prices');
            if (is_array($currencyPrices)) {
                $sanitizedPrices = [];
                foreach ($currencyPrices as $code => $price) {
                    if ($price !== '' && $price !== null && $price !== 0) {
                        $sanitizedPrices[sanitize_text_field($code)] = floatval($price);
                    }
                }
                if (!empty($sanitizedPrices)) {
                    $existingMeta['currency_prices'] = $sanitizedPrices;
                } else {
                    unset($existingMeta['currency_prices']);
                }
                // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                DB::update($table, ['meta' => json_encode($existingMeta)], ['id' => $id]);
                // phpcs:enable WordPress.WP.AlternativeFunctions
            }
        }

        $product = DB::get_row(DB::prepare(
            "SELECT * FROM $table WHERE id = %d",
            $id
        ));

        return new \WP_REST_Response(['data' => $product]);
    }

    public function deleteProduct(\WP_REST_Request $request): \WP_REST_Response
    {
        $table = DB::prefix() . 'instinctor_products';
        $id = (int) $request->get_param('id');

        $deleted = DB::delete($table, ['id' => $id]);

        if (!$deleted) {
            return new \WP_REST_Response(['error' => 'Product not found'], 404);
        }

        return new \WP_REST_Response(['success' => true]);
    }

    // Cart endpoints
    public function getCart(\WP_REST_Request $request): \WP_REST_Response
    {
        $cartService = new \Instinctor\Services\CartService();
        return new \WP_REST_Response(['data' => $cartService->getCart()]);
    }

    public function addToCart(\WP_REST_Request $request): \WP_REST_Response
    {
        $productId = (int) $request->get_param('product_id');
        $quantity  = (int) ($request->get_param('quantity') ?: 1);
        $meta      = $request->get_param('meta') ?: [];
        $addons    = $request->get_param('addons') ?: [];

        // Merge validated addons into meta so CartService can apply pricing
        if (!empty($addons) && is_array($addons)) {
            $meta['addons'] = array_map(function($v) {
                return is_array($v) ? array_map('sanitize_text_field', $v) : sanitize_text_field($v);
            }, $addons);
        }

        $cartService = new \Instinctor\Services\CartService();
        $result = $cartService->addItem($productId, $quantity, $meta);

        if (!$result['success']) {
            return new \WP_REST_Response(['error' => $result['error']], 400);
        }

        return new \WP_REST_Response($result);
    }

    public function updateCart(\WP_REST_Request $request): \WP_REST_Response
    {
        $itemKey = $request->get_param('item_key');
        $quantity = (int) $request->get_param('quantity');

        $cartService = new \Instinctor\Services\CartService();
        $result = $cartService->updateItem($itemKey, $quantity);

        if (!$result['success']) {
            return new \WP_REST_Response(['error' => $result['error']], 400);
        }

        return new \WP_REST_Response($result);
    }

    public function removeFromCart(\WP_REST_Request $request): \WP_REST_Response
    {
        $itemKey = $request->get_param('item_key');

        $cartService = new \Instinctor\Services\CartService();
        $result = $cartService->removeItem($itemKey);

        return new \WP_REST_Response($result);
    }

    public function applyCoupon(\WP_REST_Request $request): \WP_REST_Response
    {
        $code = sanitize_text_field((string)($request->get_param('code') ?? ''));
        $email = sanitize_email($request->get_param('email'));

        if (empty($code)) {
            return new \WP_REST_Response(['success' => false, 'error' => __('Please enter a coupon code.', 'instinctor')], 400);
        }

        $cartService = new \Instinctor\Services\CartService();
        $cart = $cartService->getCart();

        if (empty($cart['items'])) {
            return new \WP_REST_Response(['success' => false, 'error' => __('Your cart is empty.', 'instinctor')], 400);
        }

        $couponService = new \Instinctor\Services\CouponService();
        $result = $couponService->applyCoupon($code, $cart, $email);

        if (!$result['valid']) {
            return new \WP_REST_Response(['success' => false, 'error' => $result['error']], 400);
        }

        // Store coupon in session
        $cartService->applyCoupon($result['coupon'], $result['discount']);

        return new \WP_REST_Response([
            'success' => true,
            'coupon' => $result['coupon'],
            'discount' => $result['discount'],
            'cart' => $cartService->getCart(),
        ]);
    }

    public function removeCoupon(\WP_REST_Request $request): \WP_REST_Response
    {
        $cartService = new \Instinctor\Services\CartService();
        $cartService->removeCoupon();

        return new \WP_REST_Response([
            'success' => true,
            'cart' => $cartService->getCart(),
        ]);
    }

        public function getFreeShippingThreshold(\WP_REST_Request $request): \WP_REST_Response
    {
        $shippingService = new \Instinctor\Services\ShippingService();
        $zones = $shippingService->getZones();
        $threshold = null;

        foreach ($zones as $zone) {
            $methods = $shippingService->getMethods($zone->id);
            foreach ($methods as $method) {
                if ($method->method_type === 'shipping' && $method->min_amount > 0) {
                    if ($threshold === null || $method->min_amount < $threshold) {
                        $threshold = (float) $method->min_amount;
                    }
                }
            }
        }

        // Current cart subtotal
        $cartService = new \Instinctor\Services\CartService();
        $cart = $cartService->getCart();
        $subtotal = (float) ($cart['totals']['subtotal'] ?? 0);

        return new \WP_REST_Response([
            'success'   => true,
            'threshold' => $threshold,
            'subtotal'  => $subtotal,
            'remaining' => $threshold !== null ? max(0, $threshold - $subtotal) : null,
            'achieved'  => $threshold !== null && $subtotal >= $threshold,
        ]);
    }

public function calculateShipping(\WP_REST_Request $request): \WP_REST_Response
    {
        $address = [
            'country' => sanitize_text_field($request->get_param('country') ?? ''),
            'state' => sanitize_text_field($request->get_param('state') ?? ''),
            'postcode' => sanitize_text_field($request->get_param('postcode') ?? ''),
            'city' => sanitize_text_field($request->get_param('city') ?? ''),
        ];

        $cartService = new \Instinctor\Services\CartService();
        $result = $cartService->setShippingAddress($address);

        return new \WP_REST_Response($result);
    }

    public function selectShippingMethod(\WP_REST_Request $request): \WP_REST_Response
    {
        $methodId = (int) $request->get_param('method_id');

        if (!$methodId) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Method ID required'], 400);
        }

        $cartService = new \Instinctor\Services\CartService();
        $result = $cartService->setShippingMethod($methodId);

        return new \WP_REST_Response($result);
    }

    public function addOrderBump(\WP_REST_Request $request): \WP_REST_Response
    {
        $bumpId = sanitize_text_field((string)($request->get_param('bump_id') ?? ''));

        if (empty($bumpId)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Bump ID required'], 400);
        }

        $cartService = new \Instinctor\Services\CartService();
        $result = $cartService->addBump($bumpId);

        return new \WP_REST_Response($result);
    }

    public function removeOrderBump(\WP_REST_Request $request): \WP_REST_Response
    {
        $bumpId = sanitize_text_field((string)($request->get_param('bump_id') ?? ''));

        if (empty($bumpId)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Bump ID required'], 400);
        }

        $cartService = new \Instinctor\Services\CartService();
        $result = $cartService->removeBump($bumpId);

        return new \WP_REST_Response($result);
    }

    // Checkout
    /**
     * Authorize.net webhook receiver. Public (AN posts here), verified by the HMAC-SHA512
     * signature, then reconciled by SubscriptionService.
     */
    public function authorizenetWebhook(\WP_REST_Request $request): \WP_REST_Response
    {
        $raw = (string) $request->get_body();
        $key = (string) \Instinctor\Core\Config::get('instinctor_authorizenet_signature_key', '');
        if ($key === '') {
            return new \WP_REST_Response(['error' => 'Webhook signature key not configured'], 503);
        }
        $sig = (string) ($request->get_header('x-anet-signature') ?: sanitize_text_field(wp_unslash($_SERVER['HTTP_X_ANET_SIGNATURE'] ?? '')));
        $provided = strtoupper(trim(preg_replace('/^sha512=/i', '', $sig)));
        $asHex = strtoupper(hash_hmac('sha512', $raw, $key));
        $asBin = ctype_xdigit($key) ? strtoupper(hash_hmac('sha512', $raw, hex2bin($key))) : '';
        if ($provided === '' || (!hash_equals($asHex, $provided) && ($asBin === '' || !hash_equals($asBin, $provided)))) {
            return new \WP_REST_Response(['error' => 'Invalid signature'], 401);
        }
        $event = json_decode($raw, true);
        if (!is_array($event)) {
            return new \WP_REST_Response(['error' => 'Invalid payload'], 400);
        }
        $result = \Instinctor\Services\SubscriptionService::instance()->handleAuthorizenetWebhook(
            (string) ($event['eventType'] ?? ''),
            (array) ($event['payload'] ?? [])
        );
        return new \WP_REST_Response(array_merge(['received' => true], $result), 200);
    }

    /**
     * Permission callback for the checkout endpoints. Guest checkout is allowed,
     * so we cannot require a logged-in user; instead we require a valid REST
     * nonce (the checkout page issues wp_create_nonce('wp_rest') and the JS
     * sends it as the X-WP-Nonce header) to block cross-site request forgery.
     */
    public function verifyCheckoutNonce(\WP_REST_Request $request)
    {
        $nonce = (string) $request->get_header('X-WP-Nonce');
        if (wp_verify_nonce($nonce, 'wp_rest')) {
            return true;
        }
        return new \WP_Error(
            'rest_forbidden',
            __('Invalid or missing security token. Please reload the page and try again.', 'instinctor'),
            ['status' => 403]
        );
    }

    public function processCheckout(\WP_REST_Request $request): \WP_REST_Response
    {
        $cartService = new \Instinctor\Services\CartService();
        $checkoutService = new \Instinctor\Services\CheckoutService($cartService);

        $data = [
            'billing' => $request->get_param('billing') ?: [],
            'shipping' => $request->get_param('shipping'),
            'payment_method' => $request->get_param('payment_method') ?: 'stripe',
            'notes' => $request->get_param('notes'),
            'create_account' => $request->get_param('create_account') ?: false,
            'account_password' => $request->get_param('account_password') ?: '',
            'authorizenet_nonce' => $request->get_param('authorizenet_nonce') ?: '',
            'authorizenet_descriptor' => $request->get_param('authorizenet_descriptor') ?: '',
            'use_saved_card' => $request->get_param('use_saved_card') ?: false,
            
        ];

        $result = $checkoutService->processCheckout($data);

        if (!$result['success']) {
            return new \WP_REST_Response(['error' => $result['error']], 400);
        }

        return new \WP_REST_Response($result);
    }

    public function createPaymentIntent(\WP_REST_Request $request): \WP_REST_Response
    {
        $cartService = new \Instinctor\Services\CartService();
        $cart = $cartService->getCart();

        if (empty($cart['items'])) {
            return new \WP_REST_Response(['error' => 'Cart is empty'], 400);
        }

        $checkoutService = new \Instinctor\Services\CheckoutService($cartService);
        $currency = Config::get('instinctor_currency', 'USD');
        $result = $checkoutService->createPaymentIntent($cart['totals']['total'], $currency);

        if (!$result['success']) {
            return new \WP_REST_Response(['error' => $result['error']], 400);
        }

        return new \WP_REST_Response($result);
    }

    public function confirmPayment(\WP_REST_Request $request): \WP_REST_Response
    {
        $orderId = (int) $request->get_param('order_id');
        $paymentIntentId = $request->get_param('payment_intent_id');

        $cartService = new \Instinctor\Services\CartService();
        $checkoutService = new \Instinctor\Services\CheckoutService($cartService);
        $result = $checkoutService->confirmPayment($orderId, $paymentIntentId);

        if (!$result['success']) {
            return new \WP_REST_Response(['error' => $result['error']], 400);
        }

        return new \WP_REST_Response($result);
    }

    // Orders (placeholder implementations)
    public function getOrders(\WP_REST_Request $request): \WP_REST_Response
    {
        $table = DB::prefix() . 'instinctor_orders';

        $orders = DB::get_results("SELECT * FROM $table ORDER BY created_at DESC LIMIT 50");

        return new \WP_REST_Response(['data' => $orders]);
    }

    public function getOrder(\WP_REST_Request $request): \WP_REST_Response
    {
        $table = DB::prefix() . 'instinctor_orders';
        $id = (int) $request->get_param('id');

        $order = DB::get_row(DB::prepare(
            "SELECT * FROM $table WHERE id = %d",
            $id
        ));

        if (!$order) {
            return new \WP_REST_Response(['error' => 'Order not found'], 404);
        }

        // Get order items
        $items_table = DB::prefix() . 'instinctor_order_items';
        $order->items = DB::get_results(DB::prepare(
            "SELECT * FROM $items_table WHERE order_id = %d",
            $id
        ));

        return new \WP_REST_Response(['data' => $order]);
    }

    public function createOrder(\WP_REST_Request $request): \WP_REST_Response
    {
        // TODO: Implement
        return new \WP_REST_Response(['success' => true]);
    }

    public function updateOrder(\WP_REST_Request $request): \WP_REST_Response
    {
        // TODO: Implement
        return new \WP_REST_Response(['success' => true]);
    }

    // Customers
    public function getCustomers(\WP_REST_Request $request): \WP_REST_Response
    {
        $table = DB::prefix() . 'instinctor_customers';

        $customers = DB::get_results("SELECT * FROM $table ORDER BY created_at DESC LIMIT 50");

        return new \WP_REST_Response(['data' => $customers]);
    }

    public function getCustomer(\WP_REST_Request $request): \WP_REST_Response
    {
        $table = DB::prefix() . 'instinctor_customers';
        $id = (int) $request->get_param('id');

        $customer = DB::get_row(DB::prepare(
            "SELECT * FROM $table WHERE id = %d",
            $id
        ));

        if (!$customer) {
            return new \WP_REST_Response(['error' => 'Customer not found'], 404);
        }

        return new \WP_REST_Response(['data' => $customer]);
    }

    // Categories
    public function getCategories(\WP_REST_Request $request): \WP_REST_Response
    {
        $table = DB::prefix() . 'instinctor_categories';

        $categories = DB::get_results("SELECT * FROM $table ORDER BY sort_order ASC, name ASC");

        return new \WP_REST_Response(['data' => $categories]);
    }

    // Settings
    public function getSettings(\WP_REST_Request $request): \WP_REST_Response
    {
        return new \WP_REST_Response([
            'data' => [
                'store_type' => Config::get('instinctor_store_type', 'mixed'),
                'store_name' => Config::get('instinctor_store_name', get_bloginfo('name')),
                'currency' => Config::get('instinctor_currency', 'USD'),
                'country' => Config::get('instinctor_country', 'US'),
                'payment_methods' => Config::get('instinctor_payment_methods', []),
                'tax_enabled' => Config::get('instinctor_tax_enabled', false),
            ],
        ]);
    }

    public function updateSettings(\WP_REST_Request $request): \WP_REST_Response
    {
        $settings = ['store_type', 'store_name', 'currency', 'country', 'tax_enabled'];

        foreach ($settings as $setting) {
            if ($request->has_param($setting)) {
                Config::set('instinctor_' . $setting, $request->get_param($setting));
            }
        }

        return $this->getSettings($request);
    }

    // Stats
    public function getStats(\WP_REST_Request $request): \WP_REST_Response
    {
        
        $orders_table = DB::prefix() . 'instinctor_orders';
        $products_table = DB::prefix() . 'instinctor_products';
        $customers_table = DB::prefix() . 'instinctor_customers';

        $total_orders = DB::get_var("SELECT COUNT(*) FROM $orders_table");
        $total_revenue = DB::get_var("SELECT SUM(total) FROM $orders_table WHERE status IN ('completed', 'processing')") ?: 0;
        $total_products = DB::get_var("SELECT COUNT(*) FROM $products_table WHERE status = 'published'");
        $total_customers = DB::get_var("SELECT COUNT(*) FROM $customers_table");

        // Recent orders for dashboard
        $recent_orders = DB::get_results("SELECT * FROM $orders_table ORDER BY created_at DESC LIMIT 5");

        return new \WP_REST_Response([
            'data' => [
                'total_orders' => (int) $total_orders,
                'total_revenue' => (float) $total_revenue,
                'total_products' => (int) $total_products,
                'total_customers' => (int) $total_customers,
                'recent_orders' => $recent_orders,
            ],
        ]);
    }



    public function generateDemoData(\WP_REST_Request $request): \WP_REST_Response
    {
        $table = DB::prefix() . 'instinctor_products';

        $demoProducts = [
            [
                'name' => 'Classic T-Shirt',
                'slug' => 'classic-t-shirt',
                'description' => 'A comfortable, everyday essential. Made from 100% organic cotton.',
                'short_description' => 'Soft organic cotton tee.',
                'product_type' => 'physical',
                'status' => 'published',
                'price' => 29.99,
                'sale_price' => null,
                'sku' => 'TSH-001',
                'stock_quantity' => 50,
                'manage_stock' => 1,
            ],
            [
                'name' => 'Premium Hoodie',
                'slug' => 'premium-hoodie',
                'description' => 'Stay warm in style with our premium fleece hoodie. Features a kangaroo pocket and adjustable drawstring.',
                'short_description' => 'Cozy fleece hoodie.',
                'product_type' => 'physical',
                'status' => 'published',
                'price' => 79.99,
                'sale_price' => 59.99,
                'sku' => 'HOD-001',
                'stock_quantity' => 30,
                'manage_stock' => 1,
            ],
            [
                'name' => 'Wireless Earbuds',
                'slug' => 'wireless-earbuds',
                'description' => 'Crystal clear audio with active noise cancellation. 24-hour battery life with charging case.',
                'short_description' => 'Premium wireless earbuds with ANC.',
                'product_type' => 'physical',
                'status' => 'published',
                'price' => 149.99,
                'sale_price' => null,
                'sku' => 'EAR-001',
                'stock_quantity' => 100,
                'manage_stock' => 1,
            ],
            [
                'name' => 'Digital Course: Photography Basics',
                'slug' => 'photography-basics-course',
                'description' => '10 video lessons covering camera settings, composition, and lighting fundamentals.',
                'short_description' => 'Learn photography from scratch.',
                'product_type' => 'digital',
                'status' => 'published',
                'price' => 49.99,
                'sale_price' => null,
                'sku' => 'CRS-001',
                'downloadable' => 1,
                'virtual' => 1,
            ],
            [
                'name' => 'Ceramic Coffee Mug',
                'slug' => 'ceramic-coffee-mug',
                'description' => 'Handcrafted ceramic mug with a comfortable handle. Dishwasher and microwave safe.',
                'short_description' => 'Handcrafted 12oz mug.',
                'product_type' => 'physical',
                'status' => 'published',
                'price' => 24.99,
                'sale_price' => 19.99,
                'sku' => 'MUG-001',
                'stock_quantity' => 75,
                'manage_stock' => 1,
            ],
            [
                'name' => 'Leather Wallet',
                'slug' => 'leather-wallet',
                'description' => 'Genuine full-grain leather wallet with RFID blocking. Slim design fits comfortably in any pocket.',
                'short_description' => 'Slim RFID-blocking wallet.',
                'product_type' => 'physical',
                'status' => 'published',
                'price' => 59.99,
                'sale_price' => null,
                'sku' => 'WAL-001',
                'stock_quantity' => 40,
                'manage_stock' => 1,
            ],
        ];

        $count = 0;
        foreach ($demoProducts as $product) {
            // Check if already exists (excluding trashed)
            $exists = DB::get_var(DB::prepare(
                "SELECT id FROM $table WHERE slug = %s AND status != 'trash'",
                $product['slug']
            ));

            if ($exists) {
                continue;
            }
            
            // Check if trashed version exists - update it instead
            $trashedId = DB::get_var(DB::prepare(
                "SELECT id FROM $table WHERE slug = %s AND status = 'trash'",
                $product['slug']
            ));
            
            if ($trashedId) {
                // Restore and update the trashed product
                DB::update($table, $product, ['id' => $trashedId]);
                $count++;
            } else {
                // Create new
                DB::insert($table, $product);
                $count++;
            }
        }

        return new \WP_REST_Response([
            'success' => true,
            'data' => ['count' => $count],
        ]);
    }

    // PayPal endpoints
    public function createPayPalOrder(\WP_REST_Request $request): \WP_REST_Response
    {
        $cartService = new \Instinctor\Services\CartService();
        $cart = $cartService->getCart();

        if (empty($cart['items'])) {
            return new \WP_REST_Response(['error' => 'Cart is empty'], 400);
        }

        $clientId = Config::get('instinctor_paypal_client_id');
        $secret = Config::get('instinctor_paypal_secret');
        $sandbox = Config::get('instinctor_paypal_sandbox', true);

        if (!$clientId || !$secret) {
            return new \WP_REST_Response(['error' => 'PayPal not configured'], 400);
        }

        $baseUrl = $sandbox 
            ? 'https://api-m.sandbox.paypal.com' 
            : 'https://api-m.paypal.com';

        // Get access token
        $authResponse = wp_remote_post($baseUrl . '/v1/oauth2/token', [
            'headers' => [
                'Authorization' => 'Basic ' . base64_encode($clientId . ':' . $secret),
                'Content-Type' => 'application/x-www-form-urlencoded',
            ],
            'body' => 'grant_type=client_credentials',
        ]);

        if (is_wp_error($authResponse)) {
            return new \WP_REST_Response(['error' => 'PayPal auth failed'], 500);
        }

        // WP.org B2 — defensive: PayPal response may be a JSON-encoded error
        // string rather than an object on rate-limit / outage. Force an array.
        $authBody = json_decode(wp_remote_retrieve_body($authResponse), true);
        if (!is_array($authBody)) { $authBody = []; }
        $accessToken = $authBody['access_token'] ?? null;

        if (!$accessToken) {
            return new \WP_REST_Response(['error' => 'PayPal auth failed'], 500);
        }

        // Create order
        $currency = strtoupper(Config::get('instinctor_currency', 'USD'));
        $total = number_format($cart['totals']['total'], 2, '.', '');

        $orderData = [
            'intent' => 'CAPTURE',
            'purchase_units' => [[
                'amount' => [
                    'currency_code' => $currency,
                    'value' => $total,
                ],
            ]],
        ];

        $orderResponse = wp_remote_post($baseUrl . '/v2/checkout/orders', [
            'headers' => [
                'Authorization' => 'Bearer ' . $accessToken,
                'Content-Type' => 'application/json',
            ],
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            'body' => json_encode($orderData),
            // phpcs:enable WordPress.WP.AlternativeFunctions
        ]);

        if (is_wp_error($orderResponse)) {
            return new \WP_REST_Response(['error' => 'Failed to create PayPal order'], 500);
        }

        $orderBody = json_decode(wp_remote_retrieve_body($orderResponse), true);
        if (!is_array($orderBody)) { $orderBody = []; } // WP.org B2

        if (empty($orderBody['id'])) {
            return new \WP_REST_Response(['error' => $orderBody['message'] ?? 'PayPal error'], 500);
        }

        return new \WP_REST_Response([
            'success' => true,
            'order_id' => $orderBody['id'],
        ]);
    }

    public function capturePayPalOrder(\WP_REST_Request $request): \WP_REST_Response
    {
        $paypalOrderId = $request->get_param('paypal_order_id');
        $billing = $request->get_param('billing') ?: [];

        if (!$paypalOrderId) {
            return new \WP_REST_Response(['error' => 'Missing PayPal order ID'], 400);
        }

        $clientId = Config::get('instinctor_paypal_client_id');
        $secret = Config::get('instinctor_paypal_secret');
        $sandbox = Config::get('instinctor_paypal_sandbox', true);

        $baseUrl = $sandbox 
            ? 'https://api-m.sandbox.paypal.com' 
            : 'https://api-m.paypal.com';

        // Get access token
        $authResponse = wp_remote_post($baseUrl . '/v1/oauth2/token', [
            'headers' => [
                'Authorization' => 'Basic ' . base64_encode($clientId . ':' . $secret),
                'Content-Type' => 'application/x-www-form-urlencoded',
            ],
            'body' => 'grant_type=client_credentials',
        ]);

        $authBody = json_decode(wp_remote_retrieve_body($authResponse), true);
        if (!is_array($authBody)) { $authBody = []; } // WP.org B2
        $accessToken = $authBody['access_token'] ?? null;

        if (!$accessToken) {
            return new \WP_REST_Response(['error' => 'PayPal auth failed'], 500);
        }

        // Capture payment
        $captureResponse = wp_remote_post($baseUrl . '/v2/checkout/orders/' . $paypalOrderId . '/capture', [
            'headers' => [
                'Authorization' => 'Bearer ' . $accessToken,
                'Content-Type' => 'application/json',
            ],
            'body' => '{}',
        ]);

        $captureBody = json_decode(wp_remote_retrieve_body($captureResponse), true);
        if (!is_array($captureBody)) { $captureBody = []; } // WP.org B2

        if (($captureBody['status'] ?? '') !== 'COMPLETED') {
            return new \WP_REST_Response(['error' => 'Payment not completed'], 400);
        }

        // Create order in our system
        $cartService = new \Instinctor\Services\CartService();
        $checkoutService = new \Instinctor\Services\CheckoutService($cartService);

        $result = $checkoutService->processCheckout([
            'billing' => $billing,
            'payment_method' => 'paypal',
            'paypal_order_id' => $paypalOrderId,
            'skip_payment' => true, // Payment already captured
        ]);

        if (!$result['success']) {
            return new \WP_REST_Response(['error' => $result['error']], 400);
        }

        // Update order with PayPal transaction ID
        $captureId = $captureBody['purchase_units'][0]['payments']['captures'][0]['id'] ?? $paypalOrderId;
        DB::update(
            DB::prefix() . 'instinctor_orders',
            [
                'transaction_id' => $captureId,
                'payment_status' => 'paid',
                'status' => 'processing',
            ],
            ['id' => $result['order_id']]
        );

        return new \WP_REST_Response([
            'success' => true,
            'order_id' => $result['order_id'],
            'redirect' => $result['redirect'],
        ]);
    }

    // Permission callback for logged-in users
    public function isLoggedIn(): bool
    {
        return Auth::check();
    }

    /**
     * POST /api/account/password — change current user's password.
     * Body: { current_password, new_password, confirm_password }.
     * v2.1.1312.
     */
    public function changeAccountPassword(\WP_REST_Request $request): \WP_REST_Response
    {
        $userId  = Auth::userId();
        $current = (string) $request->get_param('current_password');
        $new     = (string) $request->get_param('new_password');
        $confirm = (string) $request->get_param('confirm_password');

        if ($new !== $confirm) {
            return new \WP_REST_Response([
                'success' => false,
                'error'   => __('New password and confirmation do not match.', 'instinctor'),
            ], 400);
        }

        $result = \Instinctor\Core\UserService::changePassword($userId, $current, $new);
        if (!$result['ok']) {
            return new \WP_REST_Response([
                'success' => false,
                'error'   => $result['error'] ?? __('Password change failed.', 'instinctor'),
            ], 400);
        }

        return new \WP_REST_Response(['success' => true], 200);
    }

    // Gift Card methods
    public function applyGiftCard(\WP_REST_Request $request): \WP_REST_Response
    {
        $code = strtoupper(sanitize_text_field($request->get_param('code')));

        if (empty($code)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Gift card code required'], 400);
        }

        // Validate gift card
        $result = \Instinctor\Services\GiftCardService::checkBalance($code);
        
        if (!$result['found']) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Invalid gift card code'], 400);
        }

        if ($result['balance'] <= 0) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Gift card has no remaining balance'], 400);
        }

        if ($result['expired']) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Gift card has expired'], 400);
        }

        // Apply to cart session
        $cartService = new \Instinctor\Services\CartService();
        $cart = $cartService->getCart();
        
        // Calculate how much of the gift card to use (can't exceed cart total)
        $amountToUse = min($result['balance'], $cart['totals']['total']);
        
        // Store gift card info in session
        CartSession::set('gift_card', [
            'id' => $result['id'],
            'code' => $code,
            'balance' => $result['balance'],
            'amount_to_use' => $amountToUse,
        ]);

        // Recalculate cart with gift card
        $cart['gift_card'] = ['code' => $code, 'id' => $result['id']];
        $cart['totals']['gift_card_amount'] = $amountToUse;
        $cart['totals']['total'] = max(0, $cart['totals']['total'] - $amountToUse);

        return new \WP_REST_Response([
            'success' => true,
            'cart' => $cart,
        ]);
    }

    public function removeGiftCard(\WP_REST_Request $request): \WP_REST_Response
    {
        CartSession::unset('gift_card');

        $cartService = new \Instinctor\Services\CartService();
        $cart = $cartService->getCart();
        
        // Remove gift card from cart
        unset($cart['gift_card']);
        $cart['totals']['gift_card_amount'] = 0;

        return new \WP_REST_Response([
            'success' => true,
            'cart' => $cart,
        ]);
    }

    // Loyalty Points methods
    public function applyLoyaltyPoints(\WP_REST_Request $request): \WP_REST_Response
    {
        $points = intval($request->get_param('points'));

        if ($points < 1) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Invalid points amount'], 400);
        }

        // Get customer ID
        $customerId = DB::get_var(DB::prepare(
            "SELECT id FROM {$p}instinctor_customers WHERE user_id = %d",
            Auth::userId()
        ));

        if (!$customerId) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Customer not found'], 400);
        }

        // Check customer has enough points
        $customerPoints = \Instinctor\Services\LoyaltyService::getCustomerPoints($customerId);
        
        if ($customerPoints->points < $points) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Insufficient points'], 400);
        }

        // Check minimum redemption
        $minRedeem = intval(Config::get('instinctor_loyalty_min_redemption', 100));
        if ($points < $minRedeem) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Minimum ' . $minRedeem . ' points required'], 400);
        }

        // Calculate discount value
        $pointsValue = floatval(Config::get('instinctor_loyalty_points_value', 0.01));
        $discount = $points * $pointsValue;

        // Get cart and check discount doesn't exceed total
        $cartService = new \Instinctor\Services\CartService();
        $cart = $cartService->getCart();
        
        $maxDiscount = $cart['totals']['total'] - ($cart['totals']['gift_card_amount'] ?? 0);
        $discount = min($discount, $maxDiscount);
        $actualPoints = ceil($discount / $pointsValue);

        // Store in session
        CartSession::set('loyalty', [
            'customer_id' => $customerId,
            'points' => $actualPoints,
            'discount' => $discount,
        ]);

        // Update cart
        $cart['loyalty_points'] = $actualPoints;
        $cart['totals']['loyalty_discount'] = $discount;
        $cart['totals']['total'] = max(0, $cart['totals']['total'] - $discount);

        return new \WP_REST_Response([
            'success' => true,
            'cart' => $cart,
            'remaining_points' => $customerPoints->points - $actualPoints,
        ]);
    }

    public function removeLoyaltyPoints(\WP_REST_Request $request): \WP_REST_Response
    {
        $loyaltyData = CartSession::get('loyalty');
        CartSession::unset('loyalty');

        $cartService = new \Instinctor\Services\CartService();
        $cart = $cartService->getCart();
        
        // Remove loyalty from cart
        unset($cart['loyalty_points']);
        $cart['totals']['loyalty_discount'] = 0;

        // Get remaining points
        $remainingPoints = 0;
        if ($loyaltyData) {
            $customerPoints = \Instinctor\Services\LoyaltyService::getCustomerPoints($loyaltyData['customer_id']);
            $remainingPoints = $customerPoints->points;
        }

        return new \WP_REST_Response([
            'success' => true,
            'cart' => $cart,
            'remaining_points' => $remainingPoints,
        ]);
    }

    /**
     * Customer login from checkout
     */
    public function customerLogin(\WP_REST_Request $request): \WP_REST_Response
    {
        $email = sanitize_email($request->get_param('email'));
        $password = $request->get_param('password');

        if (empty($email) || empty($password)) {
            return new \WP_REST_Response([
                'success' => false,
                'message' => __('Please provide email and password.', 'instinctor')
            ], 400);
        }

        // Option A (2026-07-05): authenticate against the plugin's OWN
        // instinctor_users + instinctor_auth session — no wp_signon / WP session.
        $result = \Instinctor\Core\UserService::loginCustomer((string) $email, (string) $password, true);
        if (empty($result['ok'])) {
            return new \WP_REST_Response([
                'success' => false,
                'message' => $result['error'] ?? __('Invalid email or password.', 'instinctor')
            ], 401);
        }

        return new \WP_REST_Response([
            'success' => true,
            'user_id' => (int) ($result['user_id'] ?? 0),
            'message' => __('Login successful.', 'instinctor')
        ]);
    }

    // Template Editor API Methods
    public function getTemplates(\WP_REST_Request $request): \WP_REST_Response
    {
        $type = $request->get_param('type') ?? '';
        $templates = \Instinctor\Services\TemplateService::getTemplates($type);
        return new \WP_REST_Response($templates);
    }

    public function getTemplate(\WP_REST_Request $request): \WP_REST_Response
    {
        $id = (int) $request->get_param('id');
        $template = \Instinctor\Services\TemplateService::getTemplate($id);
        
        if (!$template) {
            return new \WP_REST_Response(['error' => 'Template not found'], 404);
        }
        
        return new \WP_REST_Response($template);
    }

    public function createTemplate(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params();
        
        $id = \Instinctor\Services\TemplateService::saveTemplate([
            'name' => $data['name'] ?? 'Untitled',
            'type' => $data['type'] ?? 'page',
            'status' => $data['status'] ?? 'draft',
            'settings' => $data['settings'] ?? [],
        ]);
        
        $template = \Instinctor\Services\TemplateService::getTemplate($id);
        return new \WP_REST_Response($template, 201);
    }

    public function updateTemplate(\WP_REST_Request $request): \WP_REST_Response
    {
        $id = (int) $request->get_param('id');
        $data = $request->get_json_params();
        
        $template = \Instinctor\Services\TemplateService::getTemplate($id);
        if (!$template) {
            return new \WP_REST_Response(['error' => 'Template not found'], 404);
        }
        
        \Instinctor\Services\TemplateService::saveTemplate([
            'id' => $id,
            'name' => $data['name'] ?? $template->name,
            'slug' => $data['slug'] ?? $template->slug,
            'type' => $data['type'] ?? $template->type,
            'status' => $data['status'] ?? $template->status,
            'settings' => $data['settings'] ?? $template->settings,
        ]);
        
        $updated = \Instinctor\Services\TemplateService::getTemplate($id);
        return new \WP_REST_Response($updated);
    }

    public function deleteTemplate(\WP_REST_Request $request): \WP_REST_Response
    {
        $id = (int) $request->get_param('id');
        $result = \Instinctor\Services\TemplateService::deleteTemplate($id);
        
        if (!$result) {
            return new \WP_REST_Response(['error' => 'Failed to delete template'], 500);
        }
        
        return new \WP_REST_Response(['success' => true]);
    }

    public function getTemplateSections(\WP_REST_Request $request): \WP_REST_Response
    {
        $templateId = (int) $request->get_param('id');
        $sections = \Instinctor\Services\TemplateService::getTemplateSections($templateId);
        return new \WP_REST_Response($sections);
    }

    public function createTemplateSection(\WP_REST_Request $request): \WP_REST_Response
    {
        $templateId = (int) $request->get_param('id');
        $data = $request->get_json_params();
        
        $sectionId = \Instinctor\Services\TemplateService::saveSection([
            'template_id' => $templateId,
            'section_type' => $data['section_type'] ?? 'text_block',
            'settings' => $data['settings'] ?? [],
            'sort_order' => $data['sort_order'] ?? 0,
        ]);
        
        return new \WP_REST_Response(['id' => $sectionId], 201);
    }

    public function updateTemplateSection(\WP_REST_Request $request): \WP_REST_Response
    {
        $sectionId = (int) $request->get_param('section_id');
        $data = $request->get_json_params();
        
        \Instinctor\Services\TemplateService::saveSection([
            'id' => $sectionId,
            'section_type' => $data['section_type'] ?? null,
            'settings' => $data['settings'] ?? [],
            'sort_order' => $data['sort_order'] ?? null,
            'status' => $data['status'] ?? 'active',
        ]);
        
        return new \WP_REST_Response(['success' => true]);
    }

    public function deleteTemplateSection(\WP_REST_Request $request): \WP_REST_Response
    {
        $sectionId = (int) $request->get_param('section_id');
        $result = \Instinctor\Services\TemplateService::deleteSection($sectionId);
        
        return new \WP_REST_Response(['success' => $result]);
    }

    public function reorderTemplateSections(\WP_REST_Request $request): \WP_REST_Response
    {
        $templateId = (int) $request->get_param('id');
        $data = $request->get_json_params();
        $order = $data['order'] ?? [];
        
        \Instinctor\Services\TemplateService::reorderSections($templateId, $order);
        
        return new \WP_REST_Response(['success' => true]);
    }

    // Newsletter handlers
    public function newsletterSubscribe(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params();
        $email = $data['email'] ?? '';
        
        $result = \Instinctor\Services\NewsletterService::subscribe($email, [
            'first_name' => $data['first_name'] ?? '',
            'last_name' => $data['last_name'] ?? '',
            'source' => $data['source'] ?? 'website',
        ]);
        
        $status = $result['success'] ? 200 : 400;
        return new \WP_REST_Response($result, $status);
    }

    public function newsletterUnsubscribe(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params();
        $email = $data['email'] ?? '';
        
        $result = \Instinctor\Services\NewsletterService::unsubscribe($email);
        
        $status = $result['success'] ? 200 : 400;
        return new \WP_REST_Response($result, $status);
    }

    public function getNewsletterSubscribers(\WP_REST_Request $request): \WP_REST_Response
    {
        $subscribers = \Instinctor\Services\NewsletterService::getSubscribers([
            'status' => $request->get_param('status') ?? '',
            'search' => $request->get_param('search') ?? '',
            'orderby' => $request->get_param('orderby') ?? 'subscribed_at',
            'order' => $request->get_param('order') ?? 'DESC',
            'limit' => (int) ($request->get_param('limit') ?? 50),
            'offset' => (int) ($request->get_param('offset') ?? 0),
        ]);
        
        $total = \Instinctor\Services\NewsletterService::getCount($request->get_param('status') ?? '');
        
        return new \WP_REST_Response([
            'subscribers' => $subscribers,
            'total' => $total,
        ]);
    }

    public function deleteNewsletterSubscribers(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params();
        $ids = $data['ids'] ?? [];
        
        if (empty($ids)) {
            return new \WP_REST_Response(['error' => 'No IDs provided'], 400);
        }
        
        $deleted = \Instinctor\Services\NewsletterService::bulkDelete($ids);
        
        return new \WP_REST_Response(['deleted' => $deleted]);
    }

    public function getNewsletterStats(\WP_REST_Request $request): \WP_REST_Response
    {
        return new \WP_REST_Response(\Instinctor\Services\NewsletterService::getStats());
    }

    public function exportNewsletterSubscribers(\WP_REST_Request $request): \WP_REST_Response
    {
        $status = $request->get_param('status') ?? 'subscribed';
        $csv = \Instinctor\Services\NewsletterService::exportCSV($status);
        
        return new \WP_REST_Response([
            'csv' => $csv,
            // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
            'filename' => 'newsletter-subscribers-' . date('Y-m-d') . '.csv',
            // phpcs:enable WordPress.DateTime.RestrictedFunctions
        ]);
    }

    public function getNewsletterSettings(\WP_REST_Request $request): \WP_REST_Response
    {
        return new \WP_REST_Response(\Instinctor\Services\NewsletterService::getSettings());
    }

    public function saveNewsletterSettings(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params();
        $result = \Instinctor\Services\NewsletterService::saveSettings($data);
        
        return new \WP_REST_Response(['success' => $result]);
    }

    /**
     * ============================================================
     * IMPULSE PAGE BUILDER API
     * ============================================================
     * 
     * WordPress Pages integration for Impulse visual page builder.
     * Uses regular WordPress 'page' post type with Impulse meta.
     * 
     * DATA MODEL:
     * - Pages: Regular 'page' post type with '_impulse_enabled' meta = '1'
     * - Elements: Post meta '_impulse_elements' (array of element objects)
     * - Settings: Post meta '_impulse_settings' (page-level settings)
     * 
     * ELEMENT STRUCTURE:
     * {
     *   id: int,              // Auto-generated unique ID within page
     *   parent_id: int|null,  // Parent container ID or null for root
     *   section_type: string, // Element type (heading, text, etc.)
     *   settings: object,     // Element-specific settings
     *   sort_order: int       // Display order within parent
     * }
     * 
     * ENDPOINTS:
     * - GET    /pages                    - List all pages
     * - POST   /pages                    - Create page
     * - GET    /pages/{id}               - Get single page
     * - PUT    /pages/{id}               - Update page name/settings
     * - DELETE /pages/{id}               - Move page to trash
     * - GET    /pages/{id}/elements      - Get element tree
     * - POST   /pages/{id}/elements      - Create element
     * - POST   /pages/{id}/section-template - Create from template
     * - GET    /elements/{id}            - Get single element
     * - PUT    /elements/{id}            - Update element settings
     * - DELETE /elements/{id}            - Delete element (and children)
     * - POST   /elements/{id}/duplicate  - Duplicate with children
     * - POST   /elements/{id}/reorder    - Move before/after sibling
     * - POST   /elements/{id}/move-into  - Move into container
     * 
     * IMPORTANT NOTES:
     * - Elements use auto-incrementing IDs based on max existing ID + 1
     * - sort_order of -1 means "append to end" (API calculates max + 1)
     * - Deleting a container deletes all child elements
     * - Duplicating a container duplicates all children with new IDs
     * - Impulse pages use regular 'page' post type with '_impulse_enabled' meta
     * ============================================================
     */
    public function getImpulsePages(\WP_REST_Request $request): \WP_REST_Response
    {
        // Blog posts (type='post') are managed in the blog, not the page list —
        // keep them out so the page grid doesn't fill up as posts accumulate.
        $rows = array_values(array_filter(
            \Instinctor\Services\PageService::getAll(),
            static fn($r) => (($r['type'] ?? 'page') !== 'post')
        ));

        $pages = array_map(function ($row) {
            $s = is_array($row['settings']) ? $row['settings']
               : (is_string($row['settings']) ? (json_decode($row['settings'], true) ?: []) : []);
            return [
                'id'               => $row['id'],
                'name'             => $row['name'],
                'slug'             => $row['slug'],
                'status'           => $row['status'],
                'screen_infinite_loop'  => !empty($s['screen_infinite_loop']),
                'thumbnail'        => (string) ($row['thumbnail'] ?? ''),
                'created_at'         => $row['created_at'],
                'updated_at'       => $row['updated_at'],
                'permalink'        => \Instinctor\Services\PageService::getPageUrl((int) $row['id']),
            ];
        }, $rows);

        return new \WP_REST_Response($pages);
    }

    public function createImpulsePage(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params();

        // Merge any settings passed by the caller (e.g. screen_mode for Screen Sequence)
        $incomingSettings = isset($data['settings']) && is_array($data['settings']) ? $data['settings'] : [];
        $pageSettings = array_merge(\Instinctor\Services\PageService::defaultSettings(), $incomingSettings);


        // Scene default: Loop ON. Sergei's preference — most scenes are
        // intended to cycle, and unchecked-by-default left users hunting for
        // it. Caller can still pass `screen_infinite_loop => false` to opt out.
        if (!array_key_exists('screen_infinite_loop', $incomingSettings)) {
            $pageSettings['screen_infinite_loop'] = true;
        }

        // Atomically insert the first screen so the page is never left in a
        // zero-element state regardless of JS timing. Seed exactly the
        // structure createImpulseElement would write, including page_id and
        // the section defaults from getElementDefaults.
        $_secDefaults = ['bg_color' => 'transparent', 'padding_top' => 20, 'padding_bottom' => 20, 'padding_left' => 20, 'padding_right' => 20];
        $initialElements = [[
            'id'         => 1,
            'page_id'    => 0, // placeholder — overwritten below after $id is known
            'type'       => 'section',
            'parent_id'  => null,
            'sort_order' => 0,
            'settings'   => $_secDefaults,
        ]];

        $id = \Instinctor\Services\PageService::create([
            'name'     => $data['name'] ?? 'Untitled Page',
            'status'   => 'draft',
            'type'     => 'page',
            'settings' => $pageSettings,
            'elements' => $initialElements,
        ]);

        if (!$id) {
            return new \WP_REST_Response(['error' => 'Failed to create page'], 500);
        }

        // Backfill the page_id placeholder in all seeded elements now that $id is known
        foreach ($initialElements as &$_el) { $_el['page_id'] = $id; }
        unset($_el);
        \Instinctor\Services\PageService::saveElements($id, $initialElements);

        // Auto-name: replace placeholder with proper ID-based title + slug
        $rawName = $data['name'] ?? '';
        if ($rawName === '__auto__') {
            // "Page", not "Scene" — scene/screen_mode terminology was retired in
            // 2.1.1586; the only page type now is a Page. New pages get name
            // "Page NNNN" + slug "page-NNNN". (Existing pages keep their permanent
            // slug.) 2026-06-27.
            $autoName = 'Page ' . str_pad((string) $id, 4, '0', STR_PAD_LEFT);
            $autoSlug = sanitize_title($autoName);
            \Instinctor\Services\PageService::update($id, ['name' => $autoName, 'slug' => $autoSlug]);
        }

        return new \WP_REST_Response([
            'id'        => $id,
            'permalink' => \Instinctor\Services\PageService::getPageUrl($id),
        ], 201);
    }

    public function getImpulsePage(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!\Instinctor\Services\PageService::currentUserCanEdit((int) $request->get_param('id'))) {
            return new \WP_REST_Response(['error' => 'forbidden'], 403);
        }
        $id   = (int) $request->get_param('id');
        $page = \Instinctor\Services\PageService::getById($id);

        if (!$page) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        $settings = $page['settings'];

        $chatHidden = false;
        if (is_array($settings)) {
            $cv = $settings['chat_visible'] ?? null;
            // Default-off: chat is hidden unless the page explicitly enables it.
            if ($cv !== 1 && $cv !== '1' && $cv !== true) {
                $chatHidden = true;
            }
        }

        return new \WP_REST_Response([
            'id'          => $page['id'],
            'name'        => $page['name'],
            'slug'        => $page['slug'],
            'status'      => $page['status'],
            'settings'    => is_array($settings) ? wp_json_encode($settings) : ($settings ?: '{}'),
            'created_at'  => $page['created_at'],
            'updated_at'  => $page['updated_at'],
            'permalink'   => \Instinctor\Services\PageService::getPageUrl($id),
            'chat_hidden' => $chatHidden,
        ]);
    }

    /**
     * POST /pages/{id}/settings — merge-patch individual page settings keys.
     * Used by the page grid context menu to update e.g. next_sequence_id without
     * touching the full settings object.
     */
    public function patchImpulsePageSettings(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!\Instinctor\Services\PageService::currentUserCanEdit((int) $request->get_param('id'))) {
            return new \WP_REST_Response(['error' => 'forbidden'], 403);
        }
        $id   = (int) $request->get_param('id');
        $data = $request->get_json_params() ?: [];

        if (!\Instinctor\Services\PageService::exists($id)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        // Load existing settings and merge only the provided keys
        $existing = \Instinctor\Services\PageService::getSettings($id);
        // Defensive: protect page-level env keys from being clobbered by an
        // empty-string overwrite. When a non-page-settings panel (block edit,
        // screen-bar interaction, etc.) triggers an autosave, the panel's
        // collectSettingsFromPanel can include these keys with empty values
        // — array_merge would then drop the env. Treat '' as "absent" for
        // these specific keys so the DB value survives. Sergei 2026-06-04.
        $envGuardKeys = [
            'screen_cloud_effect',
            'screen_lava_color1','screen_lava_color2','screen_lava_color3','screen_lava_color4',
            'screen_lava_bg_color','screen_cosmos_color1','screen_cosmos_color2',
            'screen_cosmos_color3','screen_cosmos_color4','screen_cosmos_bg_color',
            'screen_3d_color1','screen_3d_color2','screen_3d_color3','screen_3d_color4',
            'screen_3d_bg_color','screen_wave_color1','screen_wave_color2',
            'screen_wave_color3','screen_wave_color4','screen_depth_image','screen_depth_map',
        ];
        foreach ($envGuardKeys as $_egk) {
            if (array_key_exists($_egk, $data) && $data[$_egk] === '' && !empty($existing[$_egk])) {
                unset($data[$_egk]);
            }
        }
        $merged   = array_merge($existing, $data);
        \Instinctor\Services\PageService::saveSettings($id, $merged);

        // Side-effect on page→scene conversion: clear bg_color on top-level
        // sections. In scene mode each top-level section becomes a screen, and
        // screens are transparent by default so the scene-level effect (cloud,
        // depth image, 3D environment) shows through. Per-section bg colors
        // carried over from page mode block that effect and turn each
        // transition into a hard color flash. Only fires when screen_mode is
        // actually flipping ON (was off, becoming on).
        // (Removed: the page->scene bg-clear migration. Every page is a screen
        // sequence, so the off->on transition it guarded can never occur.)

        return new \WP_REST_Response(['success' => true]);
    }

    public function updateImpulsePage(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!\Instinctor\Services\PageService::currentUserCanEdit((int) $request->get_param('id'))) {
            return new \WP_REST_Response(['error' => 'forbidden'], 403);
        }
        $id   = (int) $request->get_param('id');
        $data = $request->get_json_params();

        if (!\Instinctor\Services\PageService::exists($id)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        // Update page fields (name, slug, status)
        $fields = [];
        if (isset($data['name'])) {
            $fields['name'] = sanitize_text_field($data['name']);
        }
        if (isset($data['slug'])) {
            $fields['slug'] = $data['slug']; // PageService::update() sanitizes via sanitize_title()
        }
        if (isset($data['status'])) {
            $fields['status'] = $data['status'] === 'published' ? 'published' : 'draft';
        }
        if (isset($data['role'])) {
            $fields['role'] = sanitize_text_field($data['role']); // whitelisted in PageService::update
        }
        if (!empty($fields)) {
            \Instinctor\Services\PageService::update($id, $fields);
        }

        // Update settings — merge with existing rather than overwrite.
        // The client's _doSavePageSettings sends the FULL settings object
        // it has in memory; in theory that includes every key. In practice
        // any panel can produce an object missing keys it doesn't surface
        // (the Page Settings panel doesn't show hide_chrome_on_first_screens,
        // hero_sandbox, hero_screen_count, etc.) and a plain overwrite
        // would silently delete those orphans, which then resurfaces as
        // "the sandbox disappeared", "header isn't hidden anymore", etc.
        // until the values are restored by hand. Mirror patchImpulsePageSettings's
        // merge contract so this endpoint behaves the same way for callers
        // that submit partial settings. Locked 2026-05-27.
        if (isset($data['settings'])) {
            $_existingSettings = \Instinctor\Services\PageService::getSettings($id);
            $_incomingSettings = (array) $data['settings'];
            // Env-leak guard — same as patchImpulsePageSettings above. Empty
            // values for these keys are treated as "absent" so a block-level
            // autosave can't accidentally wipe the page env.
            $_envGuardKeys = [
                'screen_cloud_effect',
                'screen_lava_color1','screen_lava_color2','screen_lava_color3','screen_lava_color4',
                'screen_lava_bg_color','screen_cosmos_color1','screen_cosmos_color2',
                'screen_cosmos_color3','screen_cosmos_color4','screen_cosmos_bg_color',
                'screen_3d_color1','screen_3d_color2','screen_3d_color3','screen_3d_color4',
                'screen_3d_bg_color','screen_wave_color1','screen_wave_color2',
                'screen_wave_color3','screen_wave_color4','screen_depth_image','screen_depth_map',
            ];
            foreach ($_envGuardKeys as $_egk) {
                if (array_key_exists($_egk, $_incomingSettings)
                    && $_incomingSettings[$_egk] === ''
                    && !empty($_existingSettings[$_egk])) {
                    unset($_incomingSettings[$_egk]);
                }
            }
            $_mergedSettings   = array_merge($_existingSettings, $_incomingSettings);
            \Instinctor\Services\PageService::saveSettings($id, $_mergedSettings);
            // Re-bind for the checkout_design block below so it sees the merged shape.
            $data['settings'] = $_mergedSettings;

            // Sync checkout_design settings to CheckoutConfig
            if (isset($data['settings']['checkout_design'])) {
                $cd     = $data['settings']['checkout_design'];
                $config = \Instinctor\Services\CheckoutConfig::get();

                if (isset($cd['guest_checkout']))      $config['guest_checkout']       = (bool) $cd['guest_checkout'];
                if (isset($cd['create_account']))      $config['create_account_option'] = (bool) $cd['create_account'];
                if (isset($cd['email_first']))         $config['email_first']           = (bool) $cd['email_first'];
                if (isset($cd['show_coupon']))         $config['show_coupon']            = (bool) $cd['show_coupon'];
                if (isset($cd['show_gift_card']))      $config['show_gift_card']         = (bool) $cd['show_gift_card'];
                if (isset($cd['colors']))              $config['colors']                 = $cd['colors'];
                if (isset($cd['field_border_width']))  $config['field_border_width']     = (int) $cd['field_border_width'];
                if (isset($cd['field_radius']))        $config['field_radius']           = (int) $cd['field_radius'];
                if (isset($cd['button_radius']))       $config['button_radius']          = (int) $cd['button_radius'];
                if (isset($cd['fields']) && is_array($cd['fields'])) {
                    $fieldsAssoc = [];
                    foreach ($cd['fields'] as $idx => $field) {
                        $fieldId = $field['id'] ?? ('custom_' . $idx);
                        $fieldsAssoc[$fieldId] = $field;
                    }
                    $config['fields'] = $fieldsAssoc;
                }

                \Instinctor\Services\CheckoutConfig::save($config);
            }
        }

        $page = \Instinctor\Services\PageService::getById($id);
        return new \WP_REST_Response([
            'success'   => true,
            'slug'      => $page ? $page['slug'] : '',
            'permalink' => \Instinctor\Services\PageService::getPageUrl($id),
        ]);
    }

    public function deleteImpulsePage(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!\Instinctor\Services\PageService::currentUserCanEdit((int) $request->get_param('id'))) {
            return new \WP_REST_Response(['error' => 'forbidden'], 403);
        }
        $id = (int) $request->get_param('id');

        if (!\Instinctor\Services\PageService::exists($id)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        \Instinctor\Services\PageService::trash($id);

        return new \WP_REST_Response(['success' => true]);
    }

    public function restoreImpulsePage(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!\Instinctor\Services\PageService::currentUserCanEdit((int) $request->get_param('id'))) {
            return new \WP_REST_Response(['error' => 'forbidden'], 403);
        }
        $id = (int) $request->get_param('id');

        if (!\Instinctor\Services\PageService::exists($id)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        \Instinctor\Services\PageService::restore($id);

        return new \WP_REST_Response(['success' => true]);
    }

    public function permanentDeleteImpulsePage(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!\Instinctor\Services\PageService::currentUserCanEdit((int) $request->get_param('id'))) {
            return new \WP_REST_Response(['error' => 'forbidden'], 403);
        }
        $id = (int) $request->get_param('id');

        if (!\Instinctor\Services\PageService::exists($id)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        \Instinctor\Services\PageService::permanentDelete($id);

        return new \WP_REST_Response(['success' => true]);
    }

    /**
     * List published pages whose `thumbnail` column is empty.
     *
     * Drives the admin's batch-capture sequencer — it walks the returned
     * IDs, opens each in the iframe, fires the existing capture pipeline,
     * and moves on. Re-runnable: only ever returns the still-empty set,
     * so a partial batch can resume cleanly.
     *
     * Designer-owned pages are the typical target (their demo pages start
     * empty until populated), but the endpoint isn't designer-scoped so
     * any orphan published page without a thumbnail is included.
     */
    public function listEmptyThumbnailPages(\WP_REST_Request $request): \WP_REST_Response
    {
        $t    = DB::prefix() . 'instinctor_pages';
        $rows = DB::get_results(
            "SELECT id, name, slug FROM {$t}
              WHERE status = 'published'
                AND (thumbnail = '' OR thumbnail IS NULL)
              ORDER BY id ASC",
            'ARRAY_A'
        );
        $list = [];
        if (is_array($rows)) {
            foreach ($rows as $r) {
                $list[] = [
                    'id'   => (int) ($r['id'] ?? 0),
                    'name' => (string) ($r['name'] ?? ''),
                    'slug' => (string) ($r['slug'] ?? ''),
                ];
            }
        }
        return new \WP_REST_Response(['pages' => $list, 'count' => count($list)]);
    }

    public function saveImpulsePageThumbnail(\WP_REST_Request $request): \WP_REST_Response
    {
        $id        = (int) $request->get_param('id');
        $data      = $request->get_json_params();
        $thumbnail = $data['thumbnail'] ?? '';
        // Optional device tag — desktop (default) / tablet / mobile. Drives
        // which column the URL is stored under and which file suffix is
        // used so the three captures don't overwrite each other on disk.
        $deviceRaw = strtolower((string)($data['device'] ?? 'desktop'));
        $device    = in_array($deviceRaw, ['desktop', 'tablet', 'mobile'], true) ? $deviceRaw : 'desktop';

        if (!$thumbnail || strpos($thumbnail, 'data:image/') !== 0) {
            return new \WP_REST_Response(['error' => 'Invalid thumbnail data'], 400);
        }

        if (!\Instinctor\Services\PageService::exists($id)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        $parts = explode(',', $thumbnail, 2);
        if (count($parts) !== 2) {
            return new \WP_REST_Response(['error' => 'Invalid data URL'], 400);
        }
        $binary = base64_decode($parts[1]);
        if (!$binary || strlen($binary) < 100) {
            return new \WP_REST_Response(['error' => 'Invalid image data, capture appears blank'], 400);
        }

        // File suffix per device. Desktop keeps the legacy filename so any
        // existing references stay valid; tablet/mobile get -tablet / -mobile.
        $suffix  = $device === 'desktop' ? '' : ('-' . $device);
        $rel     = 'impulse-previews/page-' . $id . $suffix . '.jpg';
        $written = Storage::put($rel, $binary);

        if (!$written) {
            return new \WP_REST_Response(['error' => 'Failed to save preview file'], 500);
        }

        $url = Storage::url($rel) . '?v=' . time();
        \Instinctor\Services\PageService::saveThumbnail($id, $url, $device);

        return new \WP_REST_Response(['success' => true, 'url' => $url, 'device' => $device]);
    }

    public function reorderImpulsePages(\WP_REST_Request $request): \WP_REST_Response
    {
        $data  = $request->get_json_params();
        $order = $data['order'] ?? [];

        if (!is_array($order) || empty($order)) {
            return new \WP_REST_Response(['error' => 'Invalid order data'], 400);
        }

        \Instinctor\Services\PageService::reorder($order);

        return new \WP_REST_Response(['success' => true]);
    }

    public function duplicateImpulsePage(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!\Instinctor\Services\PageService::currentUserCanEdit((int) $request->get_param('id'))) {
            return new \WP_REST_Response(['error' => 'forbidden'], 403);
        }
        $id = (int) $request->get_param('id');

        if (!\Instinctor\Services\PageService::exists($id)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        $newId = \Instinctor\Services\PageService::duplicate($id);

        if (!$newId) {
            return new \WP_REST_Response(['error' => 'Failed to duplicate page'], 500);
        }

        return new \WP_REST_Response([
            'success'   => true,
            'id'        => $newId,
            'permalink' => \Instinctor\Services\PageService::getPageUrl($newId),
        ]);
    }

    /**
     * GET /pages/{id}/export
     * Export a page to portable JSON format for import on another site.
     */
    public function exportImpulsePage(\WP_REST_Request $request): \WP_REST_Response
    {
        $id = (int) $request->get_param('id');

        $data = \Instinctor\Services\PageService::exportPage($id);
        if (!$data) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        return new \WP_REST_Response($data);
    }

    /**
     * POST /pages/import
     * Import a page from exported JSON.
     * Body: the full export object (with instinctor_export, page, elements keys)
     * Optional query/body param: page_id — import into this existing page (replace elements/settings).
     * If page_id is omitted or 0, creates a new page.
     */
    public function importImpulsePage(\WP_REST_Request $request): \WP_REST_Response
    {
        $body = $request->get_json_params();

        if (empty($body) || (empty($body['elements']) && empty($body['instinctor_export']))) {
            return new \WP_REST_Response(['error' => 'Invalid import data. Expected export JSON with elements array.'], 400);
        }

        $targetId = (int) ($request->get_param('page_id') ?: 0);

        if ($targetId && \Instinctor\Services\PageService::exists($targetId)) {
            // Import into existing page — replace elements and settings
            $ok = \Instinctor\Services\PageService::importInto($targetId, $body);
            if (!$ok) {
                return new \WP_REST_Response(['error' => 'Import failed'], 500);
            }
            return new \WP_REST_Response([
                'success'   => true,
                'id'        => $targetId,
                'permalink' => \Instinctor\Services\PageService::getPageUrl($targetId),
            ]);
        }

        // Create new page
        $name  = $request->get_param('name') ?: null;
        $newId = \Instinctor\Services\PageService::importPage($body, $name);

        if (!$newId) {
            return new \WP_REST_Response(['error' => 'Import failed, could not create page'], 500);
        }

        return new \WP_REST_Response([
            'success'   => true,
            'id'        => $newId,
            'permalink' => \Instinctor\Services\PageService::getPageUrl($newId),
        ]);
    }

    // ── Full-site export ─────────────────────────────────────────────────────

    /**
     * GET /site/export
     * Returns full site as JSON — all pages, elements, config.
     */
    public function exportSite(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = \Instinctor\Services\PageService::exportSite();
        return new \WP_REST_Response($data, 200);
    }

    /**
     * POST /site/import
     * Accepts a full site export JSON, imports all pages and config.
     * Used by the platform deploy job after installing Instinctor on client hosting.
     *
     * Optional header: X-Deploy-Token — validated against INSTINCTOR_DEPLOY_TOKEN env var
     * if set (allows unauthenticated deploy from platform job).
     */
    public function importSite(\WP_REST_Request $request): \WP_REST_Response
    {
        $body = $request->get_json_params();
        if (empty($body['instinctor_site_export'])) {
            return new \WP_REST_Response(['error' => 'Invalid site export format'], 400);
        }

        $result = \Instinctor\Services\PageService::importSite($body);

        return new \WP_REST_Response([
            'success' => empty($result['errors']),
            'pages'   => $result['pages'],
            'errors'  => $result['errors'],
        ], empty($result['errors']) ? 200 : 207);
    }

    /**
     * @deprecated No longer needed — elements stored in instinctor_pages table.
     */
    private function ensureImpulseSchema(): void {}

    /**
     * GET /pages/{id}/complexity
     *
     * Returns the heuristic complexity score for the editor VU meter.
     * Walks the element tree via PageComplexity::compute() and returns
     * { score, level, contributors }. The editor calls this after every
     * save so the bar stays in sync without a full reload.
     */
    public function getPageComplexity(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId = (int) $request->get_param('id');
        if (!\Instinctor\Services\PageService::exists($pageId)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }
        $els = \Instinctor\Services\PageService::getElements($pageId);
        $ps  = \Instinctor\Services\PageService::getSettings($pageId);
        $cx  = \Instinctor\Core\PageComplexity::compute($els, $ps);
        return new \WP_REST_Response($cx, 200);
    }

    public function getImpulseElements(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId = (int) $request->get_param('id');

        if (!\Instinctor\Services\PageService::exists($pageId)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        $elements = \Instinctor\Services\PageService::getElements($pageId);

        usort($elements, function ($a, $b) {
            return ($a['sort_order'] ?? 0) - ($b['sort_order'] ?? 0);
        });

        $result = array_map(function ($el) {
            return (object) [
                'id'           => $el['id'],
                'template_id'  => $el['page_id'] ?? $el['template_id'] ?? 0,
                'parent_id'    => $el['parent_id'] ?? null,
                'section_type' => $el['type'] ?? $el['section_type'] ?? 'text',
                'settings'     => $el['settings'] ?? [],
                'sort_order'   => $el['sort_order'] ?? 0,
                'status'       => 'active',
            ];
        }, $elements);

        return new \WP_REST_Response(['elements' => $result]);
    }

    public function createImpulseElement(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId = (int) $request->get_param('id');
        $data   = $request->get_json_params();

        if (!\Instinctor\Services\PageService::exists($pageId)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        \Instinctor\Services\PageService::ensurePageRow($pageId);

        $elementType    = sanitize_text_field($data['element_type'] ?? 'text');
        $parentId       = !empty($data['parent_id']) ? intval($data['parent_id']) : null;
        $sortOrder      = isset($data['sort_order']) ? intval($data['sort_order']) : -1;
        $siblingId      = !empty($data['sibling_id']) ? intval($data['sibling_id']) : null;
        $insertPosition = !empty($data['insert_position']) ? $data['insert_position'] : null;

        $elements = \Instinctor\Services\PageService::getElements($pageId);

        // Generate unique ID (max existing + 1)
        $maxId = 0;
        foreach ($elements as $el) {
            if (isset($el['id']) && $el['id'] > $maxId) {
                $maxId = $el['id'];
            }
        }
        $newId = $maxId + 1;

        // Sibling-based insertion
        if ($siblingId && $insertPosition) {
            $siblingEl = null;
            foreach ($elements as $el) {
                if (isset($el['id']) && $el['id'] === $siblingId) {
                    $siblingEl = $el;
                    break;
                }
            }
            if ($siblingEl) {
                $parentId    = $siblingEl['parent_id'] ?? null;
                $siblingSort = (int) ($siblingEl['sort_order'] ?? 0);
                // GRID PARENT: sort_order is the CELL INDEX. A sibling insert
                // means "next to this block", i.e. the SAME cell - siblingSort
                // exactly, no shifting. The list-style +1/shift below pushed
                // the new block into the NEXT cell while injectNewElement drew
                // it beside the sibling, so it looked right until the first
                // re-render, then "flew" one cell over. Same family as the
                // grid-safe duplicate/reorder fixes. 2026-07-20.
                $siblingParentIsGrid = false;
                if (!empty($parentId)) {
                    foreach ($elements as $el) {
                        if ((int) ($el['id'] ?? 0) === (int) $parentId) {
                            $siblingParentIsGrid = (($el['type'] ?? '') === 'grid');
                            break;
                        }
                    }
                }
                if ($siblingParentIsGrid) {
                    $sortOrder = $siblingSort;
                } elseif ($insertPosition === 'before') {
                    $sortOrder = $siblingSort;
                    foreach ($elements as &$el) {
                        if (($el['parent_id'] ?? null) === $parentId && ($el['sort_order'] ?? 0) >= $siblingSort) {
                            $el['sort_order'] = ($el['sort_order'] ?? 0) + 1;
                        }
                    }
                    unset($el);
                } else {
                    $sortOrder = $siblingSort + 1;
                    foreach ($elements as &$el) {
                        if (($el['parent_id'] ?? null) === $parentId && ($el['sort_order'] ?? 0) > $siblingSort) {
                            $el['sort_order'] = ($el['sort_order'] ?? 0) + 1;
                        }
                    }
                    unset($el);
                }
            }
        } elseif ($sortOrder < 0) {
            $maxSort = -1;
            foreach ($elements as $el) {
                if (($el['parent_id'] ?? null) === $parentId && ($el['sort_order'] ?? 0) > $maxSort) {
                    $maxSort = $el['sort_order'] ?? 0;
                }
            }
            $sortOrder = $maxSort + 1;
        }

        $defaults   = $this->getElementDefaults($elementType);
        $settings   = array_merge($defaults, $data['settings'] ?? []);

        // TOP-LEVEL GUARD (Scene mode only): in a Scene-mode page every
        // root element must be a section (a screen), so a non-section block
        // dropped at the root is redirected into the first screen — a loose
        // root block would have no screen to belong to. Page mode imposes
        // NO such rule: loose blocks at the page root are legal there, so
        // the block is created exactly where it was dropped, unwrapped.
        $pageSettings    = \Instinctor\Services\PageService::getSettings($pageId);
        $topLevelAllowed = \Instinctor\Services\PageService::TOP_LEVEL_ALLOWED_TYPES;
        if (($parentId === null || $parentId === 0)
            && !in_array($elementType, $topLevelAllowed, true)) {
            // Attach to the first top-level section (the first screen).
            $first = null;
            $firstSort = PHP_INT_MAX;
            foreach ($elements as $_el) {
                if (($_el['type'] ?? '') === 'section' && empty($_el['parent_id'])) {
                    $so = (int) ($_el['sort_order'] ?? 0);
                    if ($so < $firstSort) { $firstSort = $so; $first = $_el['id'] ?? null; }
                }
            }
            if ($first) {
                $parentId = $first;
            } else {
                return new \WP_REST_Response(['error' => 'Screen mode: cannot add non-section block at root level'], 400);
            }
        }

        // A ROOT section IS a screen. But nested (compact) sections are a
        // supported feature — a section wrapped inside another section renders
        // as a "card" within its host, and a section can also live in a grid /
        // rows / columns cell (Wrap in Section, or drag-into-container). The
        // screen engine only treats ROOT sections (parent_id === null) as
        // screens, so a nested one renders as ordinary content, not a screen.
        // We therefore ONLY force a section back to root when its target parent
        // is NOT a real container — a section can't live inside a text / button
        // / image / etc. (previously this force-promoted EVERY parented section
        // to root, which broke Wrap-in-Section: the new container landed as a
        // sibling screen AFTER the host instead of nesting inside it).
        $sectionParentType = '';
        if ($elementType === 'section' && $parentId) {
            foreach ($elements as $el) {
                if ((int) ($el['id'] ?? 0) === (int) $parentId) { $sectionParentType = (string) ($el['type'] ?? ''); break; }
            }
        }
        $sectionContainerTypes = ['section', 'grid', 'rows', 'columns', 'slide', 'slider', 'modal'];
        if ($elementType === 'section' && $parentId && !in_array($sectionParentType, $sectionContainerTypes, true)) {
            $rootSort = null; $cur = $parentId; $hops = 0;
            while ($cur && $hops++ < 50) {
                $f = null;
                foreach ($elements as $el) { if ((int) ($el['id'] ?? 0) === (int) $cur) { $f = $el; break; } }
                if (!$f) break;
                if (empty($f['parent_id'])) { $rootSort = (int) ($f['sort_order'] ?? 0); break; }
                $cur = $f['parent_id'];
            }
            $parentId = null;
            if ($rootSort !== null) {
                $sortOrder = $rootSort + 1;
                foreach ($elements as &$el) {
                    if (empty($el['parent_id']) && (int) ($el['sort_order'] ?? 0) > $rootSort) {
                        $el['sort_order'] = (int) ($el['sort_order'] ?? 0) + 1;
                    }
                }
                unset($el);
            } else {
                $maxSort = -1;
                foreach ($elements as $el) { if (empty($el['parent_id']) && (int) ($el['sort_order'] ?? 0) > $maxSort) $maxSort = (int) ($el['sort_order'] ?? 0); }
                $sortOrder = $maxSort + 1;
            }
        }

        $newElement = [
            'id'         => $newId,
            'page_id'    => $pageId,
            'parent_id'  => $parentId,
            'type'       => $elementType,
            'settings'   => $settings,
            'sort_order' => $sortOrder,
        ];

        $elements[] = $newElement;

        \Instinctor\Services\PageService::saveElements($pageId, $elements);

        return new \WP_REST_Response([
            'id'         => $newId,
            'sort_order' => $sortOrder,
            'settings'   => $settings,
            'parent_id'  => $parentId,
            'type'       => $elementType,
        ], 201);
    }

    private function getElementDefaults(string $type): array
    {
        // NEW layout cells ship with 10px padding so content has breathing room
        // out of the box (Sergei 2026-06-29). This seeds creation only — existing
        // grids/rows/columns keep whatever padding they already have (no retro shift).
        $cp = ['padding_sides' => 10, 'padding_top' => 10, 'padding_bottom' => 10];
        $defaults = [
            // ── Layout ───────────────────────────────────────────────────────
            // screen_min_height_vh 40: new sections open as a workable 40vh
            // canvas instead of a hug-content sliver, growing with content
            // (Sergei 2026-07-19). Root-section-only by construction - the
            // renderer/template only honor the key on root screens, nested
            // sections ignore it. Creation seed only; existing pages keep
            // their saved heights, and the panel slider shows the 40 so it
            // can be dialed anywhere including 0.
            'section'      => ['bg_color' => 'transparent', 'padding_top' => 20, 'padding_bottom' => 20, 'padding_left' => 20, 'padding_right' => 20, 'content_align' => 'center', 'valign' => 'center', 'screen_min_height_vh' => 40],
            'grid'         => ['columns' => 2, 'rows' => 2, 'gap_horizontal' => 20, 'gap_vertical' => 20, 'bg_color' => 'transparent', 'max_width' => 1200, 'cell_padding_h' => 10, 'cell_padding_v' => 10],
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            'rows'    => ['rows' => json_encode([['widths' => [33.33, 33.33, 33.34], 'cells' => [$cp, $cp, $cp]], ['widths' => [50, 50], 'cells' => [$cp, $cp]]]), 'row_gap' => 16, 'cell_gap' => 16, 'row_height' => 200, 'max_width' => 1200],
            // phpcs:enable WordPress.WP.AlternativeFunctions
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            'columns'  => ['columns' => json_encode([['width' => 50, 'cells' => [$cp]], ['width' => 50, 'cells' => [$cp, $cp, $cp]]]), 'col_gap' => 16, 'cell_gap' => 16, 'min_height' => 200, 'max_width' => 1200],
            // phpcs:enable WordPress.WP.AlternativeFunctions
            'slider'   => ['autoplay' => true, 'autoplay_speed' => 5, 'direction' => 'right', 'transition_speed' => 400],
            'slide'        => ['bg_color' => '#f5f5f5', 'min_height' => 400, 'padding_top' => 60, 'padding_bottom' => 60, 'padding_left' => 40, 'padding_right' => 40],

            // ── Basic ─────────────────────────────────────────────────────────
            'title' => ['tag' => 'h2', 'align' => 'center', 'line_height' => 120, 'letter_spacing' => 0],
            'text'         => ['content' => '', 'align' => 'left', 'color' => '#666666', 'size' => 16],
            // image_popup defaults merged into 'image' as the lightbox_*
            // settings (2.1.420). When the lightbox_enabled toggle is on,
            // image.php renders the thumb+modal lightbox path. Defaults
            // for the lightbox-specific fields are kept here so a freshly-
            // toggled Image picks up sensible values.
            'image'        => ['src' => '', 'alt' => '', 'width' => 50, 'border_radius' => 0, 'lightbox_enabled' => false, 'thumb_shape' => 'circle', 'thumb_size' => 96, 'popup_shape' => 'rect', 'popup_max_width' => 900, 'hover' => false, 'border_speed' => 8, 'border_color_1' => '#1d2327', 'border_color_2' => '#cccccc', 'border_width' => 2],
            'button'       => ['text' => 'Click Here', 'url' => '#', 'align' => 'center', 'bg_color' => '#1d2327', 'text_color' => '#ffffff', 'border_radius' => 6, 'padding_x' => 28, 'padding_y' => 12, 'btn_size' => 'md', 'badge_bg' => '#444444'],
            'video'        => ['video_url' => '', 'width' => 100, 'autoplay' => false, 'controls' => true, 'border_radius' => 0],
            'divider'      => ['style' => 'neon', 'width' => 100, 'thickness' => 2, 'color' => '#00ccff', 'color2' => '#00ffff', 'neon_preset' => 4],
            'icon_card'    => ['icon' => 'star', 'size' => 48, 'color' => '#666666', 'align' => 'center'],
            'table'    => ['items' => [['icon' => 'check', 'text' => 'Feature one'], ['icon' => 'check', 'text' => 'Feature two']], 'icon_size' => 20, 'text_size' => 16, 'gap' => 12, 'icon_gap' => 12, 'columns' => 1],
            'unfold'    => ['items' => [['title' => 'Title 1', 'content' => 'Description 1'], ['title' => 'Title 2', 'content' => 'Description 2']]],
            'carousel' => ['height' => 400, 'border_radius' => 0, 'items_to_show' => 1, 'gap' => 0, 'object_fit' => 'cover', 'max_width' => 100],
            'marquee'  => ['mode' => 'icon', 'image_style' => 'photo', 'caption_pos' => 'below', 'speed' => 5, 'card_width' => 200, 'row_count' => 2, 'edge_fade' => true, 'pause_hover' => true],
            'countdown' => ['cd_rings' => '1', 'cd_units' => 'd,h,m,s', 'cd_size' => 64, 'cd_digit' => '#1d2327', 'cd_accent' => '#999999', 'cd_zero' => 'keep'],
            'map'   => ['address' => '', 'zoom' => 14, 'map_shape' => 'rectangle', 'map_height' => 400],
            'flip_box'     => ['front_title' => '', 'front_content' => '', 'front_bg' => '#f5f5f5', 'front_color' => '#1d2327', 'back_title' => '', 'back_content' => '', 'back_bg' => '#1d2327', 'back_color' => '#ffffff'],
            'social' => ['icon_size' => 20, 'gap' => 12, 'icon_color' => '#666666', 'hover_color' => '#1d2327', 'bg_shape' => 'none'],
            'code'         => ['code' => '', 'placement' => 'body', 'bg_color' => 'transparent'],
            'modal'        => ['trigger' => 'click', 'trigger_delay' => 3, 'trigger_scroll' => 50, 'overlay_color' => 'rgba(0,0,0,0.5)', 'popup_bg' => '#ffffff', 'popup_radius' => 8],
            'modal'   => ['oc_position' => 'right', 'oc_width' => 360, 'oc_padding' => 24, 'oc_bg' => '#ffffff', 'oc_show_close' => true, 'oc_show_overlay' => true],

            // ── Commerce ─────────────────────────────────────────────────────
            'products'         => ['source' => 'query', 'query_type' => 'recent', 'count' => 8, 'columns' => 4, 'gap' => 10],
            'bundle'       => ['bundle_id' => 0, 'layout' => 'stack', 'show_images' => true, 'show_prices' => true, 'border_radius' => 6],
            'cart'           => ['bg_color' => '#ffffff', 'text_color' => '#1d2327', 'max_width' => 100, 'show_image' => true],
            'checkout'       => ['section_max_width' => 400],
            'receipt' => ['section_max_width' => 600, 'icon_color' => '#999999', 'text_color' => '#1d2327', 'btn_bg' => '#1d2327', 'btn_color' => '#ffffff'],
            'shipping'         => ['text_before' => 'Add {amount} more for free shipping!', 'text_after' => "You've unlocked free shipping!", 'bar_color' => '#555555', 'threshold' => '', 'bar_height' => 8, 'border_radius' => 20, 'font_size' => 14],
            'order_tracking'       => ['field_bg' => '#ffffff', 'field_border_color' => '#e0e0e0', 'button_bg' => '#1d2327', 'button_color' => '#ffffff', 'title' => 'Track Your Order'],
            'account'        => ['section_max_width' => 960, 'bg_color' => '#ffffff', 'text_color' => '#1d2327'],
            'plans'        => ['plans' => []],
            'price_list'           => ['pl_items' => [], 'pl_separator' => 'dashed'],

            // ── Forms ─────────────────────────────────────────────────────────
            'contact'          => ['form_name' => 'Contact Form', 'fields' => [['type' => 'text', 'label' => 'Your Name', 'name' => 'name', 'required' => true, 'width' => 100], ['type' => 'email', 'label' => 'Email Address', 'name' => 'email', 'required' => true, 'width' => 100], ['type' => 'textarea', 'label' => 'Message', 'name' => 'message', 'required' => false, 'width' => 100]], 'button_text' => 'Send Message', 'button_bg' => '#1c2226', 'field_gap' => 16, 'field_radius' => 4, 'section_max_width' => 400],
            'login'    => ['field_gap' => 16, 'field_radius' => 4, 'field_bg' => '#f5f5f5', 'button_bg' => '#1c2226', 'section_max_width' => 400],
            'register' => ['field_gap' => 16, 'field_radius' => 4, 'field_bg' => '#f5f5f5', 'button_bg' => '#1c2226', 'section_max_width' => 400],

            // ── Theme ─────────────────────────────────────────────────────────
            'header' => ['layout' => 'logo-left', 'bg_color' => '#ffffff', 'text_color' => '#1d2327', 'hover_color' => '#666666', 'border_color' => '#e0e0e0'],
            'footer' => ['layout' => '4-col', 'bg_color' => '#1d2327', 'text_color' => '#999999', 'heading_color' => '#ffffff', 'link_color' => '#999999'],
        ];

        return $defaults[$type] ?? [];
    }

        public function createImpulseSectionFromTemplate(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId   = (int) $request->get_param('id');
        $data     = $request->get_json_params();
        $template = $data['template'] ?? '';
        $parentId = (int) ($data['parent_id'] ?? 0);

        $elements = \Instinctor\Services\PageService::getElements($pageId);

        $maxId = 0;
        foreach ($elements as $el) {
            if (isset($el['id']) && $el['id'] > $maxId) {
                $maxId = $el['id'];
            }
        }
        $sectionId = $maxId + 1;

        $maxSortOrder = -1;
        foreach ($elements as $el) {
            if (($el['parent_id'] ?? 0) == $parentId && ($el['sort_order'] ?? 0) > $maxSortOrder) {
                $maxSortOrder = $el['sort_order'];
            }
        }
        $sortOrder = $maxSortOrder + 1;

        $sectionSettings = $this->getSectionTemplateSettings($template);
        $sectionElement  = [
            'id'         => $sectionId,
            'page_id'    => $pageId,
            'parent_id'  => $parentId,
            'type'       => 'section',
            'settings'   => $sectionSettings,
            'sort_order' => $sortOrder,
        ];

        $elements[] = $sectionElement;

        $childElements = $this->getSectionTemplateChildren($template, $sectionId, $pageId);
        $childId       = $sectionId;
        foreach ($childElements as $child) {
            $childId++;
            $child['id']      = $childId;
            $child['page_id'] = $pageId;
            $child['parent_id'] = $child['parent_id'] ?? $sectionId;

            $nestedChildren = [];
            if (!empty($child['_is_container']) && !empty($child['_children'])) {
                $nestedChildren = $child['_children'];
            }
            unset($child['_is_container'], $child['_children']);
            $elements[] = $child;

            $containerId = $childId;
            foreach ($nestedChildren as $nested) {
                $childId++;
                $nested['id']       = $childId;
                $nested['page_id']  = $pageId;
                $nested['parent_id'] = $containerId;
                $elements[] = $nested;
            }
        }

        \Instinctor\Services\PageService::saveElements($pageId, $elements);

        return new \WP_REST_Response([
            'id'             => $sectionId,
            'template'       => $template,
            'children_count' => count($childElements),
        ], 201);
    }

    /**
     * Get section template settings based on template key
     */
    private function getSectionTemplateSettings(string $template): array
    {
        $templates = [
            'hero_centered' => [
                'bg_color' => '#0f172a', 'padding_top' => 120, 'padding_bottom' => 120,
                'padding_left' => 40, 'padding_right' => 40, 'content_width' => 100,
                'content_align' => 'center', 'valign' => 'center', 'min_height' => 0,
            ],
            'hero_split' => [
                'bg_color' => '#ffffff', 'padding_top' => 80, 'padding_bottom' => 80,
                'padding_left' => 40, 'padding_right' => 40, 'content_width' => 100,
                'content_align' => 'center', 'min_height' => 0,
            ],
            'features_3col' => [
                'bg_color' => '#f8fafc', 'padding_top' => 80, 'padding_bottom' => 80,
                'padding_left' => 40, 'padding_right' => 40, 'content_width' => 100,
                'content_align' => 'center', 'min_height' => 0,
            ],
            'products_grid' => [
                'bg_color' => '#ffffff', 'padding_top' => 60, 'padding_bottom' => 60,
                'padding_left' => 40, 'padding_right' => 40, 'content_width' => 100,
                'content_align' => 'center', 'min_height' => 0,
            ],
            'newsletter_centered' => [
                'bg_color' => '#f1f5f9', 'padding_top' => 60, 'padding_bottom' => 60,
                'padding_left' => 40, 'padding_right' => 40, 'content_width' => 100,
                'content_align' => 'center', 'min_height' => 0,
            ],
        ];
        return $templates[$template] ?? [
            'bg_color' => '#ffffff', 'padding_top' => 60, 'padding_bottom' => 60, 'min_height' => 0,
        ];
    }

    /**
     * Get child elements for section template
     */
    private function getSectionTemplateChildren(string $template, int $parentId, int $pageId): array
    {
        $children = [];
        switch ($template) {
            case 'hero_centered':
                $children = [
                    ['type' => 'title', 'settings' => ['text' => 'Build Something Amazing', 'tag' => 'h1', 'size' => 52, 'weight' => '700', 'color' => '#ffffff', 'align' => 'center', 'line_height' => 115, 'margin_bottom' => 16], 'sort_order' => 0],
                    ['type' => 'text', 'settings' => ['content' => 'Create stunning websites with the most powerful page builder. No coding required, just drag, drop, and publish.', 'size' => 18, 'color' => '#94a3b8', 'align' => 'center', 'line_height' => 170], 'sort_order' => 1],
                    ['type' => 'spacer', 'settings' => ['height' => 16], 'sort_order' => 2],
                    ['type' => 'button', 'settings' => ['text' => 'Get Started', 'url' => '#', 'bg_color' => '#ffffff', 'text_color' => '#0f172a', 'hover_bg_color' => '#e2e8f0', 'hover_text_color' => '#0f172a', 'align' => 'center', 'padding_x' => 36, 'padding_y' => 14, 'border_radius' => 8, 'weight' => '600', 'font_size' => 16], 'sort_order' => 3],
                ];
                break;
            case 'hero_split':
                $children = [
                    // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                    ['type' => 'rows', 'settings' => ['rows' => json_encode([['widths' => [50, 50], 'cells' => [[], []]]]), 'cell_gap' => 40, 'row_gap' => 0, 'row_height' => 320, 'cell_radius' => 0], 'sort_order' => 0, '_is_container' => true, '_children' => [
                    // phpcs:enable WordPress.WP.AlternativeFunctions
                        ['type' => 'title', 'settings' => ['text' => 'Grow Your Business Online', 'tag' => 'h1', 'size' => 44, 'weight' => '700', 'color' => '#0f172a', 'align' => 'left', 'line_height' => 120, 'margin_bottom' => 16, '_cell_row' => 0, '_cell_col' => 0], 'sort_order' => 0],
                        ['type' => 'text', 'settings' => ['content' => 'Everything you need to launch, manage, and scale your online store.', 'size' => 17, 'color' => '#64748b', 'align' => 'left', 'line_height' => 170, '_cell_row' => 0, '_cell_col' => 0], 'sort_order' => 1],
                        ['type' => 'spacer', 'settings' => ['height' => 8, '_cell_row' => 0, '_cell_col' => 0], 'sort_order' => 2],
                        ['type' => 'button', 'settings' => ['text' => 'Get Started', 'url' => '#', 'bg_color' => '#0f172a', 'text_color' => '#ffffff', 'hover_bg_color' => '#1e293b', 'align' => 'left', 'padding_x' => 32, 'padding_y' => 14, 'border_radius' => 8, 'weight' => '600', 'font_size' => 15, '_cell_row' => 0, '_cell_col' => 0], 'sort_order' => 3],
                        ['type' => 'image', 'settings' => ['border_radius' => 12, 'fill_container' => true, '_cell_row' => 0, '_cell_col' => 1], 'sort_order' => 0],
                    ]],
                ];
                break;
            case 'features_3col':
                $children = [
                    ['type' => 'title', 'settings' => ['text' => 'Why Choose Us', 'tag' => 'h2', 'size' => 36, 'weight' => '700', 'color' => '#0f172a', 'align' => 'center', 'margin_bottom' => 8], 'sort_order' => 0],
                    ['type' => 'text', 'settings' => ['content' => 'Everything you need to build and grow your online presence.', 'size' => 17, 'color' => '#64748b', 'align' => 'center'], 'sort_order' => 1],
                    ['type' => 'spacer', 'settings' => ['height' => 32], 'sort_order' => 2],
                    // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                    ['type' => 'rows', 'settings' => ['rows' => json_encode([['widths' => [33.33, 33.33, 33.34], 'cells' => [[], [], []]]]), 'cell_gap' => 24, 'row_gap' => 0, 'row_height' => 0, 'cell_radius' => 0], 'sort_order' => 3, '_is_container' => true, '_children' => [
                    // phpcs:enable WordPress.WP.AlternativeFunctions
                        ['type' => 'icon_card', 'settings' => ['icon' => 'fas fa-bolt', 'show_title' => true, 'title_content' => 'Lightning Fast', 'show_text' => true, 'text_content' => 'Optimized for speed with no bloat.', 'color' => '#3b82f6', 'size' => 28, 'title_size' => 18, 'text_size' => 14, 'title_color' => '#0f172a', 'text_color' => '#64748b', 'align' => 'center', '_cell_row' => 0, '_cell_col' => 0], 'sort_order' => 0],
                        ['type' => 'icon_card', 'settings' => ['icon' => 'fas fa-palette', 'show_title' => true, 'title_content' => 'Fully Customizable', 'show_text' => true, 'text_content' => 'Every element is adjustable.', 'color' => '#8b5cf6', 'size' => 28, 'title_size' => 18, 'text_size' => 14, 'title_color' => '#0f172a', 'text_color' => '#64748b', 'align' => 'center', '_cell_row' => 0, '_cell_col' => 1], 'sort_order' => 0],
                        ['type' => 'icon_card', 'settings' => ['icon' => 'fas fa-shield-alt', 'show_title' => true, 'title_content' => 'Rock Solid Reliable', 'show_text' => true, 'text_content' => 'Fewer dependencies means fewer things that break.', 'color' => '#10b981', 'size' => 28, 'title_size' => 18, 'text_size' => 14, 'title_color' => '#0f172a', 'text_color' => '#64748b', 'align' => 'center', '_cell_row' => 0, '_cell_col' => 2], 'sort_order' => 0],
                    ]],
                ];
                break;
            case 'products_grid':
                $children = [
                    ['type' => 'title', 'settings' => ['text' => 'Featured Products', 'tag' => 'h2', 'size' => 36, 'weight' => '700', 'color' => '#0f172a', 'align' => 'center', 'margin_bottom' => 8], 'sort_order' => 0],
                    ['type' => 'text', 'settings' => ['content' => 'Handpicked items our customers love most.', 'size' => 17, 'color' => '#64748b', 'align' => 'center'], 'sort_order' => 1],
                    ['type' => 'spacer', 'settings' => ['height' => 24], 'sort_order' => 2],
                    ['type' => 'shortcode', 'settings' => ['code' => '[instinctor_products columns="4" count="4"]'], 'sort_order' => 3],
                ];
                break;
            case 'newsletter_centered':
                $children = [
                    ['type' => 'title', 'settings' => ['text' => 'Stay in the Loop', 'tag' => 'h3', 'size' => 28, 'weight' => '700', 'color' => '#0f172a', 'align' => 'center', 'margin_bottom' => 8], 'sort_order' => 0],
                    ['type' => 'text', 'settings' => ['content' => 'Get the latest updates delivered straight to your inbox.', 'size' => 15, 'color' => '#64748b', 'align' => 'center'], 'sort_order' => 1],
                    ['type' => 'spacer', 'settings' => ['height' => 20], 'sort_order' => 2],
                    // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                    ['type' => 'contact', 'settings' => ['form_name' => 'Newsletter', 'form_width' => 80, 'fields' => json_encode([['type' => 'email', 'label' => 'Email Address', 'name' => 'email', 'required' => true, 'width' => 100]]), 'button_text' => 'Subscribe', 'button_bg' => '#0f172a', 'button_color' => '#ffffff', 'button_radius' => 8, 'field_radius' => 8, 'success_message' => 'Thanks for subscribing!'], 'sort_order' => 3],
                    // phpcs:enable WordPress.WP.AlternativeFunctions
                ];
                break;
        }
        return $children;
    }

    public function wrapOrphanElements(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId = (int) $request->get_param('id');

        if (!\Instinctor\Services\PageService::exists($pageId)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        $elements = \Instinctor\Services\PageService::getElements($pageId);

        $rootEls = [];
        foreach ($elements as $idx => $el) {
            $pid = $el['parent_id'] ?? null;
            if ($pid === null || $pid === 0 || $pid === '') {
                $rootEls[] = ['idx' => $idx, 'el' => $el];
            }
        }
        usort($rootEls, function ($a, $b) {
            return ($a['el']['sort_order'] ?? 0) - ($b['el']['sort_order'] ?? 0);
        });

        $maxId = 0;
        foreach ($elements as $el) {
            if (isset($el['id']) && $el['id'] > $maxId) $maxId = $el['id'];
        }

        $sectionsCreated = 0;
        $totalWrapped    = 0;

        foreach ($rootEls as $root) {
            $type = $root['el']['type'] ?? '';
            if ($type === 'section') continue;

            $maxId++;
            $newSectionId = $maxId;
            $orphanSort   = $root['el']['sort_order'] ?? 0;

            $elements[] = [
                'id'         => $newSectionId,
                'page_id'    => $pageId,
                'parent_id'  => null,
                'type'       => 'section',
                'settings'   => ['bg_color' => 'transparent', 'padding_top' => 20, 'padding_bottom' => 20, 'padding_left' => 20, 'padding_right' => 20, 'full_width' => false],
                'sort_order' => $orphanSort,
            ];
            $sectionsCreated++;

            $orphanId = $root['el']['id'];
            foreach ($elements as &$el) {
                if (isset($el['id']) && $el['id'] === $orphanId) {
                    $el['parent_id']  = $newSectionId;
                    $el['sort_order'] = 0;
                    $totalWrapped++;
                    break;
                }
            }
            unset($el);
        }

        if ($sectionsCreated === 0) {
            return new \WP_REST_Response(['wrapped' => 0, 'sections_created' => 0]);
        }

        // Re-number root sort_order
        $newRootIdxs = [];
        foreach ($elements as $idx => $el) {
            $pid = $el['parent_id'] ?? null;
            if ($pid === null || $pid === 0 || $pid === '') {
                $newRootIdxs[] = $idx;
            }
        }
        usort($newRootIdxs, function ($a, $b) use ($elements) {
            return ($elements[$a]['sort_order'] ?? 0) - ($elements[$b]['sort_order'] ?? 0);
        });
        $sortCounter = 0;
        foreach ($newRootIdxs as $idx) {
            $elements[$idx]['sort_order'] = $sortCounter++;
        }

        \Instinctor\Services\PageService::saveElements($pageId, $elements);

        return new \WP_REST_Response(['wrapped' => $totalWrapped, 'sections_created' => $sectionsCreated]);
    }

    /**
     * Expand all slider elements into individual root sections.
     * Each slide becomes a section at the slider's position, in order.
     * The slider element and its slide-specific metadata are discarded.
     */
    public function expandSliders(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId = (int) $request->get_param('id');

        if (!\Instinctor\Services\PageService::exists($pageId)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        $elements = \Instinctor\Services\PageService::getElements($pageId);

        // Find root-level slider elements
        $sliders = [];
        foreach ($elements as $idx => $el) {
            $pid  = $el['parent_id'] ?? null;
            $type = $el['type'] ?? '';
            if (($pid === null || $pid === 0 || $pid === '') && $type === 'slider') {
                $sliders[] = ['idx' => $idx, 'el' => $el];
            }
        }

        if (empty($sliders)) {
            return new \WP_REST_Response(['sliders_expanded' => 0, 'screens_created' => 0]);
        }

        $slidersExpanded = 0;
        $screensCreated  = 0;

        foreach ($sliders as $sliderInfo) {
            $slider     = $sliderInfo['el'];
            $sliderId   = $slider['id'];
            $sliderSort = $slider['sort_order'] ?? 0;
            $settings   = $slider['settings'] ?? [];
            if (is_string($settings)) $settings = json_decode($settings, true) ?: [];

            // Collect slides definition from settings
            $slides = $settings['slides'] ?? [];
            if (is_string($slides)) $slides = json_decode($slides, true) ?: [];
            if (empty($slides)) {
                $slides = [
                    ['id' => 'slide-1', 'background' => '#f5f5f5'],
                    ['id' => 'slide-2', 'background' => '#f5f5f5'],
                ];
            }
            $savedCount = isset($settings['slide_count']) ? intval($settings['slide_count']) : 0;
            if ($savedCount >= 1 && $savedCount <= 3) {
                $slides = array_slice($slides, 0, $savedCount);
            }

            // Find children of this slider (elements whose parent_id = sliderId)
            // Group them by their slide index (sort_order among siblings)
            $childrenBySlideSortOrder = [];
            foreach ($elements as $idx => $el) {
                if (isset($el['parent_id']) && (int)$el['parent_id'] === (int)$sliderId) {
                    $childrenBySlideSortOrder[$idx] = $el;
                }
            }

            // Determine max existing element ID
            $maxId = 0;
            foreach ($elements as $el) {
                if (isset($el['id']) && (int)$el['id'] > $maxId) $maxId = (int)$el['id'];
            }

            // Create one section per slide, placed at the slider's original sort_order
            // Then move subsequent root elements up to make room
            // We insert by assigning fractional-like sort orders, then renumber at the end
            $insertSort = $sliderSort;
            $newSectionIds = [];

            foreach ($slides as $slideIdx => $slide) {
                $maxId++;
                $bgColor = $slide['background'] ?? $slide['bg_color'] ?? '#f5f5f5';
                $newSection = [
                    'id'         => $maxId,
                    'page_id'    => $pageId,
                    'parent_id'  => null,
                    'type'       => 'section',
                    'settings'   => [
                        'bg_color'       => $bgColor,
                        'padding_top'    => 0,
                        'padding_bottom' => 0,
                        'padding_left'   => 0,
                        'padding_right'  => 0,
                        'content_align'  => 'center',
                        'valign'         => $slide['valign'] ?? 'center',
                    ],
                    'sort_order' => $insertSort + $slideIdx * 0.01,
                ];
                $elements[] = $newSection;
                $newSectionIds[$slideIdx] = $maxId;
                $screensCreated++;
            }

            // Re-parent slider's children into their respective new sections.
            // Children are sorted by sort_order; distribute round-robin across slides
            // if we can't identify which slide they belong to.
            // For slider the child sections map 1:1 to slides by sort_order.
            usort($childrenBySlideSortOrder, function($a, $b) {
                return ($a['sort_order'] ?? 0) - ($b['sort_order'] ?? 0);
            });

            $slideChildIdx = 0;
            foreach ($childrenBySlideSortOrder as $origIdx => $child) {
                $targetSectionId = $newSectionIds[$slideChildIdx] ?? $newSectionIds[0];
                // Find and update child in elements array
                foreach ($elements as &$el) {
                    if ((int)($el['id'] ?? 0) === (int)$child['id']) {
                        $el['parent_id']  = $targetSectionId;
                        $el['sort_order'] = 0;
                        break;
                    }
                }
                unset($el);
                $slideChildIdx++;
            }

            // Remove the slider element itself
            foreach ($elements as $idx => $el) {
                if ((int)($el['id'] ?? 0) === (int)$sliderId) {
                    array_splice($elements, $idx, 1);
                    break;
                }
            }

            $slidersExpanded++;
        }

        // Renumber all root sort_orders cleanly
        $rootIdxs = [];
        foreach ($elements as $idx => $el) {
            $pid = $el['parent_id'] ?? null;
            if ($pid === null || $pid === 0 || $pid === '') {
                $rootIdxs[] = $idx;
            }
        }
        usort($rootIdxs, function($a, $b) use ($elements) {
            return ($elements[$a]['sort_order'] ?? 0) <=> ($elements[$b]['sort_order'] ?? 0);
        });
        $sortCounter = 0;
        foreach ($rootIdxs as $idx) {
            $elements[$idx]['sort_order'] = $sortCounter++;
        }

        \Instinctor\Services\PageService::saveElements($pageId, $elements);

        return new \WP_REST_Response([
            'sliders_expanded' => $slidersExpanded,
            'screens_created'  => $screensCreated,
        ]);
    }

    public function setSectionMinHeights(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId    = (int) $request->get_param('id');
        $minHeight = (int) ($request->get_json_params()['min_height'] ?? 600);

        if (!\Instinctor\Services\PageService::exists($pageId)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        $elements = \Instinctor\Services\PageService::getElements($pageId);

        if (empty($elements)) {
            return new \WP_REST_Response(['updated' => 0]);
        }

        $updated = 0;
        foreach ($elements as &$el) {
            $pid  = $el['parent_id'] ?? null;
            $type = $el['type'] ?? '';
            if ($type !== 'section') continue;
            if ($pid !== null && $pid !== 0 && $pid !== '') continue;

            $settings = $el['settings'] ?? [];
            if (is_string($settings)) $settings = json_decode($settings, true) ?: [];

            if ((int) ($settings['min_height'] ?? 0) === 0) {
                $settings['min_height'] = $minHeight;
                $el['settings']         = $settings;
                $updated++;
            }
        }
        unset($el);

        if ($updated > 0) {
            \Instinctor\Services\PageService::saveElements($pageId, $elements);
        }

        return new \WP_REST_Response(['updated' => $updated]);
    }

    public function getImpulseElement(\WP_REST_Request $request): \WP_REST_Response
    {
        $id     = (int) $request->get_param('id');
        $pageId = (int) $request->get_param('page_id');

        $result = $this->findImpulseElement($id, $pageId);

        if (!$result) {
            return new \WP_REST_Response(['error' => 'Element not found'], 404);
        }

        $element = (object) [
            'id'           => $result['element']['id'],
            'template_id'  => $result['page_id'],
            'parent_id'    => $result['element']['parent_id'] ?? null,
            'section_type' => $result['element']['type'] ?? 'text',
            'settings'     => $result['element']['settings'] ?? [],
            'sort_order'   => $result['element']['sort_order'] ?? 0,
            'status'       => 'active',
        ];

        if (in_array(($result['element']['type'] ?? ''), ['rows', 'columns'])) {
            $elements = \Instinctor\Services\PageService::getElements($result['page_id']);
            $children = [];
            foreach ($elements as $el) {
                if (($el['parent_id'] ?? null) === $id) {
                    $children[] = [
                        'id'       => $el['id'],
                        'type'     => $el['type'] ?? 'text',
                        'cell_row' => $el['settings']['_cell_row'] ?? 0,
                        'cell_col' => $el['settings']['_cell_col'] ?? 0,
                    ];
                }
            }
            $element->children = $children;
        }

        return new \WP_REST_Response($element);
    }

    public function getImpulseElementTree(\WP_REST_Request $request): \WP_REST_Response
    {
        $id     = (int) $request->get_param('id');
        $pageId = (int) $request->get_param('page_id');

        $result = $this->findImpulseElement($id, $pageId);
        if (!$result) {
            return new \WP_REST_Response(['error' => 'Element not found'], 404);
        }

        $elements = \Instinctor\Services\PageService::getElements($result['page_id']);
        $tree     = $this->buildElementTree($id, $elements);

        return new \WP_REST_Response($tree);
    }

    private function buildElementTree(int $rootId, array $elements): array
    {
        $root = null;
        foreach ($elements as $el) {
            if (($el['id'] ?? 0) === $rootId) { $root = $el; break; }
        }
        if (!$root) return [];

        // parent_id + sort_order are included so undo-delete can restore the
        // subtree at its exact original position, including the case where the
        // root was inside a rows (cell coords live inside settings as
        // _cell_row / _cell_col, so they carry over via 'settings' already).
        $tree = [
            'type'       => $root['type'] ?? 'text',
            'settings'   => $root['settings'] ?? [],
            'parent_id'  => $root['parent_id'] ?? null,
            'sort_order' => $root['sort_order'] ?? 0,
        ];

        $children = [];
        foreach ($elements as $el) {
            $elId = $el['id'] ?? 0;
            if ($elId !== $rootId && (($el['parent_id'] ?? null) === $rootId || ($el['parent_id'] ?? 0) === $rootId)) {
                $children[] = $el;
            }
        }
        usort($children, function ($a, $b) {
            return ($a['sort_order'] ?? 0) - ($b['sort_order'] ?? 0);
        });
        if (!empty($children)) {
            $tree['children'] = [];
            foreach ($children as $child) {
                $tree['children'][] = $this->buildElementTree($child['id'], $elements);
            }
        }
        return $tree;
    }

    public function updateImpulseElement(\WP_REST_Request $request): \WP_REST_Response
    {
        try {
            $id     = (int) $request->get_param('id');
            $data   = $request->get_json_params();
            $pageId = (int) ($data['page_id'] ?? $request->get_param('page_id') ?? 0);

            $result = $this->findImpulseElement($id, $pageId);
            if (!$result) {
                return new \WP_REST_Response(['error' => 'Element not found'], 404);
            }
            $pageId = $result['page_id'];

            if (isset($data['parent_id'])) {
                $newParentId = $data['parent_id'] ? intval($data['parent_id']) : null;
                if ($newParentId === $id) {
                    return new \WP_REST_Response(['error' => 'Cannot set element as its own parent'], 400);
                }
            }

            // FRESH READ immediately before write — prevents the stale-read race
            // where a DELETE that landed between our initial findImpulseElement
            // and saveElements would be overwritten by this PUT's stale copy.
            // If the element is now missing (DELETE won), skip the write.
            $elements = \Instinctor\Services\PageService::getElements($pageId);
            $freshIndex = null;
            foreach ($elements as $idx => $el) {
                if ((int) ($el['id'] ?? 0) === $id) { $freshIndex = $idx; break; }
            }
            if ($freshIndex === null) {
                return new \WP_REST_Response(['error' => 'Element deleted'], 410);
            }

            if (isset($data['settings'])) {
                $existing = $elements[$freshIndex]['settings'] ?? [];
                $elements[$freshIndex]['settings'] = array_merge($existing, $data['settings']);
            }
            if (isset($data['parent_id'])) {
                $elements[$freshIndex]['parent_id'] = $data['parent_id'] ? intval($data['parent_id']) : null;
            }
            if (isset($data['sort_order'])) {
                $elements[$freshIndex]['sort_order'] = intval($data['sort_order']);
            }
            if (isset($data['element_type'])) {
                $elements[$freshIndex]['type'] = sanitize_text_field($data['element_type']);
            }

            \Instinctor\Services\PageService::saveElements($pageId, $elements);

            return new \WP_REST_Response(['success' => true]);
        } catch (\Throwable $e) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('[Instinctor] updateElement crashed: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
            return new \WP_REST_Response(['error' => $e->getMessage()], 500);
        }
    }

    public function deleteImpulseElement(\WP_REST_Request $request): \WP_REST_Response
    {
        try {
            $id     = (int) $request->get_param('id');
            $pageId = (int) $request->get_param('page_id');

            $result = $this->findImpulseElement($id, $pageId);
            if (!$result) {
                return new \WP_REST_Response(['error' => 'Element not found'], 404);
            }

            $pageId   = $result['page_id'];
            $elements = \Instinctor\Services\PageService::getElements($pageId);

            $idsToDelete = [$id];
            $this->collectChildIds($id, $elements, $idsToDelete);
            $elements = array_values(array_filter($elements, function ($el) use ($idsToDelete) {
                return !in_array($el['id'], $idsToDelete);
            }));

            \Instinctor\Services\PageService::saveElements($pageId, $elements);

            return new \WP_REST_Response(['success' => true]);
        } catch (\Throwable $e) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('[Instinctor] deleteElement crashed: ' . $e->getMessage() . ' in ' . $e->getFile() . ':' . $e->getLine());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
            return new \WP_REST_Response(['error' => $e->getMessage()], 500);
        }
    }

    private function collectChildIds(int $parentId, array $elements, array &$ids): void
    {
        foreach ($elements as $el) {
            $elId = $el['id'] ?? 0;
            if ((int)($el['parent_id'] ?? 0) === $parentId && !in_array($elId, $ids)) {
                $ids[] = $elId;
                $this->collectChildIds($elId, $elements, $ids);
            }
        }
    }

    /**
     * Basic grid "Duplicate Cell": clone one cell's children (with their
     * subtrees) plus every per-cell style key (cell_*_<idx>) into the first
     * EMPTY cell - scanning forward from the source, then wrapping. When the
     * grid is full, grow one column at the END and remap the row-major cell
     * indices (children sort_orders AND cell_* setting suffixes) to the new
     * column count. sort_order here is the CELL INDEX, so copies share the
     * target index and are never shift-renumbered (same rule as the grid-safe
     * duplicate fix below). 2026-07-19.
     */
    public function duplicateBasicGridCell(\WP_REST_Request $request): \WP_REST_Response
    {
        $gridId = (int) $request->get_param('id');
        $data   = $request->get_json_params();
        $srcIdx = isset($data['cell']) ? max(0, (int) $data['cell']) : 0;
        $pageId = (int) ($data['page_id'] ?? 0);

        $result = $this->findImpulseElement($gridId, $pageId);
        if (!$result) return new \WP_REST_Response(['error' => 'Grid not found'], 404);
        $pageId = $result['page_id'];
        if (($result['element']['type'] ?? '') !== 'grid') {
            return new \WP_REST_Response(['error' => 'Not a grid'], 400);
        }

        $elements = \Instinctor\Services\PageService::getElements($pageId);
        $gridKey  = null;
        foreach ($elements as $k => $el) {
            if ((int) ($el['id'] ?? 0) === $gridId) { $gridKey = $k; break; }
        }
        if ($gridKey === null) return new \WP_REST_Response(['error' => 'Grid not found'], 404);

        $cols  = max(1, (int) ($elements[$gridKey]['settings']['columns'] ?? 2));
        $rows  = max(1, (int) ($elements[$gridKey]['settings']['rows'] ?? 1));
        $total = $cols * $rows;
        if ($srcIdx >= $total) return new \WP_REST_Response(['error' => 'Cell out of range'], 400);

        // $total by REFERENCE: the grow branch below raises it, and the
        // post-grow child scan must clamp against the NEW cell count.
        $clampSort = static function ($el) use (&$total) {
            $so = (int) ($el['sort_order'] ?? 0);
            return max(0, min($total - 1, $so));
        };
        $occupied = [];
        foreach ($elements as $el) {
            if ((int) ($el['parent_id'] ?? 0) === $gridId) $occupied[$clampSort($el)] = true;
        }
        if (empty($occupied[$srcIdx])) {
            return new \WP_REST_Response(['error' => 'Source cell is empty'], 400);
        }

        // Target: first empty AFTER the source, then wrap around.
        $target = null;
        for ($i = $srcIdx + 1; $i < $total; $i++) { if (empty($occupied[$i])) { $target = $i; break; } }
        if ($target === null) { for ($i = 0; $i < $srcIdx; $i++) { if (empty($occupied[$i])) { $target = $i; break; } } }

        $cellKeyRe = '/^(cell_[a-z_]+?)_(\d+)(_tablet|_mobile)?$/';
        $grew = false;
        if ($target === null) {
            // Grid is full: grow one column at the END; remap row-major
            // indices (r,c) -> r*(cols+1)+c for children AND cell_* keys.
            $newCols = $cols + 1;
            $grew    = true;
            $map     = [];
            for ($r = 0; $r < $rows; $r++) {
                for ($c = 0; $c < $cols; $c++) $map[$r * $cols + $c] = $r * $newCols + $c;
            }
            foreach ($elements as &$el) {
                if ((int) ($el['parent_id'] ?? 0) === $gridId) {
                    $el['sort_order'] = $map[$clampSort($el)];
                }
            }
            unset($el);
            $ns = [];
            foreach ($elements[$gridKey]['settings'] as $k => $v) {
                if (preg_match($cellKeyRe, (string) $k, $m)) {
                    $oi = (int) $m[2];
                    $ns[$m[1] . '_' . ($map[$oi] ?? $oi) . ($m[3] ?? '')] = $v;
                } else {
                    $ns[$k] = $v;
                }
            }
            $ns['columns'] = (string) $newCols;
            $elements[$gridKey]['settings'] = $ns;
            $srcIdx = $map[$srcIdx];
            $cols   = $newCols;
            $total  = $cols * $rows;
            $target = intdiv($srcIdx, $cols) * $cols + ($cols - 1); // source's row, new last column
        }

        // Copy the source cell's per-cell style keys to the target index.
        $adds = [];
        foreach ($elements[$gridKey]['settings'] as $k => $v) {
            if (preg_match($cellKeyRe, (string) $k, $m) && (int) $m[2] === $srcIdx) {
                $adds[$m[1] . '_' . $target . ($m[3] ?? '')] = $v;
            }
        }
        foreach ($adds as $k => $v) $elements[$gridKey]['settings'][$k] = $v;

        // Clone the source cell's children (with subtrees) into the target cell.
        $maxId = 0;
        foreach ($elements as $el) { if ((int) ($el['id'] ?? 0) > $maxId) $maxId = (int) $el['id']; }
        $srcKids = [];
        foreach ($elements as $el) {
            if ((int) ($el['parent_id'] ?? 0) === $gridId && $clampSort($el) === $srcIdx) $srcKids[] = $el;
        }
        foreach ($srcKids as $kid) {
            $maxId++;
            $copy = $kid;
            $copy['id'] = $maxId;
            $copy['sort_order'] = $target;
            $elements[] = $copy;
            $idMap = [(int) $kid['id'] => $maxId];
            $this->duplicateChildElementsMeta((int) $kid['id'], $maxId, $elements, $maxId, $idMap);
        }

        \Instinctor\Services\PageService::saveElements($pageId, $elements);
        return new \WP_REST_Response(['success' => true, 'target' => $target, 'grew' => $grew]);
    }

    public function duplicateImpulseElement(\WP_REST_Request $request): \WP_REST_Response
    {
        $id     = (int) $request->get_param('id');
        $pageId = (int) $request->get_param('page_id');

        $result = $this->findImpulseElement($id, $pageId);
        if (!$result) {
            return new \WP_REST_Response(['error' => 'Element not found'], 404);
        }

        $pageId   = $result['page_id'];
        $element  = $result['element'];
        $elements = \Instinctor\Services\PageService::getElements($pageId);

        $maxId = 0;
        foreach ($elements as $el) {
            if ($el['id'] > $maxId) $maxId = $el['id'];
        }

        $originalSortOrder  = (int) ($element['sort_order'] ?? 0);
        $parentId           = $element['parent_id'] ?? null;
        $normalizeParent    = fn($p) => (empty($p) || $p === 0 || $p === '0') ? null : (int) $p;
        $normalizedParentId = $normalizeParent($parentId);

        // GRID PARENT: sort_order is the CELL INDEX, not a list position.
        // The list-style "+1 and shift later siblings" pushed the copy into
        // the NEXT cell and shifted every later cell's content one cell over
        // (clamped into the last cell when past the grid) - "duplicated
        // button flew to cell 2". Copies of grid children keep the original
        // sort (same cell, renders after the original by array order) and
        // siblings are never renumbered. 2026-07-19.
        $parentIsGrid = false;
        if ($normalizedParentId !== null) {
            foreach ($elements as $el) {
                if ((int) ($el['id'] ?? 0) === $normalizedParentId) {
                    $parentIsGrid = (($el['type'] ?? '') === 'grid');
                    break;
                }
            }
        }

        if (!$parentIsGrid) {
            foreach ($elements as $key => $el) {
                $elParentNorm = $normalizeParent($el['parent_id'] ?? null);
                $elSortOrder  = (int) ($el['sort_order'] ?? 0);
                if ($elParentNorm === $normalizedParentId && $elSortOrder > $originalSortOrder) {
                    $elements[$key]['sort_order'] = $elSortOrder + 1;
                }
            }
        }

        $newId      = ++$maxId;
        $idMap      = [$id => $newId];
        $elements[] = [
            'id'         => $newId,
            'page_id'    => $pageId,
            'parent_id'  => $parentId,
            'type'       => $element['type'],
            'settings'   => $element['settings'],
            'sort_order' => $parentIsGrid ? $originalSortOrder : $originalSortOrder + 1,
        ];

        $this->duplicateChildElementsMeta($id, $newId, $elements, $maxId, $idMap);

        \Instinctor\Services\PageService::saveElements($pageId, $elements);

        return new \WP_REST_Response(['success' => true, 'id' => $newId, 'id_map' => $idMap]);
    }

    private function duplicateChildElementsMeta(int $oldParentId, int $newParentId, array &$elements, int &$maxId, array &$idMap = []): void
    {
        $children = array_filter($elements, fn ($el) => (int)($el['parent_id'] ?? 0) === $oldParentId && ($el['id'] ?? 0) !== $oldParentId);
        foreach ($children as $child) {
            $maxId++;
            $newChildId = $maxId;
            $idMap[$child['id']] = $newChildId;
            $elements[] = [
                'id'         => $newChildId,
                'page_id'    => $child['page_id'] ?? 0,
                'parent_id'  => $newParentId,
                'type'       => $child['type'],
                'settings'   => $child['settings'],
                'sort_order' => $child['sort_order'] ?? 0,
            ];
            $this->duplicateChildElementsMeta($child['id'], $newChildId, $elements, $maxId, $idMap);
        }
    }

    /**
     * POST /elements/paste-tree — paste a copied element subtree (from the
     * editor's localStorage clipboard) onto a page under a target parent,
     * assigning fresh sequential IDs. Powers cross-page Ctrl+C / Ctrl+V of
     * whole sections/grids: the clipboard carries the full tree
     * ({ type, settings, children:[...] } from GET /elements/{id}/tree), not
     * just the root's settings. Body: { page_id, parent_id, after_id?, tree }.
     */
    public function pasteImpulseElementTree(\WP_REST_Request $request): \WP_REST_Response
    {
        $data    = $request->get_json_params() ?: [];
        $pageId  = (int) ($data['page_id'] ?? 0);
        $tree    = $data['tree'] ?? null;
        $afterId = (int) ($data['after_id'] ?? 0);

        $normalizeParent = fn($p) => (empty($p) || $p === 0 || $p === '0') ? null : (int) $p;
        $parentId = $normalizeParent($data['parent_id'] ?? null);

        if (!$pageId || !\Instinctor\Services\PageService::exists($pageId)) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }
        if (!is_array($tree) || empty($tree['type'])) {
            return new \WP_REST_Response(['error' => 'Invalid clipboard tree'], 400);
        }
        // Never paste at root level — that would create a screen.
        if (!$parentId) {
            return new \WP_REST_Response(['error' => 'Cannot paste at root level'], 400);
        }

        $elements = \Instinctor\Services\PageService::getElements($pageId);

        $maxId = 0;
        foreach ($elements as $el) {
            if ((int) ($el['id'] ?? 0) > $maxId) $maxId = (int) $el['id'];
        }

        // Root sort_order: with after_id (pasting as a sibling of the selected
        // leaf) insert right after it and bump later siblings; otherwise (paste
        // inside a container) append at the end.
        $rootSort = 0;
        if ($afterId) {
            $afterSort = 0;
            foreach ($elements as $el) {
                if ((int) ($el['id'] ?? 0) === $afterId) { $afterSort = (int) ($el['sort_order'] ?? 0); break; }
            }
            foreach ($elements as $k => $el) {
                if ($normalizeParent($el['parent_id'] ?? null) === $parentId
                    && (int) ($el['sort_order'] ?? 0) > $afterSort) {
                    $elements[$k]['sort_order'] = (int) ($elements[$k]['sort_order'] ?? 0) + 1;
                }
            }
            $rootSort = $afterSort + 1;
        } else {
            foreach ($elements as $el) {
                if ($normalizeParent($el['parent_id'] ?? null) === $parentId) {
                    $rootSort = max($rootSort, (int) ($el['sort_order'] ?? 0) + 1);
                }
            }
        }

        $newRootId = $this->insertElementTree($tree, $parentId, $rootSort, $pageId, $elements, $maxId);

        \Instinctor\Services\PageService::saveElements($pageId, $elements);

        return new \WP_REST_Response(['success' => true, 'id' => $newRootId]);
    }

    /**
     * Recursively append a detached clipboard subtree into $elements with fresh
     * sequential IDs. Mirrors duplicateChildElementsMeta() but sources from the
     * tree JSON (clipboard) rather than existing rows. Returns the new root id.
     */
    private function insertElementTree(array $node, ?int $parentId, int $sortOrder, int $pageId, array &$elements, int &$maxId): int
    {
        $newId = ++$maxId;
        $elements[] = [
            'id'         => $newId,
            'page_id'    => $pageId,
            'parent_id'  => $parentId,
            'type'       => $node['type'],
            'settings'   => is_array($node['settings'] ?? null) ? $node['settings'] : [],
            'sort_order' => $sortOrder,
        ];
        if (!empty($node['children']) && is_array($node['children'])) {
            $cs = 0;
            foreach ($node['children'] as $child) {
                if (is_array($child) && !empty($child['type'])) {
                    $this->insertElementTree($child, $newId, $cs++, $pageId, $elements, $maxId);
                }
            }
        }
        return $newId;
    }

    /**
     * Duplicate a slide in a slider: copy slide settings entry + all child elements for that slide.
     * POST /elements/{sliderId}/duplicate-slide  { page_id, slide_index }
     */
    public function duplicateSlide(\WP_REST_Request $request): \WP_REST_Response
    {
        $sliderId  = (int) $request->get_param('id');
        $data      = $request->get_json_params();
        $pageId    = (int) ($data['page_id']    ?? 0);
        $srcIdx    = (int) ($data['slide_index'] ?? 0);

        $result = $this->findImpulseElement($sliderId, $pageId);
        if (!$result) return new \WP_REST_Response(['error' => 'Slider not found'], 404);

        $pageId   = $result['page_id'];
        $slider   = $result['element'];
        $elements = \Instinctor\Services\PageService::getElements($pageId);

        // ── 1. Clone the slide entry in settings.slides ──────────────────────
        $settings = is_array($slider['settings']) ? $slider['settings'] : (array)json_decode($slider['settings'] ?? '{}', true);
        $slides   = $settings['slides'] ?? [['id' => 'slide-1', 'background' => '', 'valign' => 'center']];
        if (!isset($slides[$srcIdx])) return new \WP_REST_Response(['error' => 'Slide not found'], 400);

        $newIdx    = $srcIdx + 1;
        $newSlide  = $slides[$srcIdx];
        $newSlide['id'] = 'slide-' . time() . '-' . $newIdx;
        array_splice($slides, $newIdx, 0, [$newSlide]);
        $settings['slides']      = $slides;
        $settings['slide_count'] = count($slides);

        // ── 2. Update child elements: shift slide_index > srcIdx, then clone srcIdx children ──
        $maxId   = 0;
        $idMap   = [];
        foreach ($elements as $el) { if ($el['id'] > $maxId) $maxId = $el['id']; }

        $newChildren = [];
        foreach ($elements as &$el) {
            if ((int)($el['parent_id'] ?? 0) !== $sliderId) continue;
            $elSettings = is_array($el['settings']) ? $el['settings'] : (array)json_decode($el['settings'] ?? '{}', true);
            $elSlideIdx = isset($elSettings['slide_index']) ? (int)$elSettings['slide_index'] : null;
            if ($elSlideIdx === null) continue;
            if ($elSlideIdx > $srcIdx) {
                // Shift up by 1
                $elSettings['slide_index'] = $elSlideIdx + 1;
                $el['settings'] = $elSettings;
            } elseif ($elSlideIdx === $srcIdx) {
                // Clone for new slide
                $newChildId = ++$maxId;
                $idMap[$el['id']] = $newChildId;
                $cloneSettings = $elSettings;
                $cloneSettings['slide_index'] = $newIdx;
                $newChildren[] = [
                    'id'         => $newChildId,
                    'page_id'    => $pageId,
                    'parent_id'  => $sliderId,
                    'type'       => $el['type'],
                    'settings'   => $cloneSettings,
                    'sort_order' => $el['sort_order'] ?? 0,
                ];
            }
        }
        unset($el);

        // ── 3. Update slider settings + save ─────────────────────────────────
        foreach ($elements as &$el) {
            if ($el['id'] === $sliderId) {
                $el['settings'] = $settings;
                break;
            }
        }
        unset($el);

        $elements = array_merge($elements, $newChildren);
        \Instinctor\Services\PageService::saveElements($pageId, $elements);

        return new \WP_REST_Response([
            'success'        => true,
            'new_slide_index' => $newIdx,
            'slide_count'    => count($slides),
            'id_map'         => $idMap,
        ]);
    }

    /**
     * Duplicate a row of children in a row-based container.
     *
     * Two code paths:
     *  - rows: children use settings._cell_row to identify their row.
     *    Shift rows below srcRow by +1, clone children of srcRow with
     *    _cell_row = newRow. The container's `rows` setting array is
     *    updated client-side before this call (ctSaveRows).
     *  - grid (masonry): children use sort_order = cell index, with
     *    columns = settings.columns. Row N occupies sort_orders
     *    [N*cols, (N+1)*cols-1]. Shift children at sort_order
     *    >= (srcRow+1)*cols by +cols, clone source-row children with
     *    sort_orders shifted into the new row band, increment grid's
     *    `rows` setting by 1.
     */
    public function duplicateGridRow(\WP_REST_Request $request): \WP_REST_Response
    {
        $gridId  = (int) $request->get_param('id');
        $data    = $request->get_json_params();
        $pageId  = (int) ($data['page_id'] ?? 0);
        $srcRow  = (int) ($data['row'] ?? 0);
        $newRow  = (int) ($data['new_row'] ?? $srcRow + 1);

        $result = $this->findImpulseElement($gridId, $pageId);
        if (!$result) {
            return new \WP_REST_Response(['error' => 'Grid not found'], 404);
        }

        $pageId    = $result['page_id'];
        $gridEl    = $result['element'] ?? null;
        $gridType  = $gridEl['type'] ?? '';
        $elements  = \Instinctor\Services\PageService::getElements($pageId);

        $maxId = 0;
        foreach ($elements as $el) {
            if ($el['id'] > $maxId) $maxId = $el['id'];
        }

        if ($gridType === 'grid') {
            // Masonry grid: cells indexed by sort_order, columns from settings.
            $cols = max(1, (int) (($gridEl['settings'] ?? [])['columns'] ?? 1));
            $rowStart = $srcRow * $cols;
            $rowEnd   = $rowStart + $cols - 1; // inclusive

            // Shift children at sort_order > rowEnd by +cols
            foreach ($elements as &$el) {
                if (($el['parent_id'] ?? null) != $gridId) continue;
                $so = (int) ($el['sort_order'] ?? 0);
                if ($so > $rowEnd) {
                    $el['sort_order'] = $so + $cols;
                }
            }
            unset($el);

            // Clone children whose sort_order is inside the source row
            $toClone = [];
            foreach ($elements as $el) {
                if (($el['parent_id'] ?? null) != $gridId) continue;
                $so = (int) ($el['sort_order'] ?? 0);
                if ($so >= $rowStart && $so <= $rowEnd) {
                    $toClone[] = $el;
                }
            }
            foreach ($toClone as $child) {
                $maxId++;
                $newSO = (int) ($child['sort_order'] ?? 0) + $cols;
                $elements[] = [
                    'id'         => $maxId,
                    'page_id'    => $pageId,
                    'parent_id'  => $gridId,
                    'type'       => $child['type'],
                    'settings'   => $child['settings'] ?? [],
                    'sort_order' => $newSO,
                ];
                $this->duplicateChildElementsMeta($child['id'], $maxId, $elements, $maxId);
            }

            // Bump the grid's `rows` setting so the new band has a slot to render into
            foreach ($elements as &$el) {
                if ($el['id'] == $gridId) {
                    $s = $el['settings'] ?? [];
                    $rows = max(1, (int) ($s['rows'] ?? 1));
                    $el['settings']['rows'] = $rows + 1;
                    break;
                }
            }
            unset($el);
        } else {
            // rows (and anything else that uses _cell_row): row identity
            // lives in settings._cell_row.
            foreach ($elements as &$el) {
                if (($el['parent_id'] ?? null) != $gridId) continue;
                $s = $el['settings'] ?? [];
                $r = (int) ($s['_cell_row'] ?? 0);
                if ($r > $srcRow) {
                    $el['settings']['_cell_row'] = $r + 1;
                }
            }
            unset($el);

            $toClone = [];
            foreach ($elements as $el) {
                if (($el['parent_id'] ?? null) != $gridId) continue;
                $r = (int) (($el['settings'] ?? [])['_cell_row'] ?? 0);
                if ($r === $srcRow) {
                    $toClone[] = $el;
                }
            }

            foreach ($toClone as $child) {
                $maxId++;
                $newSettings = $child['settings'] ?? [];
                $newSettings['_cell_row'] = $newRow;
                $elements[] = [
                    'id'         => $maxId,
                    'page_id'    => $pageId,
                    'parent_id'  => $gridId,
                    'type'       => $child['type'],
                    'settings'   => $newSettings,
                    'sort_order' => $child['sort_order'] ?? 0,
                ];
                $this->duplicateChildElementsMeta($child['id'], $maxId, $elements, $maxId);
            }
        }

        \Instinctor\Services\PageService::saveElements($pageId, $elements);
        return new \WP_REST_Response(['success' => true]);
    }

    /**
     * Duplicate a cell in a rows row: shift siblings to the right, clone children in the source cell.
     */
    public function duplicateGridCell(\WP_REST_Request $request): \WP_REST_Response
    {
        $gridId  = (int) $request->get_param('id');
        $data    = $request->get_json_params();
        $pageId  = (int) ($data['page_id'] ?? 0);
        $srcRow  = (int) ($data['row'] ?? 0);
        $srcCol  = (int) ($data['col'] ?? 0);
        $newCol  = (int) ($data['new_col'] ?? $srcCol + 1);

        $result = $this->findImpulseElement($gridId, $pageId);
        if (!$result) {
            return new \WP_REST_Response(['error' => 'Grid not found'], 404);
        }

        $pageId   = $result['page_id'];
        $elements = \Instinctor\Services\PageService::getElements($pageId);

        $maxId = 0;
        foreach ($elements as $el) {
            if ($el['id'] > $maxId) $maxId = $el['id'];
        }

        // Shift children in the same row with col > srcCol
        foreach ($elements as &$el) {
            if (($el['parent_id'] ?? null) != $gridId) continue;
            $s = $el['settings'] ?? [];
            $r = (int) ($s['_cell_row'] ?? 0);
            $c = (int) ($s['_cell_col'] ?? 0);
            if ($r === $srcRow && $c > $srcCol) {
                $el['settings']['_cell_col'] = $c + 1;
            }
        }
        unset($el);

        // Clone children from source cell
        $toClone = [];
        foreach ($elements as $el) {
            if (($el['parent_id'] ?? null) != $gridId) continue;
            $s = $el['settings'] ?? [];
            $r = (int) ($s['_cell_row'] ?? 0);
            $c = (int) ($s['_cell_col'] ?? 0);
            if ($r === $srcRow && $c === $srcCol) {
                $toClone[] = $el;
            }
        }

        foreach ($toClone as $child) {
            $maxId++;
            $newSettings = $child['settings'] ?? [];
            $newSettings['_cell_col'] = $newCol;
            $elements[] = [
                'id'         => $maxId,
                'page_id'    => $pageId,
                'parent_id'  => $gridId,
                'type'       => $child['type'],
                'settings'   => $newSettings,
                'sort_order' => $child['sort_order'] ?? 0,
            ];
            $this->duplicateChildElementsMeta($child['id'], $maxId, $elements, $maxId);
        }

        \Instinctor\Services\PageService::saveElements($pageId, $elements);
        return new \WP_REST_Response(['success' => true]);
    }

    public function reorderImpulseElement(\WP_REST_Request $request): \WP_REST_Response
    {
        $sourceId        = (int) $request->get_param('id');
        $data            = $request->get_json_params();
        $targetId        = isset($data['target_id']) ? (int) $data['target_id'] : 0;
        $position        = $data['position'] ?? '';
        $directParentId  = isset($data['parent_id']) ? (int) $data['parent_id'] : null;
        $directSortOrder = isset($data['sort_order']) ? (int) $data['sort_order'] : null;
        $directCellRow   = isset($data['cell_row']) ? (int) $data['cell_row'] : null;
        $directCellCol   = isset($data['cell_col']) ? (int) $data['cell_col'] : null;
        $pageId          = (int) ($data['page_id'] ?? $request->get_param('page_id') ?? 0);

        if ($directParentId !== null && $directSortOrder !== null && !$targetId) {
            $sourceResult = $this->findImpulseElement($sourceId, $pageId);
            if (!$sourceResult) return new \WP_REST_Response(['error' => 'Source element not found'], 404);

            $pageId   = $sourceResult['page_id'];
            $elements = \Instinctor\Services\PageService::getElements($pageId);

            // SCREEN MODE GUARD: block non-section reorder to root
            $reorderPageSettings = \Instinctor\Services\PageService::getSettings($pageId);
            if ($directParentId === 0 || $directParentId === null) {
                $srcType = $sourceResult['element']['type'] ?? '';
                if ($srcType !== 'section') {
                    return new \WP_REST_Response(['error' => 'Screen mode: cannot reorder non-section to root'], 400);
                }
            }

            // GRID PARENT: sort_order = cell index; multiple children share a
            // sort and must NEVER be shift-renumbered (that moves them to other
            // cells - the "content flew to the next cell" class of bug, same
            // family as the duplicate fix below). 2026-07-19.
            $directParentIsGrid = false;
            foreach ($elements as $el) {
                if ((int) ($el['id'] ?? 0) === $directParentId) {
                    $directParentIsGrid = (($el['type'] ?? '') === 'grid');
                    break;
                }
            }
            if (!$directParentIsGrid) {
                foreach ($elements as &$el) {
                    if ($el['id'] === $sourceId) continue;
                    if (($el['parent_id'] ?? 0) != $directParentId) continue;
                    if ($directCellRow !== null && $directCellCol !== null) {
                        if (($el['settings']['_cell_row'] ?? null) != $directCellRow || ($el['settings']['_cell_col'] ?? null) != $directCellCol) continue;
                    }
                    if (($el['sort_order'] ?? 0) >= $directSortOrder) {
                        $el['sort_order'] = ($el['sort_order'] ?? 0) + 1;
                    }
                }
                unset($el);
            }

            foreach ($elements as &$el) {
                if ($el['id'] === $sourceId) {
                    // 0 = root: store null, never integer 0 (normalizeTree
                    // would purge the subtree as dangling otherwise).
                    $el['parent_id']  = $directParentId > 0 ? $directParentId : null;
                    $el['sort_order'] = $directSortOrder;
                    if ($directCellRow !== null && $directCellCol !== null) {
                        $el['settings']['_cell_row'] = $directCellRow;
                        $el['settings']['_cell_col'] = $directCellCol;
                    } else {
                        unset($el['settings']['_cell_row'], $el['settings']['_cell_col']);
                    }
                    break;
                }
            }
            unset($el);

            \Instinctor\Services\PageService::saveElements($pageId, $elements);
            return new \WP_REST_Response(['success' => true]);
        }

        $sourceResult = $this->findImpulseElement($sourceId, $pageId);
        if (!$sourceResult) return new \WP_REST_Response(['error' => 'Source element not found'], 404);

        $targetResult = $this->findImpulseElement($targetId, $pageId);
        if (!$targetResult) return new \WP_REST_Response(['error' => 'Target element not found'], 404);

        $pageId   = $sourceResult['page_id'];
        $elements = \Instinctor\Services\PageService::getElements($pageId);

        $target        = $targetResult['element'];
        $targetSort    = (int) ($target['sort_order'] ?? 0);
        $targetParent  = $target['parent_id'] ?? null;
        $targetCellRow = $target['settings']['_cell_row'] ?? null;
        $targetCellCol = $target['settings']['_cell_col'] ?? null;

        // SCREEN MODE GUARD: block reorder next to root elements
        $reorderPageSettings2 = \Instinctor\Services\PageService::getSettings($pageId);
        if (empty($targetParent)) {
            $srcType2 = $sourceResult['element']['type'] ?? '';
            if ($srcType2 !== 'section') {
                return new \WP_REST_Response(['error' => 'Screen mode: cannot reorder non-section to root'], 400);
            }
        }

        $isGridCell = false;
        if ($targetParent) {
            foreach ($elements as $el) {
                if ($el['id'] === $targetParent && $el['type'] === 'grid') { $isGridCell = true; break; }
            }
        }

        if ($isGridCell) {
            // For grid blocks, sort_order = cell index — multiple children
            // can share the same sort_order inside one cell. The source's
            // new sort_order matches the target's so they land in the same
            // cell, but the order WITHIN the cell is determined by array
            // order (the grid renderer groups by sort_order, then iterates
            // each group in its array sequence). We must therefore also
            // re-position the source in the array, otherwise dragging a
            // heading "above" a text in the same cell looks ignored —
            // sort_order updates, but the heading still renders below.
            $newSort = $targetSort;
            $srcIdx = $tgtIdx = -1;
            foreach ($elements as $i => $el) {
                if ($el['id'] === $sourceId) $srcIdx = $i;
                if ($el['id'] === $targetId) $tgtIdx = $i;
            }
            if ($srcIdx >= 0 && $tgtIdx >= 0) {
                // Pull source out, then splice back in relative to target.
                $srcEl = $elements[$srcIdx];
                array_splice($elements, $srcIdx, 1);
                // Removing source can shift target's index down by one.
                if ($srcIdx < $tgtIdx) $tgtIdx--;
                $insertAt = ($position === 'before') ? $tgtIdx : ($tgtIdx + 1);
                array_splice($elements, $insertAt, 0, [$srcEl]);
            }
        } elseif ($position === 'before') {
            $newSort = $targetSort;
            foreach ($elements as &$el) {
                $sameCell = (($el['settings']['_cell_row'] ?? null) === $targetCellRow && ($el['settings']['_cell_col'] ?? null) === $targetCellCol);
                if (($el['parent_id'] ?? null) === $targetParent && $sameCell && ($el['sort_order'] ?? 0) >= $targetSort && $el['id'] !== $sourceId) {
                    $el['sort_order'] = ($el['sort_order'] ?? 0) + 1;
                }
            }
        } else {
            $newSort = $targetSort + 1;
            foreach ($elements as &$el) {
                $sameCell = (($el['settings']['_cell_row'] ?? null) === $targetCellRow && ($el['settings']['_cell_col'] ?? null) === $targetCellCol);
                if (($el['parent_id'] ?? null) === $targetParent && $sameCell && ($el['sort_order'] ?? 0) > $targetSort && $el['id'] !== $sourceId) {
                    $el['sort_order'] = ($el['sort_order'] ?? 0) + 1;
                }
            }
        }
        unset($el);

        foreach ($elements as &$el) {
            if ($el['id'] === $sourceId) {
                $el['parent_id']  = $targetParent;
                $el['sort_order'] = $newSort;
                if ($targetCellRow !== null && $targetCellCol !== null) {
                    $el['settings']['_cell_row'] = $targetCellRow;
                    $el['settings']['_cell_col'] = $targetCellCol;
                } else {
                    unset($el['settings']['_cell_row'], $el['settings']['_cell_col']);
                }
                break;
            }
        }
        unset($el);

        \Instinctor\Services\PageService::saveElements($pageId, $elements);
        return new \WP_REST_Response(['success' => true]);
    }

    public function moveImpulseElementInto(\WP_REST_Request $request): \WP_REST_Response
    {
        $sourceId      = (int) $request->get_param('id');
        $data          = $request->get_json_params();
        $parentId      = isset($data['parent_id']) ? (int) $data['parent_id'] : -1;
        $cellRow       = isset($data['cell_row']) ? (int) $data['cell_row'] : null;
        $cellCol       = isset($data['cell_col']) ? (int) $data['cell_col'] : null;
        $gridCellIndex = isset($data['grid_cell_index']) ? (int) $data['grid_cell_index'] : null;

        if ($parentId < 0) {
            return new \WP_REST_Response(['error' => 'Missing parent_id', 'received' => $data], 400);
        }

        // Guard: element cannot be its own parent
        if ($parentId === $sourceId) {
            return new \WP_REST_Response(['error' => 'Cannot move element into itself'], 400);
        }

        $movePageId   = (int) ($data['page_id'] ?? $request->get_param('page_id') ?? 0);
        $sourceResult = $this->findImpulseElement($sourceId, $movePageId);
        if (!$sourceResult) return new \WP_REST_Response(['error' => 'Element not found'], 404);

        $pageId   = $sourceResult['page_id'];
        $elements = \Instinctor\Services\PageService::getElements($pageId);

        // Guard: target cannot be a descendant of source (would create a cycle)
        if ($parentId > 0) {
            $descendantIds = [$sourceId];
            $this->collectChildIds($sourceId, $elements, $descendantIds);
            if (in_array($parentId, $descendantIds)) {
                return new \WP_REST_Response(['error' => 'Cannot move element into its own descendant'], 400);
            }
        }

        // SCREEN MODE GUARD: block non-section moves to root
        $sourceType = null;
        foreach ($elements as $_el) {
            if ($_el['id'] === $sourceId) { $sourceType = $_el['type'] ?? ''; break; }
        }
        if ($parentId === 0) {
            $movePageSettings = \Instinctor\Services\PageService::getSettings($pageId);
            if ($sourceType !== 'section') {
                // Redirect to first root section
                $fallbackParent = null;
                foreach ($elements as $_el) {
                    if (($_el['type'] ?? '') === 'section' && empty($_el['parent_id'])) {
                        $fallbackParent = $_el['id'] ?? null;
                        break;
                    }
                }
                if ($fallbackParent) {
                    $parentId = $fallbackParent;
                } else {
                    return new \WP_REST_Response(['error' => 'Screen mode: cannot move non-section to root'], 400);
                }
            }
        }

        if ($parentId === 0) {
            $maxSort = -1;
            foreach ($elements as $el) {
                if (empty($el['parent_id']) && ($el['sort_order'] ?? 0) > $maxSort) $maxSort = $el['sort_order'] ?? 0;
            }
            $newSort = $maxSort + 1;

            foreach ($elements as &$el) {
                if ($el['id'] === $sourceId) {
                    $el['parent_id']  = 0;
                    $el['sort_order'] = $newSort;
                    unset($el['settings']['_cell_row'], $el['settings']['_cell_col']);
                    break;
                }
            }
            unset($el);

            \Instinctor\Services\PageService::saveElements($pageId, $elements);
            return new \WP_REST_Response(['success' => true, 'moved' => $sourceId, 'into' => 0, 'new_sort' => $newSort]);
        }

        $parent = null;
        foreach ($elements as $el) {
            if ($el['id'] === $parentId) { $parent = $el; break; }
        }
        if (!$parent) return new \WP_REST_Response(['error' => 'Container not found'], 404);

        $containerTypes = ['section', 'grid', 'rows', 'columns', 'slider', 'carousel', 'modal'];
        if (!in_array($parent['type'], $containerTypes)) {
            return new \WP_REST_Response(['error' => 'Target is not a container', 'type' => $parent['type']], 400);
        }

        if ($parent['type'] === 'grid' && $gridCellIndex !== null) {
            $newSort = $gridCellIndex;
        } else {
            $maxSort = -1;
            foreach ($elements as $el) {
                if (($el['parent_id'] ?? null) === $parentId) {
                    if (in_array($parent['type'], ['rows', 'columns']) && $cellRow !== null && $cellCol !== null) {
                        if (($el['settings']['_cell_row'] ?? null) === $cellRow && ($el['settings']['_cell_col'] ?? null) === $cellCol && ($el['sort_order'] ?? 0) > $maxSort) {
                            $maxSort = $el['sort_order'] ?? 0;
                        }
                    } else {
                        if (($el['sort_order'] ?? 0) > $maxSort) $maxSort = $el['sort_order'] ?? 0;
                    }
                }
            }
            $newSort = $maxSort + 1;
        }

        foreach ($elements as &$el) {
            if ($el['id'] === $sourceId) {
                $el['parent_id']  = $parentId;
                $el['sort_order'] = $newSort;
                if (in_array($parent['type'], ['rows', 'columns']) && $cellRow !== null && $cellCol !== null) {
                    $el['settings']['_cell_row'] = $cellRow;
                    $el['settings']['_cell_col'] = $cellCol;
                } elseif ($parent['type'] === 'slider' || $parent['type'] === 'carousel') {
                    $el['settings']['slide_index'] = isset($data['slide_index']) ? (int) $data['slide_index'] : 0;
                    unset($el['settings']['_cell_row'], $el['settings']['_cell_col']);
                } else {
                    unset($el['settings']['_cell_row'], $el['settings']['_cell_col'], $el['settings']['slide_index']);
                }
                break;
            }
        }
        unset($el);

        \Instinctor\Services\PageService::saveElements($pageId, $elements);
        return new \WP_REST_Response(['success' => true, 'moved' => $sourceId, 'into' => $parentId, 'new_sort' => $newSort]);
    }

    public function swapCellGridCells(\WP_REST_Request $request): \WP_REST_Response
    {
        $cellGridId = (int) $request->get_param('id');
        $data       = $request->get_json_params();
        $fromRow    = isset($data['from_row']) ? (int) $data['from_row'] : null;
        $fromCol    = isset($data['from_col']) ? (int) $data['from_col'] : null;
        $toRow      = isset($data['to_row']) ? (int) $data['to_row'] : null;
        $toCol      = isset($data['to_col']) ? (int) $data['to_col'] : null;

        if ($fromRow === null || $fromCol === null || $toRow === null || $toCol === null) {
            return new \WP_REST_Response(['error' => 'Missing cell positions'], 400);
        }

        $swapPageId = (int) ($data['page_id'] ?? $request->get_param('page_id') ?? 0);
        $result     = $this->findImpulseElement($cellGridId, $swapPageId);
        if (!$result) return new \WP_REST_Response(['error' => 'Element not found'], 404);

        $elType = $result['element']['type'] ?? '';
        if ($elType !== 'rows' && $elType !== 'columns') {
            return new \WP_REST_Response(['error' => 'Element is not a rows or columns'], 400);
        }

        $pageId       = $result['page_id'];
        $elementIndex = $result['index'];
        $elements     = \Instinctor\Services\PageService::getElements($pageId);
        $settings     = $elements[$elementIndex]['settings'] ?? [];
        $isLayout     = $elType === 'columns';

        if ($isLayout) {
            $columns = $settings['columns'] ?? [];
            if (is_string($columns)) $columns = json_decode($columns, true) ?: [];
            $fromCellData = $columns[$fromCol]['cells'][$fromRow] ?? [];
            $toCellData   = $columns[$toCol]['cells'][$toRow] ?? [];
            if (isset($columns[$fromCol]['cells'][$fromRow])) $columns[$fromCol]['cells'][$fromRow] = $toCellData;
            if (isset($columns[$toCol]['cells'][$toRow]))     $columns[$toCol]['cells'][$toRow]     = $fromCellData;
            $elements[$elementIndex]['settings']['columns'] = $columns;
        } else {
            $rows = $settings['rows'] ?? [];
            if (is_string($rows)) $rows = json_decode($rows, true) ?: [];
            $fromCellData = $rows[$fromRow]['cells'][$fromCol] ?? [];
            $toCellData   = $rows[$toRow]['cells'][$toCol] ?? [];
            if (isset($rows[$fromRow]['cells'][$fromCol])) $rows[$fromRow]['cells'][$fromCol] = $toCellData;
            if (isset($rows[$toRow]['cells'][$toCol]))     $rows[$toRow]['cells'][$toCol]     = $fromCellData;
            $elements[$elementIndex]['settings']['rows'] = $rows;
        }

        $fromCount = 0;
        $toCount   = 0;
        foreach ($elements as &$el) {
            if (($el['parent_id'] ?? null) === $cellGridId) {
                $cr = $el['settings']['_cell_row'] ?? 0;
                $cc = $el['settings']['_cell_col'] ?? 0;
                if ($cr === $fromRow && $cc === $fromCol) {
                    $el['settings']['_cell_row'] = $toRow;
                    $el['settings']['_cell_col'] = $toCol;
                    $fromCount++;
                } elseif ($cr === $toRow && $cc === $toCol) {
                    $el['settings']['_cell_row'] = $fromRow;
                    $el['settings']['_cell_col'] = $fromCol;
                    $toCount++;
                }
            }
        }
        unset($el);

        \Instinctor\Services\PageService::saveElements($pageId, $elements);

        return new \WP_REST_Response([
            'success'             => true,
            'swapped_from_count'  => $fromCount,
            'swapped_to_count'    => $toCount,
            'cell_data_swapped'   => true,
            'from'                => ['row' => $fromRow, 'col' => $fromCol],
            'to'                  => ['row' => $toRow, 'col' => $toCol],
        ]);
    }

    /**
     * Find an element by ID in a page's elements array.
     * Returns ['page_id', 'index', 'element'] or null.
     */
    /**
     * GET /pages/{id}/health — diagnose page data integrity
     */
    public function pageHealth(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId = (int) $request->get_param('id');
        $t = \Instinctor\Core\DB::prefix() . 'instinctor_pages';
        
        // Read raw JSON from DB
        $row = \Instinctor\Core\DB::get_row(\Instinctor\Core\DB::prepare(
            "SELECT LENGTH(elements) as json_bytes, elements FROM {$t} WHERE id = %d LIMIT 1",
            $pageId
        ), ARRAY_A);
        
        if (!$row) {
            return new \WP_REST_Response(['error' => 'Page not found in instinctor_pages'], 404);
        }
        
        $jsonBytes = (int) ($row['json_bytes'] ?? 0);
        $rawJson = $row['elements'] ?? '';
        
        // Try decode
        $elements = json_decode($rawJson, true);
        $jsonError = json_last_error();
        $jsonErrorMsg = json_last_error_msg();
        
        $report = [
            'page_id' => $pageId,
            'json_bytes' => $jsonBytes,
            'json_mb' => round($jsonBytes / 1024 / 1024, 3),
            'json_decode_ok' => $jsonError === JSON_ERROR_NONE,
            'json_error' => $jsonErrorMsg,
            'element_count' => is_array($elements) ? count($elements) : 0,
            'php_memory_limit' => ini_get('memory_limit'),
            'php_memory_usage_mb' => round(memory_get_usage(true) / 1024 / 1024, 2),
        ];
        
        if (is_array($elements)) {
            // Check for circular parent refs
            $parentMap = [];
            $orphans = [];
            $types = [];
            foreach ($elements as $el) {
                $id = $el['id'] ?? 0;
                $pid = $el['parent_id'] ?? null;
                $type = $el['type'] ?? 'unknown';
                $parentMap[$id] = $pid;
                $types[$type] = ($types[$type] ?? 0) + 1;
                
                // Check for elements with settings that contain invalid UTF-8
                // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
                $settingsJson = json_encode($el['settings'] ?? []);
                // phpcs:enable WordPress.WP.AlternativeFunctions
                if ($settingsJson === false) {
                    $report['corrupt_elements'][] = ['id' => $id, 'type' => $type, 'issue' => 'settings json_encode fails: ' . json_last_error_msg()];
                }
            }
            
            // Detect circular parent references
            foreach ($parentMap as $id => $pid) {
                if ($pid === null) continue;
                $visited = [$id];
                $current = $pid;
                $depth = 0;
                while ($current !== null && $depth < 50) {
                    if (in_array($current, $visited)) {
                        $report['circular_refs'][] = ['element' => $id, 'cycle_at' => $current];
                        break;
                    }
                    $visited[] = $current;
                    $current = $parentMap[$current] ?? null;
                    $depth++;
                }
            }
            
            $report['element_types'] = $types;
            
            // Try re-encoding
            $reEncoded = wp_json_encode($elements);
            $report['re_encode_ok'] = $reEncoded !== false;
            $report['re_encode_bytes'] = $reEncoded ? strlen($reEncoded) : 0;
        }
        
        return new \WP_REST_Response($report);
    }
    
    /**
     * POST /pages/{id}/repair — fix common data corruption issues
     */
    public function pageRepair(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId = (int) $request->get_param('id');
        $elements = \Instinctor\Services\PageService::getElements($pageId);
        
        if (empty($elements)) {
            return new \WP_REST_Response(['error' => 'Page has no elements'], 404);
        }
        
        $fixes = [];
        $originalCount = count($elements);
        
        // Fix 1: Remove elements with invalid settings
        foreach ($elements as $i => &$el) {
            $settings = $el['settings'] ?? [];
            if (is_string($settings)) {
                $decoded = json_decode($settings, true);
                if (is_array($decoded)) {
                    $el['settings'] = $decoded;
                    $fixes[] = 'Element ' . ($el['id'] ?? $i) . ': decoded string settings to array';
                } else {
                    $el['settings'] = [];
                    $fixes[] = 'Element ' . ($el['id'] ?? $i) . ': reset corrupt settings';
                }
            }
            
            // Fix invalid UTF-8 in all string values
            if (is_array($el['settings'])) {
                array_walk_recursive($el['settings'], function(&$val) {
                    if (is_string($val)) {
                        $cleaned = mb_convert_encoding($val, 'UTF-8', 'UTF-8');
                        if ($cleaned !== $val) $val = $cleaned;
                    }
                });
            }
        }
        unset($el);
        
        // Fix 2: Break circular parent references
        $parentMap = [];
        foreach ($elements as $el) {
            $parentMap[$el['id'] ?? 0] = $el['parent_id'] ?? null;
        }
        foreach ($elements as &$el) {
            $id = $el['id'] ?? 0;
            $pid = $el['parent_id'] ?? null;
            if ($pid === null) continue;
            $visited = [$id];
            $current = $pid;
            $depth = 0;
            while ($current !== null && $depth < 50) {
                if (in_array($current, $visited)) {
                    $el['parent_id'] = null;
                    $fixes[] = 'Element ' . $id . ': broke circular parent ref (was child of ' . $pid . ')';
                    break;
                }
                $visited[] = $current;
                $current = $parentMap[$current] ?? null;
                $depth++;
            }
        }
        unset($el);
        
        // Fix 3: Ensure all elements have required keys
        foreach ($elements as &$el) {
            if (!isset($el['id'])) { $fixes[] = 'Removed element without id'; continue; }
            if (!isset($el['type'])) { $el['type'] = 'text'; $fixes[] = 'Element ' . $el['id'] . ': set missing type to text'; }
            if (!isset($el['settings'])) { $el['settings'] = []; $fixes[] = 'Element ' . $el['id'] . ': initialized empty settings'; }
            if (!isset($el['sort_order'])) { $el['sort_order'] = 0; $fixes[] = 'Element ' . $el['id'] . ': set missing sort_order'; }
        }
        unset($el);
        
        // Remove elements without id
        $elements = array_values(array_filter($elements, function($el) { return isset($el['id']); }));
        
        // Try saving
        $json = wp_json_encode($elements);
        if ($json === false) {
            return new \WP_REST_Response(['error' => 'Still cannot encode after repair: ' . json_last_error_msg(), 'fixes' => $fixes], 500);
        }
        
        \Instinctor\Services\PageService::saveElements($pageId, $elements);
        
        return new \WP_REST_Response([
            'success' => true,
            'original_count' => $originalCount,
            'repaired_count' => count($elements),
            'fixes' => $fixes,
            'json_bytes' => strlen($json),
        ]);
    }

    private function findImpulseElement(int $elementId, int $pageId = 0): ?array
    {
        if ($pageId > 0) {
            // Guarantee the page row exists before reading elements.
            \Instinctor\Services\PageService::ensurePageRow($pageId);
            $elements = \Instinctor\Services\PageService::getElements($pageId);
            foreach ($elements as $index => $el) {
                if (isset($el['id']) && (int) $el['id'] === $elementId) {
                    return ['page_id' => $pageId, 'index' => $index, 'element' => $el];
                }
            }
            return null;
        }

        // Fallback: search all pages (legacy, slow — always pass page_id to avoid this)
        $pages = \Instinctor\Services\PageService::getAll();
        foreach ($pages as $page) {
            $elements = \Instinctor\Services\PageService::getElements((int) $page['id']);
            foreach ($elements as $index => $el) {
                if (isset($el['id']) && (int) $el['id'] === $elementId) {
                    return ['page_id' => (int) $page['id'], 'index' => $index, 'element' => $el];
                }
            }
        }
        return null;
    }

    /**
     * Apply a quick start template to a page
     */


    /**
     * GET /impulse/global-settings?type=header|footer
     */
    public function getGlobalSettings(\WP_REST_Request $request): \WP_REST_Response
    {
        $type = sanitize_text_field($request->get_param('type') ?? '');
        if (!in_array($type, ['header', 'footer'], true)) {
            return new \WP_REST_Response(['error' => 'Invalid type'], 400);
        }
        $key = 'instinctor_global_' . $type . '_settings';
        $settings = Config::get($key, null);
        if (!$settings) {
            return new \WP_REST_Response(['error' => 'No global ' . $type . ' saved yet'], 404);
        }
        if (is_string($settings)) {
            $settings = json_decode($settings, true) ?: [];
        }
        return new \WP_REST_Response(['settings' => $settings]);
    }

    /**
     * POST /impulse/global-settings  { type: 'header'|'footer', settings: {...} }
     */
    public function saveGlobalSettings(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params();
        $type = sanitize_text_field($data['type'] ?? '');
        if (!in_array($type, ['header', 'footer'], true)) {
            return new \WP_REST_Response(['error' => 'Invalid type'], 400);
        }
        $settings = $data['settings'] ?? [];
        if (!is_array($settings)) {
            return new \WP_REST_Response(['error' => 'Invalid settings'], 400);
        }
        $key = 'instinctor_global_' . $type . '_settings';
        Config::set($key, wp_json_encode($settings));
        return new \WP_REST_Response(['success' => true]);
    }

    /**
     * Create a global header or footer page
     */
    public function createGlobalPage(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params();
        $type = sanitize_text_field($data['type'] ?? '');

        if (!in_array($type, ['global_header', 'global_footer'], true)) {
            return new \WP_REST_Response(['error' => 'Invalid type'], 400);
        }

        // Check if already exists in our table
        $existing = \Instinctor\Services\PageService::getByType($type);
        if ($existing) {
            return new \WP_REST_Response(['id' => $existing['id'], 'exists' => true]);
        }

        $name = $type === 'global_header' ? 'Global Header' : 'Global Footer';
        $bgColor = $type === 'global_header' ? '#ffffff' : '#1d1d1f';

        $id = \Instinctor\Services\PageService::create([
            'name'   => $name,
            'slug'   => sanitize_title($type),
            'status' => 'published',
            'type'   => $type,
            'settings' => [
                'padding_top'     => 16,
                'padding_right'   => 24,
                'padding_bottom'  => 16,
                'padding_left'    => 24,
                'element_spacing' => 16,
                'max_width'       => 1200,
                'bg_color'        => $bgColor,
            ],
            'elements' => [],
        ]);

        if (!$id) {
            return new \WP_REST_Response(['error' => 'Failed to create global page'], 500);
        }

        return new \WP_REST_Response(['id' => $id, 'name' => $name, 'type' => $type]);
    }

    /**
     * GET /products/{id}/addons
     * Returns applicable add-ons for a product (product-specific + global).
     * Public read — used by frontend product card initialisation.
     */
    public function getProductAddonsApi(\WP_REST_Request $request): \WP_REST_Response
    {
        $productId = (int) $request->get_param('id');
        $addons    = \Instinctor\Services\ProductAddonService::getAllAddons($productId);
        return new \WP_REST_Response(['addons' => is_array($addons) ? $addons : []]);
    }

    /**
     * GET /products/{id}/volume-tiers
     * Returns applicable volume discount tiers for a product (product > category > global).
     */
    public function getProductVolumeTiers(\WP_REST_Request $request): \WP_REST_Response
    {
        $productId = (int) $request->get_param('id');

        // Mirror the lookup priority from VolumeDiscountService::getDiscountedPrice
        $tiers = \Instinctor\Services\VolumeDiscountService::getProductTiers($productId);

        if (empty($tiers)) {
            $pc = DB::prefix() . 'instinctor_product_categories';
            $categoryId = (int) DB::get_var(
                DB::prepare("SELECT category_id FROM {$pc} WHERE product_id = %d LIMIT 1", $productId)
            );
            if ($categoryId) {
                $tiers = \Instinctor\Services\VolumeDiscountService::getCategoryTiers($categoryId);
            }
        }

        if (empty($tiers)) {
            $tiers = \Instinctor\Services\VolumeDiscountService::getGlobalTiers();
        }

        return new \WP_REST_Response(['tiers' => $tiers]);
    }

    // ── SEO Engine handlers ──────────────────────────────────────────────────

    public function seoGetOverview(\WP_REST_Request $request): \WP_REST_Response
    {
        return new \WP_REST_Response(\Instinctor\Services\SEOService::getSiteOverview());
    }

    public function seoGetPages(\WP_REST_Request $request): \WP_REST_Response
    {
        return new \WP_REST_Response(\Instinctor\Services\SEOService::getAllPagesWithSEO());
    }

    public function seoGetPageData(\WP_REST_Request $request): \WP_REST_Response
    {
        $id  = (int) $request->get_param('id');
        $seo = \Instinctor\Services\SEOService::getPageSEO($id);
        return new \WP_REST_Response([
            'seo'       => $seo,
            'title'     => \Instinctor\Services\SEOService::getPageTitle($id),
            'desc'      => \Instinctor\Services\SEOService::getPageDescription($id),
            'permalink' => \Instinctor\Services\PageService::getPageUrl($id),
            'slug'      => ($p = \Instinctor\Services\PageService::getById($id)) ? $p['slug'] : '',
        ]);
    }

    /**
     * GET /seo/pages/{id}/pagespeed?strategy=mobile|desktop&refresh=1
     *
     * Returns the cached run by default so opening the panel is free; only an
     * explicit refresh spends a Lighthouse render. `pending` tells the panel to
     * offer the button rather than render an empty result.
     */
    public function seoPageSpeed(\WP_REST_Request $request): \WP_REST_Response
    {
        $id       = (int) $request->get_param('id');
        $strategy = $request->get_param('strategy') === 'desktop' ? 'desktop' : 'mobile';
        $refresh  = (string) $request->get_param('refresh') === '1';
        $url      = (string) \Instinctor\Services\PageService::getPageUrl($id);

        if ($url === '') {
            return new \WP_REST_Response(['ok' => false, 'error' => 'This page has no public URL yet.'], 200);
        }
        if (!$refresh) {
            $hit = \Instinctor\Services\PageSpeedService::cached($url, $strategy);
            if ($hit === null) {
                return new \WP_REST_Response(['ok' => false, 'pending' => true, 'url' => $url], 200);
            }
            $hit['cached'] = true;
            return new \WP_REST_Response($hit, 200);
        }
        return new \WP_REST_Response(
            \Instinctor\Services\PageSpeedService::check($url, $strategy, true),
            200
        );
    }

    public function seoSavePageData(\WP_REST_Request $request): \WP_REST_Response
    {
        $id   = (int) $request->get_param('id');
        $data = $request->get_json_params() ?: [];
        $ok   = \Instinctor\Services\SEOService::savePageSEO($id, $data);
        if (!$ok) return new \WP_REST_Response(['error' => 'Save failed'], 500);
        $analysis = \Instinctor\Services\SEOService::analyzePage($id);
        return new \WP_REST_Response(['success' => true, 'score' => $analysis['score'], 'grade' => $analysis['grade'], 'checks' => $analysis['checks']]);
    }

    public function seoAnalyzePage(\WP_REST_Request $request): \WP_REST_Response
    {
        $id = (int) $request->get_param('id');
        return new \WP_REST_Response(\Instinctor\Services\SEOService::analyzePage($id));
    }

    public function seoGetRedirects(\WP_REST_Request $request): \WP_REST_Response
    {
        return new \WP_REST_Response(\Instinctor\Services\SEOService::getRedirects());
    }

    public function seoSaveRedirect(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params() ?: [];
        $id   = \Instinctor\Services\SEOService::saveRedirect($data);
        if (!$id) return new \WP_REST_Response(['error' => 'Save failed'], 500);
        return new \WP_REST_Response(['success' => true, 'id' => $id], 201);
    }

    public function seoUpdateRedirect(\WP_REST_Request $request): \WP_REST_Response
    {
        $id   = (int) $request->get_param('id');
        $data = $request->get_json_params() ?: [];
        $ok   = \Instinctor\Services\SEOService::saveRedirect($data, $id);
        return new \WP_REST_Response(['success' => (bool)$ok]);
    }

    public function seoDeleteRedirect(\WP_REST_Request $request): \WP_REST_Response
    {
        $id = (int) $request->get_param('id');
        return new \WP_REST_Response(['success' => \Instinctor\Services\SEOService::deleteRedirect($id)]);
    }

    public function seoGenerateSitemap(\WP_REST_Request $request): \WP_REST_Response
    {
        // Flush rewrite rules so WP serves the sitemap immediately
        flush_rewrite_rules(false);
        $url = home_url('/sitemap.xml');
        return new \WP_REST_Response(['success' => true, 'sitemap_url' => $url]);
    }

    public function seoSchemaPreview(\WP_REST_Request $request): \WP_REST_Response
    {
        // Build the actual schema that would be injected at runtime
        $schema = \Instinctor\Services\SEOService::buildLocalBusinessSchema();
        if (!$schema) {
            // Not enabled — return a template so user can see what it would look like
            $schema = [
                '@context' => 'https://schema.org',
                '@type'    => \Instinctor\Core\Config::get('instinctor_seo_localbusiness_data', [])['type'] ?? 'LocalBusiness',
                'name'     => \Instinctor\Services\SEOService::getSetting('site_name', get_bloginfo('name')),
                'url'      => home_url('/'),
                '_note'    => 'Schema is disabled. Enable Local Business schema in SEO › Site › Local Business Schema.',
            ];
        }
        return new \WP_REST_Response(['schema' => $schema]);
    }

    public function seoRobotsSuggest(\WP_REST_Request $request): \WP_REST_Response
    {
        $storeType   = \Instinctor\Core\Config::get('instinctor_seo_localbusiness_data', [])['type'] ?? 'ecommerce';
        $siteName    = \Instinctor\Services\SEOService::getSetting('site_name', get_bloginfo('name'));
        $hasProducts = (bool) \Instinctor\Core\DB::get_var("SELECT COUNT(*) FROM " . \Instinctor\Core\DB::prefix() . "instinctor_products WHERE status = 'published'");
        $pageCount   = (int) \Instinctor\Core\DB::get_var("SELECT COUNT(*) FROM " . \Instinctor\Core\DB::prefix() . "instinctor_pages WHERE status = 'published'");
        $isWP        = !defined('INSTINCTOR_ENV') || \INSTINCTOR_ENV === 'wordpress';

        $context = "Site: '{$siteName}' ({$storeType}). Published pages: {$pageCount}."
            . ($hasProducts ? " Has product catalogue." : "")
            . ($isWP ? " Running on WordPress." : " Standalone (non-WordPress).");

        $prompt = "Generate optimized robots.txt Disallow rules for this site.\n"
            . "Context: {$context}\n\n"
            . "Include rules to:\n"
            . "1. Block AI training crawlers (GPTBot, ClaudeBot, Google-Extended, CCBot, Bytespider, Applebot-Extended)\n"
            . "2. Block admin and internal paths (/wp-admin, /wp-login, /cart, /checkout, /account, /api, /instinctor-admin)\n"
            . "3. Block search/filter query strings that generate duplicate content\n"
            . "4. Block internal utility paths that waste crawl budget\n"
            . ($hasProducts ? "5. Allow product and shop pages for Googlebot.\n" : "")
            . "\nReturn ONLY raw robots.txt directives. No explanation. No User-agent: * header (already present). Just Disallow/Allow lines with short inline comments.";

        $result = \Instinctor\Services\AIContentService::generateContent($prompt, 500);
        if (is_wp_error($result)) {
            return new \WP_REST_Response(['error' => $result->get_error_message()], 500);
        }
        return new \WP_REST_Response(['robots_rules' => trim($result)]);
    }

    public function seoGetSettings(\WP_REST_Request $request): \WP_REST_Response
    {
        return new \WP_REST_Response(\Instinctor\Services\SEOService::getGlobalSettings());
    }

    public function seoSearchPages(\WP_REST_Request $request): \WP_REST_Response
    {
        $q = sanitize_text_field($request->get_param('q') ?? '');
        return new \WP_REST_Response(\Instinctor\Services\SEOService::searchPages($q));
    }

    public function seoSaveSettings(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params() ?: [];
        \Instinctor\Services\SEOService::saveGlobalSettings($data);
        return new \WP_REST_Response(['success' => true]);
    }

    public function seoGetRobots(\WP_REST_Request $request): \WP_REST_Response
    {
        return new \WP_REST_Response(['content' => \Instinctor\Services\SEOService::generateRobotsTxt()]);
    }

    /**
     * POST /seo/ai-batch-meta
     * Generates meta title + description for a single page in a batch queue.
     * JS calls this once per page, sequentially, to avoid relay timeouts.
     */
    public function seoAIBatchMeta(\WP_REST_Request $request): \WP_REST_Response
    {
        $data    = $request->get_json_params() ?: [];
        $postId  = (int)($data['post_id'] ?? 0);
        $fields  = $data['fields'] ?? ['meta_title', 'meta_description'];
        if (!$postId) return new \WP_REST_Response(['error' => 'Missing post_id'], 400);

        $post      = \Instinctor\Services\PageService::getById($postId);
        $excerpt   = substr(\Instinctor\Services\SEOService::getPageDescription($postId), 0, 600);
        $siteName  = \Instinctor\Services\SEOService::getSetting('site_name', get_bloginfo('name'));
        $storeName = \Instinctor\Core\Config::get('instinctor_store_name', $siteName);
        $storeDesc = \Instinctor\Core\Config::get('instinctor_store_description', '');
        $pageName  = $post ? $post['name'] : '';
        $keyword   = sanitize_text_field($data['keyword'] ?? (\Instinctor\Services\SEOService::getPageSEO($postId)['focus_keyword'] ?? ''));

        $context = "Store: {$storeName}" . ($storeDesc ? ".\nStore description: {$storeDesc}" : '') . ".\n"
            . "Page: \"{$pageName}\"\n"
            . ($keyword ? "Focus keyword: \"{$keyword}\"\n" : '')
            . ($excerpt ? "Page content: " . $excerpt . "\n" : '');

        $result = [];

        if (in_array('meta_title', $fields, true)) {
            $p = $context
                . "Write an SEO meta title. Rules: 50-60 chars, include keyword naturally, compelling for humans. "
                . "Return ONLY the title text, nothing else.";
            $t = \Instinctor\Services\AIContentService::generateContent($p, 100);
            $result['meta_title'] = is_wp_error($t) ? null : trim($t, " \"'");
        }

        if (in_array('meta_description', $fields, true)) {
            $p = $context
                . "Write an SEO meta description. Rules: 120-155 chars, include keyword, compelling CTA, no quotes. "
                . "Return ONLY the description text, nothing else.";
            $d = \Instinctor\Services\AIContentService::generateContent($p, 200);
            $result['meta_description'] = is_wp_error($d) ? null : trim($d, " \"'");
        }

        // Auto-save generated fields
        if (!empty(array_filter($result, fn($v) => $v !== null))) {
            $existing = \Instinctor\Services\SEOService::getPageSEO($postId);
            $save = $existing;
            foreach ($result as $k => $v) { if ($v !== null) $save[$k] = $v; }
            \Instinctor\Services\SEOService::savePageSEO($postId, $save);
        }

        return new \WP_REST_Response(['post_id' => $postId, 'generated' => $result]);
    }

    public function seoAIGenerate(\WP_REST_Request $request): \WP_REST_Response
    {
        $data    = $request->get_json_params() ?: [];
        $postId  = (int)($data['post_id']  ?? 0);
        $field   = sanitize_text_field($data['field']   ?? '');
        $keyword = sanitize_text_field($data['keyword'] ?? '');

        if (!$postId || !in_array($field, ['meta_title', 'meta_description'], true)) {
            return new \WP_REST_Response(['error' => 'Invalid params'], 400);
        }

        $post     = \Instinctor\Services\PageService::getById($postId);
        $excerpt  = \Instinctor\Services\SEOService::getPageDescription($postId);
        $excerpt  = substr($excerpt, 0, 600);
        $siteName = \Instinctor\Services\SEOService::getSetting('site_name', get_bloginfo('name'));
        $pageName = $post ? $post['name'] : '';

        if ($field === 'meta_title') {
            $prompt = "Write an SEO meta title for a web page.\n"
                . "Page name: \"{$pageName}\"\nSite: \"{$siteName}\"\n"
                . "Page content excerpt: " . $excerpt . "\n"
                . ($keyword ? "Focus keyword: \"{$keyword}\"\n" : '')
                . "Rules: 50–60 characters, include keyword naturally, compelling for humans.\n"
                . "Return ONLY the title text, nothing else.";
        } else {
            $prompt = "Write an SEO meta description for a web page.\n"
                . "Page name: \"{$pageName}\"\nSite: \"{$siteName}\"\n"
                . "Page content excerpt: " . $excerpt . "\n"
                . ($keyword ? "Focus keyword: \"{$keyword}\"\n" : '')
                . "Rules: 120–155 characters, include keyword, compelling CTA, no quotes.\n"
                . "Return ONLY the description text, nothing else.";
        }

        $result = \Instinctor\Services\AIContentService::generateContent($prompt, 200);
        if (is_wp_error($result)) return new \WP_REST_Response(['error' => $result->get_error_message()], 500);
        return new \WP_REST_Response(['text' => trim($result)]);
    }

    /**
     * POST /seo/images/{id}/generate-alt
     * Accepts base64 image from JS (already resized client-side to ~500px).
     * Calls vision relay, returns generated alt text.
     */
    public function seoGenerateImageAlt(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!\instinctor_has_pro()) {
            return new \WP_REST_Response(['error' => 'Pro feature'], 403);
        }

        $data      = $request->get_json_params() ?: [];
        $imageUrl  = esc_url_raw($data['image_url']  ?? '');
        $imageBase64 = sanitize_text_field($data['image_data'] ?? '');
        $mimeType  = sanitize_text_field($data['mime_type'] ?? 'image/jpeg');

        // Site + page context for SEO-aware generation
        $siteContext = [
            'store_name'        => sanitize_text_field($data['store_name']        ?? \Instinctor\Core\Config::get('instinctor_store_name',        get_bloginfo('name'))),
            'store_description' => sanitize_textarea_field($data['store_description'] ?? \Instinctor\Core\Config::get('instinctor_store_description', get_bloginfo('description'))),
            'page_title'        => sanitize_text_field($data['page_title']        ?? ''),
            'page_description'  => sanitize_textarea_field($data['page_description']  ?? ''),
            'focus_keyword'     => sanitize_text_field($data['focus_keyword']     ?? ''),
            'page_slug'         => sanitize_text_field($data['page_slug']         ?? ''),
        ];

        // Prefer URL path: fetch server-side, resize to ≤500px, encode base64
        if ($imageUrl) {
            $fetched = wp_remote_get($imageUrl, ['timeout' => 15, 'sslverify' => false]);
            if (is_wp_error($fetched)) {
                return new \WP_REST_Response(['error' => 'Could not fetch image: ' . $fetched->get_error_message()], 400);
            }
            $rawBytes = wp_remote_retrieve_body($fetched);
            if (!$rawBytes) {
                return new \WP_REST_Response(['error' => 'Empty image response'], 400);
            }
            // Detect mime from Content-Type header
            $ct = wp_remote_retrieve_header($fetched, 'content-type');
            $mimeType = strtok($ct ?: 'image/jpeg', ';');

            // Resize via GD to ≤500px longest side
            $src = @imagecreatefromstring($rawBytes);
            if ($src) {
                $origW = imagesx($src);
                $origH = imagesy($src);
                $maxPx = 500;
                if ($origW > $maxPx || $origH > $maxPx) {
                    $scale = $maxPx / max($origW, $origH);
                    $newW  = max(1, (int)round($origW * $scale));
                    $newH  = max(1, (int)round($origH * $scale));
                    $dst   = imagecreatetruecolor($newW, $newH);
                    imagecopyresampled($dst, $src, 0, 0, 0, 0, $newW, $newH, $origW, $origH);
                    imagedestroy($src);
                    $src = $dst;
                }
                ob_start();
                imagejpeg($src, null, 85);
                $rawBytes = ob_get_clean();
                imagedestroy($src);
                $mimeType = 'image/jpeg';
            }

            $imageBase64 = base64_encode($rawBytes);
        }

        if (!$imageBase64) {
            return new \WP_REST_Response(['error' => 'No image data'], 400);
        }

        // Fetch filename + dimensions for text fallback
        $imgId    = (int) $request->get_param('id');
        $table    = \Instinctor\Core\DB::prefix() . 'instinctor_images';
        $imgRow   = \Instinctor\Core\DB::get_row(
            \Instinctor\Core\DB::prepare("SELECT filename, width, height FROM {$table} WHERE id = %d", $imgId),
            'ARRAY_A'
        );
        $filename = $imgRow['filename'] ?? '';
        $width    = (int) ($imgRow['width']  ?? 0);
        $height   = (int) ($imgRow['height'] ?? 0);

        $result = \Instinctor\Services\AIContentService::generateAltText($imageBase64, $mimeType, $filename, $width, $height, $siteContext);
        if (is_wp_error($result)) {
            return new \WP_REST_Response(['error' => $result->get_error_message()], 500);
        }

        return new \WP_REST_Response([
            'alt_text'    => $result['alt']   ?? '',
            'image_title' => $result['title'] ?? '',
        ]);
    }

    // ── SEO Image Optimization ────────────────────────────────────────────────

    /**
     * GET /seo/pages/{id}/images
     * Returns all library images on a page with derivative status + stretch warnings.
     * Used by the SEO workspace Page Images panel to populate the thumbnail strip.
     */
    public function seoGetPageImages(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId = (int) $request->get_param('id');
        if (!$pageId) return new \WP_REST_Response(['error' => 'Invalid page ID'], 400);

        $images = \Instinctor\Services\ImageLibraryService::getPageImagesStatus($pageId);
        return new \WP_REST_Response(['images' => $images]);
    }

    /**
     * POST /seo/pages/{id}/images/optimize
     * Creates d/t/m WebP derivatives for every library image on the page.
     * Skips upscaling. Returns per-image results + any stretched-image warnings.
     */
    public function seoOptimizePageImages(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId = (int) $request->get_param('id');
        if (!$pageId) return new \WP_REST_Response(['error' => 'Invalid page ID'], 400);

        $lib     = \Instinctor\Services\ImageLibraryService::instance();
        $dimMap  = $lib->calculatePageImageDimensions($pageId);

        if (empty($dimMap)) {
            return new \WP_REST_Response([
                'results' => [],
                'message' => 'No library images found on this page.',
            ]);
        }

        $table   = \Instinctor\Core\DB::prefix() . 'instinctor_images';
        $results = [];

        foreach ($dimMap as $imgId => $dims) {
            $row = \Instinctor\Core\DB::get_row(
                \Instinctor\Core\DB::prepare(
                    "SELECT * FROM {$table} WHERE id = %d AND parent_id IS NULL",
                    $imgId
                ),
                'ARRAY_A'
            );
            if (!$row) {
                $results[] = ['id' => $imgId, 'status' => 'skipped', 'reason' => 'not_in_library'];
                continue;
            }

            $origW = (int) $row['width'];

            // Identify stretched roles (target wider than original)
            $stretched = [];
            $validDims = [];
            foreach ($dims as $role => $wh) {
                $targetW = $wh[0] ?? 0;
                if ($targetW > 0 && $origW > 0 && $targetW > $origW) {
                    $stretched[$role] = $targetW;
                } else {
                    $validDims[$role] = $wh;
                }
            }

            // All roles stretched — flag and skip
            if (empty($validDims)) {
                $results[] = [
                    'id'       => $imgId,
                    'status'   => 'stretched',
                    'title'    => $row['title'],
                    'orig_w'   => $origW,
                    'needs'    => max(array_column(array_values($dims), 0)),
                ];
                continue;
            }

            $derivs = $lib->createDerivatives($row, $validDims);

            $results[] = [
                'id'          => $imgId,
                'status'      => 'optimized',
                'title'       => $row['title'],
                'orig_url'    => \Instinctor\Core\Storage::url($row['rel_path']),
                'derivatives' => $derivs,
                'stretched'   => $stretched,
            ];
        }

        return new \WP_REST_Response(['results' => $results]);
    }

    /**
     * POST /seo/images/{id}/restore
     * Deletes all derivatives for an original image.
     * The block will fall back to the original URL (handled by frontend).
     */
    public function seoRestoreImage(\WP_REST_Request $request): \WP_REST_Response
    {
        $imgId = (int) $request->get_param('id');
        if (!$imgId) return new \WP_REST_Response(['error' => 'Invalid image ID'], 400);

        $table  = \Instinctor\Core\DB::prefix() . 'instinctor_images';
        $origin = \Instinctor\Core\DB::get_row(
            \Instinctor\Core\DB::prepare(
                "SELECT * FROM {$table} WHERE id = %d AND parent_id IS NULL",
                $imgId
            ),
            'ARRAY_A'
        );
        if (!$origin) return new \WP_REST_Response(['error' => 'Original not found'], 404);

        $derivs = \Instinctor\Core\DB::get_results(
            \Instinctor\Core\DB::prepare(
                "SELECT * FROM {$table} WHERE parent_id = %d",
                $imgId
            ),
            'ARRAY_A'
        ) ?: [];

        foreach ($derivs as $d) {
            \Instinctor\Core\Storage::delete($d['rel_path'] ?? '');
            \Instinctor\Core\DB::delete($table, ['id' => (int) $d['id']]);
        }

        \Instinctor\Core\DB::update($table, ['optimized' => 0, 'bytes_saved' => 0], ['id' => $imgId]);

        return new \WP_REST_Response([
            'success'      => true,
            'original_url' => \Instinctor\Core\Storage::url($origin['rel_path']),
        ]);
    }

    // ── Currencies ────────────────────────────────────────────────────────────

    public function getCurrencies(\WP_REST_Request $request): \WP_REST_Response
    {
        $currencies  = \Instinctor\Services\CurrencyService::getAllCurrencies();
        $allCodes    = \Instinctor\Services\CurrencyService::getAvailableCurrencyCodes();
        $default     = \Instinctor\Services\CurrencyService::getDefaultCurrency();
        return new \WP_REST_Response([
            'currencies' => $currencies,
            'available'  => array_keys($allCodes),
            'default'    => $default ? $default->code : '',
        ]);
    }

    public function saveCurrency(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params() ?: [];
        $code = strtoupper(trim($data['code'] ?? ''));
        if (strlen($code) !== 3) {
            return new \WP_REST_Response(['error' => 'Invalid currency code'], 400);
        }
        $id = \Instinctor\Services\CurrencyService::saveCurrency($data);
        if (!$id) {
            return new \WP_REST_Response(['error' => 'Failed to save currency'], 500);
        }
        return new \WP_REST_Response(['success' => true, 'id' => $id]);
    }

    public function deleteCurrency(\WP_REST_Request $request): \WP_REST_Response
    {
        $id     = (int) $request->get_param('id');
        $result = \Instinctor\Services\CurrencyService::deleteCurrency($id);
        if (!$result) {
            return new \WP_REST_Response(['error' => 'Cannot delete default currency'], 403);
        }
        return new \WP_REST_Response(['success' => true]);
    }

    public function setDefaultCurrency(\WP_REST_Request $request): \WP_REST_Response
    {
        $id    = (int) $request->get_param('id');
        $table = \Instinctor\Core\DB::prefix() . 'instinctor_currencies';
        $row   = \Instinctor\Core\DB::get_row(\Instinctor\Core\DB::prepare(
            "SELECT id, code FROM `{$table}` WHERE id = %d", $id
        ));
        if (!$row) {
            return new \WP_REST_Response(['error' => 'Currency not found'], 404);
        }
        \Instinctor\Core\DB::update($table, ['is_default' => 0], ['is_default' => 1]);
        \Instinctor\Core\DB::update($table, ['is_default' => 1, 'enabled' => 1], ['id' => $id]);
        return new \WP_REST_Response(['success' => true]);
    }

    // ── Team ──────────────────────────────────────────────────────────────────

    public function getTeam(\WP_REST_Request $request): \WP_REST_Response
    {
        $rm    = \Instinctor\Core\RoleManager::instance();
        $team  = $rm->getTeamMembers();
        $roles = $rm->getAssignableRoles(); // workspace labels
        return new \WP_REST_Response(['team' => $team, 'roles' => $roles, 'workspaces' => \Instinctor\Core\RoleManager::WORKSPACE_LABELS]);
    }

    public function inviteTeamMember(\WP_REST_Request $request): \WP_REST_Response
    {
        $data       = $request->get_json_params() ?: [];
        $email      = sanitize_email($data['email'] ?? '');
        $workspaces = isset($data['workspaces']) && is_array($data['workspaces'])
            ? array_map('sanitize_text_field', $data['workspaces'])
            : [];
        $role       = sanitize_text_field($data['role'] ?? ''); // legacy fallback

        if (!$email) {
            return new \WP_REST_Response(['error' => 'Email is required.'], 400);
        }

        if (empty($workspaces) && !$role) {
            return new \WP_REST_Response(['error' => 'Select at least one workspace.'], 400);
        }

        // WP mode: use existing WP user management
        if (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV !== 'standalone') {
            if (!function_exists('get_user_by')) {
                return new \WP_REST_Response(['error' => 'WP user functions unavailable.'], 500);
            }
            $existing = get_user_by('email', $email);
            if ($existing) {
                $rm = \Instinctor\Core\RoleManager::instance();
                if (!empty($workspaces)) {
                    $rm->setTeamMemberAccess((int) $existing->ID, $workspaces);
                } else {
                    $rm->setTeamMemberRole((int) $existing->ID, $role);
                }
                return new \WP_REST_Response(['success' => true, 'message' => 'Access updated.']);
            }
            return new \WP_REST_Response(['error' => 'User not found in WordPress. Add them as a WP user first.'], 404);
        }

        // Standalone mode: full invite flow
        $user        = \Instinctor\Core\Auth::user();
        $inviterName = $user ? ($user->display_name ?? '') : '';
        // Map workspaces to a role for standalone
        if (!$role && !empty($workspaces)) {
            if (count($workspaces) >= count(\Instinctor\Core\RoleManager::WORKSPACE_CAPS)) {
                $role = 'admin';
            } elseif (in_array('store', $workspaces, true)) {
                $role = 'manager';
            } else {
                $role = 'agent';
            }
        }
        $result = \Instinctor\Core\UserService::invite($email, $role ?: 'agent', $inviterName);

        if (!$result['ok']) {
            return new \WP_REST_Response(['error' => $result['error']], 400);
        }
        return new \WP_REST_Response(['success' => true, 'message' => 'Invitation sent.']);
    }

    public function updateTeamMember(\WP_REST_Request $request): \WP_REST_Response
    {
        $userId = (int) $request->get_param('id');
        $data   = $request->get_json_params() ?: [];
        if (!$userId) {
            return new \WP_REST_Response(['error' => 'Invalid user ID.'], 400);
        }
        $rm     = \Instinctor\Core\RoleManager::instance();
        $result = $rm->updateTeamMember($userId, $data);
        if (!$result) {
            return new \WP_REST_Response(['error' => 'Cannot update this user.'], 403);
        }
        return new \WP_REST_Response(['success' => true]);
    }

    public function setTeamRole(\WP_REST_Request $request): \WP_REST_Response
    {
        $data   = $request->get_json_params() ?: [];
        $userId = (int) $request->get_param('id');

        if (!$userId) {
            return new \WP_REST_Response(['error' => 'Invalid request.'], 400);
        }

        $rm = \Instinctor\Core\RoleManager::instance();

        // New: accept workspaces array
        if (isset($data['workspaces']) && is_array($data['workspaces'])) {
            $workspaces = array_map('sanitize_text_field', $data['workspaces']);
            $result = $rm->setTeamMemberAccess($userId, $workspaces);
        } else {
            // Legacy: accept role string
            $role = sanitize_text_field($data['role'] ?? '');
            if (!$role) {
                return new \WP_REST_Response(['error' => 'Invalid request.'], 400);
            }
            $result = $rm->setTeamMemberRole($userId, $role);
        }

        if (!$result) {
            return new \WP_REST_Response(['error' => 'Cannot modify this user\'s access.'], 403);
        }
        return new \WP_REST_Response(['success' => true]);
    }

    public function revokeTeamAccess(\WP_REST_Request $request): \WP_REST_Response
    {
        $userId = (int) $request->get_param('id');
        if (!$userId) {
            return new \WP_REST_Response(['error' => 'Invalid user ID.'], 400);
        }

        $rm     = \Instinctor\Core\RoleManager::instance();
        $result = $rm->revokeAccess($userId);

        if (!$result) {
            return new \WP_REST_Response(['error' => 'Cannot revoke access for this user.'], 403);
        }
        return new \WP_REST_Response(['success' => true]);
    }

    public function submitFeedback(\WP_REST_Request $request): \WP_REST_Response
    {
        $message = sanitize_textarea_field($request->get_param('message') ?? '');
        if (empty($message)) {
            return new \WP_REST_Response(['error' => 'Empty message'], 400);
        }

        $entry = [
            'message'   => $message,
            'block'     => sanitize_text_field($request->get_param('block') ?? ''),
            'page_id'   => intval($request->get_param('page_id') ?? 0),
            'timestamp' => gmdate('Y-m-d H:i:s'),
            'user'      => get_current_user_id(),
        ];

        $feedback = get_option('instinctor_beta_feedback', []);
        $feedback[] = $entry;
        update_option('instinctor_beta_feedback', $feedback, false);

        return new \WP_REST_Response(['success' => true]);
    }

    // ─── Page revisions ──────────────────────────────────────────────────────

    public function listPageRevisions(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId = (int) $request['id'];
        $revs   = \Instinctor\Services\PageService::listRevisions($pageId);
        return new \WP_REST_Response(['revisions' => $revs]);
    }

    public function restorePageRevision(\WP_REST_Request $request): \WP_REST_Response
    {
        $pageId = (int) $request['id'];
        $revId  = (int) $request['rev'];
        $ok = \Instinctor\Services\PageService::restoreRevision($pageId, $revId);
        if (!$ok) {
            return new \WP_REST_Response(['error' => 'Revision not found'], 404);
        }
        return new \WP_REST_Response(['success' => true]);
    }

    // listPagesByDesigner removed 2.1.413 — marketplace teardown.

    // ─── Library: sections (per-user local) ──────────────────────────────────

    public function listLibrarySections(\WP_REST_Request $request): \WP_REST_Response
    {
        $rows = \Instinctor\Services\LibraryService::listMine('section');
        return new \WP_REST_Response(['items' => $rows]);
    }

    public function createLibrarySection(\WP_REST_Request $request): \WP_REST_Response
    {
        $params      = $request->get_json_params() ?: $request->get_params();
        $name        = trim((string) ($params['name']         ?? ''));
        $elementTree = $params['element_tree'] ?? [];
        $htmlPreview = (string) ($params['html_preview'] ?? '');
        $category    = (string) ($params['category']     ?? 'other');
        $tags        = (array)  ($params['tags']         ?? []);

        if ($name === '') {
            return new \WP_REST_Response(['error' => 'Name required'], 400);
        }
        if (!is_array($elementTree) || empty($elementTree)) {
            return new \WP_REST_Response(['error' => 'element_tree required'], 400);
        }

        $id = \Instinctor\Services\LibraryService::saveSection(
            $name, $elementTree, $htmlPreview, $category, $tags
        );
        if (!$id) {
            return new \WP_REST_Response(['error' => 'Save failed'], 500);
        }
        return new \WP_REST_Response(['success' => true, 'id' => $id]);
    }

    public function updateLibrarySection(\WP_REST_Request $request): \WP_REST_Response
    {
        $id     = (int) $request['id'];
        $params = $request->get_json_params() ?: $request->get_params();
        $ok     = \Instinctor\Services\LibraryService::update('section', $id, $params);
        if (!$ok) {
            return new \WP_REST_Response(['error' => 'Not found or nothing to update'], 404);
        }
        return new \WP_REST_Response(['success' => true]);
    }

    public function deleteLibrarySection(\WP_REST_Request $request): \WP_REST_Response
    {
        $id = (int) $request['id'];
        $ok = \Instinctor\Services\LibraryService::delete('section', $id);
        if (!$ok) {
            return new \WP_REST_Response(['error' => 'Not found'], 404);
        }
        return new \WP_REST_Response(['success' => true]);
    }

    // ─── Library: scenes (per-user local) ────────────────────────────────────

    public function listLibraryScenes(\WP_REST_Request $request): \WP_REST_Response
    {
        $rows = \Instinctor\Services\LibraryService::listMine('scene');
        return new \WP_REST_Response(['items' => $rows]);
    }

    /**
     * Save a scene-mode page into the user's scene library.
     *
     * Input: { name, page_id }. Server reads the source page's elements +
     * settings and stores them alongside the page's existing thumbnail URL
     * (reused as the tile preview — avoids generating a fresh screenshot).
     */
    public function createLibraryScene(\WP_REST_Request $request): \WP_REST_Response
    {
        $params = $request->get_json_params() ?: $request->get_params();
        $name   = trim((string) ($params['name']    ?? ''));
        $pageId = (int)          ($params['page_id'] ?? 0);

        if ($name === '' || !$pageId) {
            return new \WP_REST_Response(['error' => 'name and page_id required'], 400);
        }

        $page = \Instinctor\Services\PageService::getById($pageId);
        if (!$page) {
            return new \WP_REST_Response(['error' => 'Page not found'], 404);
        }

        $settings = is_array($page['settings']) ? $page['settings'] : [];
        $elements = is_array($page['elements']) ? $page['elements'] : [];

        // Only scene-mode pages qualify. Scene mode is stored in page settings
        // as `screen_mode` (legacy naming — the UI-facing term is "scene").
        $isScene = true; // every page is a screen sequence
        if (!$isScene) {
            return new \WP_REST_Response(['error' => 'Only scene-mode pages can be saved as scenes'], 400);
        }

        // Store the source thumbnail URL in html_preview — the column type is
        // longtext but a short URL fits fine and the tile renderer branches on
        // the value (URL → <img>, HTML → iframe srcdoc).
        $thumbnailUrl = is_string($page['thumbnail'] ?? null) ? $page['thumbnail'] : '';

        $id = \Instinctor\Services\LibraryService::saveScene(
            $name, $elements, $settings, $thumbnailUrl
        );
        if (!$id) {
            return new \WP_REST_Response(['error' => 'Save failed'], 500);
        }
        return new \WP_REST_Response(['success' => true, 'id' => $id]);
    }

    public function updateLibraryScene(\WP_REST_Request $request): \WP_REST_Response
    {
        $id     = (int) $request['id'];
        $params = $request->get_json_params() ?: $request->get_params();
        $ok     = \Instinctor\Services\LibraryService::update('scene', $id, $params);
        if (!$ok) {
            return new \WP_REST_Response(['error' => 'Not found or nothing to update'], 404);
        }
        return new \WP_REST_Response(['success' => true]);
    }

    public function deleteLibraryScene(\WP_REST_Request $request): \WP_REST_Response
    {
        $id = (int) $request['id'];
        $ok = \Instinctor\Services\LibraryService::delete('scene', $id);
        if (!$ok) {
            return new \WP_REST_Response(['error' => 'Not found'], 404);
        }
        return new \WP_REST_Response(['success' => true]);
    }

    /**
     * Create a new page from a saved scene. Returns the new page id.
     *
     * Delegates to PageService::importPage with a synthesized export-shape
     * array so the same ID-remapping + slug-uniqueness logic is reused for
     * both user-triggered imports and library insertions.
     */
    // ─── Library: Platform global (proxied, read-only for users) ─────────────

    public function listPlatformSections(\WP_REST_Request $request): \WP_REST_Response
    {
        return new \WP_REST_Response([
            'items' => \Instinctor\Services\LibraryService::fetchPlatformItems('section'),
        ]);
    }

    public function listPlatformScenes(\WP_REST_Request $request): \WP_REST_Response
    {
        return new \WP_REST_Response([
            'items' => \Instinctor\Services\LibraryService::fetchPlatformItems('scene'),
        ]);
    }

    /**
     * POST /library/platform/upload
     * Body: { kind: 'section'|'scene', source_id?: int, name?, ... }
     *
     * If `source_id` is given, copies the matching local item's data; otherwise
     * consumes name + element_tree etc. from the body directly. Only the admin
     * install passes the permission gate.
     */
    public function uploadToPlatformLibrary(\WP_REST_Request $request): \WP_REST_Response
    {
        $params = $request->get_json_params() ?: $request->get_params();
        $kind   = ($params['kind'] ?? 'section') === 'scene' ? 'scene' : 'section';

        if (!empty($params['source_id'])) {
            $row = \Instinctor\Services\LibraryService::getMine($kind, (int) $params['source_id']);
            if (!$row) {
                return new \WP_REST_Response(['error' => 'Source item not found'], 404);
            }
            $data = [
                'name'          => $params['name']          ?? $row['name'],
                'category'      => $params['category']      ?? $row['category'],
                'tags'          => $params['tags']          ?? $row['tags'],
                'element_tree'  => $row['element_tree']     ?? [],
                'html_preview'  => $row['html_preview']     ?? '',
                'page_settings' => $row['page_settings']    ?? [],
            ];
        } else {
            $data = [
                'name'          => (string) ($params['name']          ?? ''),
                'category'      => (string) ($params['category']      ?? 'other'),
                'tags'          => (array)  ($params['tags']          ?? []),
                'element_tree'  => $params['element_tree']  ?? [],
                'html_preview'  => (string) ($params['html_preview'] ?? ''),
                'page_settings' => $params['page_settings'] ?? [],
            ];
        }

        if (trim((string) $data['name']) === '') {
            return new \WP_REST_Response(['error' => 'Name required'], 400);
        }
        if (!is_array($data['element_tree']) || empty($data['element_tree'])) {
            return new \WP_REST_Response(['error' => 'element_tree required'], 400);
        }

        $result = \Instinctor\Services\LibraryService::uploadToPlatform($kind, $data);
        if (empty($result['success'])) {
            return new \WP_REST_Response(['error' => $result['error'] ?? 'Upload failed'], 500);
        }

        // Successful upload — promote the item out of "My Sections" so it
        // doesn't show up twice (once locally, once via the Platform list).
        // Only when the upload was sourced from a local row.
        if (!empty($params['source_id'])) {
            \Instinctor\Services\LibraryService::delete($kind, (int) $params['source_id']);
        }

        return new \WP_REST_Response([
            'success'      => true,
            'platform_id'  => $result['id'] ?? 0,
            'local_removed' => !empty($params['source_id']),
        ]);
    }

    public function insertLibraryScene(\WP_REST_Request $request): \WP_REST_Response
    {
        $id = (int) $request['id'];
        $scene = \Instinctor\Services\LibraryService::getMine('scene', $id);
        if (!$scene) {
            return new \WP_REST_Response(['error' => 'Not found'], 404);
        }

        $params = $request->get_json_params() ?: $request->get_params();
        $name   = trim((string) ($params['name'] ?? '')) ?: ($scene['name'] ?: 'Untitled Scene');

        $exportData = [
            'instinctor_export' => true,
            'version'           => defined('INSTINCTOR_VERSION') ? INSTINCTOR_VERSION : '0',
            'page' => [
                'name'     => $name,
                'type'     => 'page',
                'settings' => $scene['page_settings'] ?? [],
            ],
            'elements' => $scene['element_tree'] ?? [],
        ];

        $newPageId = \Instinctor\Services\PageService::importPage($exportData, $name);
        if (!$newPageId) {
            return new \WP_REST_Response(['error' => 'Import failed'], 500);
        }
        return new \WP_REST_Response(['success' => true, 'page_id' => $newPageId]);
    }

    /**
     * GET /solo/slug-available?slug=foo
     *
     * Lightweight public check used by the /start landing form. Returns
     * {available: bool, reason?: string}. A slug is available if:
     *   - format is OK (lowercase, a-z0-9-, 2-60 chars),
     *   - it isn't in the route-reserved list,
     *   - no existing page under the instinctor-team designer owns it.
     *
     * Not authenticated — slug names are not sensitive.
     *
     * @since 2.1.238
     */
    /**
     * Analytics dashboard — one round-trip returns totals, the views-over-time
     * series, top referrers, top UTM sources, direct traffic, and device +
     * country breakdowns for the chosen range. Pure read; no side effects.
     * AnalyticsService does the SQL.
     *
     * @param \WP_REST_Request $request
     *        range   today | 7d | 30d | 90d | all  (default 7d)
     *        page_id optional int — when set, viewsOverTime is filtered to
     *                this page; the other panels stay site-wide.
     */
    /**
     * Count a page's CURRENT screens (root-level sections). Lets the exit-by-
     * screen panel label/clamp so stale indices from a previous, longer layout
     * don't masquerade as real screens.
     */
    /**
     * Resolve a recorded URL path to its page NAME (e.g. "/" -> the front
     * page's name, "/instinctor-2" -> "Instinctor"). Empty string when the
     * path doesn't map to a page (privacy/terms/feed/etc) so the caller
     * falls back to showing the raw path.
     */
    private static function resolvePathLabel(string $path): string
    {
        try {
            $p = trim($path, '/');
            if ($p === '') {
                $fid = (int) \Instinctor\Core\Config::get('instinctor_front_page_id', 0);
                if (!$fid) $fid = (int) \Instinctor\Core\Config::get('page_on_front', 0);
                if ($fid) { $pg = \Instinctor\Services\PageService::getById($fid); return $pg ? (string) ($pg['name'] ?? '') : ''; }
                return '';
            }
            $pg = \Instinctor\Services\PageService::getByPublicSlug($p);
            if (!$pg && ctype_digit($p)) $pg = \Instinctor\Services\PageService::getById((int) $p);
            if ($pg) return (string) ($pg['name'] ?? '');
            // Routed templates (no page row) tracked since 2026-07-29: give the
            // funnel entries a readable name instead of a bare path.
            $routed = [
                'make-a-site' => 'Make a Site (funnel)',
                'demo'        => 'Demo',
            ];
            $seg = strtolower(explode('/', $p)[0]);
            return $routed[$seg] ?? '';
        } catch (\Throwable $e) {
            return '';
        }
    }

    private static function countPageScreens(int $pageId): int
    {
        try {
            $els = \Instinctor\Services\PageService::getElements($pageId);
        } catch (\Throwable $e) {
            return 0;
        }
        $n = 0;
        foreach ((array) $els as $_el) {
            if (is_array($_el) && (($_el['type'] ?? '') === 'section')) $n++;
        }
        return $n;
    }

    public function analyticsSummary(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!class_exists('\\Instinctor\\Services\\AnalyticsService')) {
            return new \WP_REST_Response(['error' => 'analytics_not_available'], 500);
        }
        $rangeRaw = strtolower((string) $request->get_param('range'));
        $pageId   = (int) $request->get_param('page_id') ?: null;
        $exitPageId = (int) $request->get_param('exit_page_id') ?: null;

        $today = gmdate('Y-m-d');
        $from  = $today;
        $label = 'Today';
        switch ($rangeRaw) {
            case 'today':
                $from = $today; $label = 'Today'; break;
            case '30d':
                $from = gmdate('Y-m-d', strtotime('-29 days')); $label = 'Last 30 days'; break;
            case '90d':
                $from = gmdate('Y-m-d', strtotime('-89 days')); $label = 'Last 90 days'; break;
            case 'all':
                // Earliest day in the table; falls back to today if empty.
                $earliest = \Instinctor\Core\DB::get_var(
                    "SELECT MIN(day) FROM " . \Instinctor\Core\DB::prefix() . "instinctor_pageviews"
                );
                $from = $earliest ?: $today;
                $label = 'All time';
                break;
            case '7d':
            default:
                $from = gmdate('Y-m-d', strtotime('-6 days')); $label = 'Last 7 days'; break;
        }

        $svc = '\\Instinctor\\Services\\AnalyticsService';

        // Exit-by-screen is per-page. Build the list of pages with exit data,
        // default to the busiest when the client hasn't picked one, and report
        // each page's CURRENT screen count so the panel can label screens and
        // flag exits left over from an older (longer) layout.
        $exitPagesRaw = $svc::exitPages($from, $today, 20);
        $exitPages = [];
        foreach ($exitPagesRaw as $_row) {
            $pid = (int) ($_row['page_id'] ?? 0);
            if ($pid <= 0) continue;
            $page = \Instinctor\Services\PageService::getById($pid);
            if (!$page) continue;
            $lbl = trim((string) ($page['name'] ?? ''));
            if ($lbl === '') $lbl = 'Page ' . $pid;
            // Classic pages still report exit indexes (the screen engine runs
            // for reveals; each index is a SECTION, not a snap screen) - the
            // panel labels them 'Section N' instead of 'Screen N'.
            $_epSettings = \Instinctor\Services\PageService::getSettings($pid);
            $exitPages[] = [
                'page_id'  => $pid,
                'label'    => $lbl,
                'screens'  => self::countPageScreens($pid),
                'classic'  => ((string) ($_epSettings['body_scroll'] ?? '') === 'classic'),
                'sessions' => (int) ($_row['sessions'] ?? 0),
            ];
        }
        $dropoffPageId = $exitPageId;
        if (!$dropoffPageId && !empty($exitPages)) $dropoffPageId = $exitPages[0]['page_id'];
        $dropoffScreens = 0;
        foreach ($exitPages as $_ep) {
            if ($_ep['page_id'] === $dropoffPageId) { $dropoffScreens = $_ep['screens']; break; }
        }

        // Resolve a human label (page name) for each top page so the list
        // reads 'Instinctor' instead of the bare URL path. Sergei 2026-06-04.
        $topPages = $svc::topPages($from, $today, 20);
        foreach ($topPages as &$_tp) { $_tp['label'] = self::resolvePathLabel((string) ($_tp['path'] ?? '')); }
        unset($_tp);
        return new \WP_REST_Response([
            'range' => ['from' => $from, 'to' => $today, 'label' => $label],
            // What the referrer exclusion list removed from this range, so the
            // panel can name it rather than silently reporting smaller numbers.
            'excluded' => $svc::excludedStats($from, $today),
            'page_id' => $pageId,
            'totals' => $svc::totals($from, $today),
            'viewsOverTime' => $svc::viewsOverTime($from, $today, $pageId),
            'channels' => $svc::channelBreakdown($from, $today),
            'channelLanding' => $svc::channelLandingPages($from, $today, 40),
            'journeys' => $svc::topJourneys($from, $today, 12),
            'leadsBySource' => $svc::leadsBySource($from, $today),
            'conversion' => $svc::conversionSummary($from, $today),
            'topReferrers' => $svc::topReferrers($from, $today, 20),
            'topSources' => $svc::topSources($from, $today, 20),
            'direct' => $svc::directTraffic($from, $today),
            'engagement' => $svc::engagementSummary($from, $today),
            'dropoff' => $svc::exitScreenHistogram($from, $today, 16, $dropoffPageId),
            'dropoffPageId' => $dropoffPageId,
            'dropoffScreens' => $dropoffScreens,
            'exitPages' => $exitPages,
            'topPages' => $topPages,
            'deviceBreakdown' => $svc::deviceBreakdown($from, $today),
            'countryBreakdown' => $svc::countryBreakdown($from, $today, 30),
        ], 200);
    }

    /**
     * Behavior-tracker beacon (public). The frontend tracker POSTs a tiny JSON
     * summary on page exit; we hand it to AnalyticsService::ingestBeacon which
     * clamps + GREATESTs every value onto the row matched by view_token.
     * Always 204 (sendBeacon ignores the body) and never errors the visitor.
     */
    public function analyticsBeacon(\WP_REST_Request $request): \WP_REST_Response
    {
        if (class_exists('\\Instinctor\\Services\\AnalyticsService')) {
            $token = (string) $request->get_param('t');
            if ($token !== '') {
                \Instinctor\Services\AnalyticsService::ingestBeacon(
                    $token,
                    (int) $request->get_param('s'),
                    (int) $request->get_param('sc'),
                    $request->get_param('ex') !== null ? (int) $request->get_param('ex') : -1,
                    // Absent `hu` means an older cached tracker: default 1 so
                    // real visitors are never discarded during the rollout.
                    $request->get_param('hu') !== null ? (int) $request->get_param('hu') : 1
                );
            }
        }
        return new \WP_REST_Response(null, 204);
    }

    public function soloSlugAvailable(\WP_REST_Request $request): \WP_REST_Response
    {
        $slug = strtolower(trim((string) $request->get_param('slug')));
        if ($slug === '') {
            return new \WP_REST_Response(['available' => false, 'reason' => 'empty'], 200);
        }
        if (!preg_match('/^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/', $slug)) {
            return new \WP_REST_Response(['available' => false, 'reason' => 'format'], 200);
        }
        if (in_array($slug, self::RESERVED_SLUGS, true)) {
            return new \WP_REST_Response(['available' => false, 'reason' => 'reserved'], 200);
        }
        $pagesT = \Instinctor\Core\DB::prefix() . 'instinctor_pages';
        $taken = (int) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
            "SELECT COUNT(*) FROM {$pagesT} WHERE slug = %s AND status != 'trash'",
            $slug
        ));
        if ($taken > 0) {
            return new \WP_REST_Response(['available' => false, 'reason' => 'taken'], 200);
        }
        return new \WP_REST_Response(['available' => true], 200);
    }

    /**
     * Demo lead capture (public). Emails a restaurant's enquiry from the /pizza
     * sales demo's quote form to the Instinctor contact inbox. Honeypot-guarded;
     * stores nothing. Name + (phone OR email) are required.
     */
    /**
     * POST /demo/start — public. Body: {trade, name}. Clones the trade's
     * cinematic template, injects the business name, returns the signed demo
     * URL. Per-IP rate-limited via a transient (public write).
     */
    public function demoStart(\WP_REST_Request $request): \WP_REST_Response
    {
        $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
        $rlKey = 'inst_demo_rl_' . md5($ip);
        $hits = (int) get_transient($rlKey);
        if ($hits >= 30) {
            return new \WP_REST_Response(['success' => false, 'error' => 'rate_limited'], 429);
        }
        set_transient($rlKey, $hits + 1, 3600);

        // No silent 'salon' default any more: an empty description used to mint a
        // hair-studio site for whoever left the field blank. startDemo() now rejects it
        // (DemoService::describeProblem), which doubles as the spam bar on a form that
        // mints a free public site.
        $trade = sanitize_text_field((string) $request->get_param('trade'));
        $name = (string) $request->get_param('name');
        $city = sanitize_text_field((string) $request->get_param('city'));
        $url  = esc_url_raw((string) $request->get_param('url'));
        // Optional recovery contact (so we can nudge them to publish before purge).
        $email = sanitize_email((string) $request->get_param('email'));
        $phone = sanitize_text_field((string) $request->get_param('phone'));

        // FACTS THE VISITOR GAVE US, kept separate from the description so the seeding
        // can tell "they told us" from "a model inferred it". Whitelisted rather than
        // passed through: these decide whether we invent staff for somebody.
        $staff = strtolower(sanitize_text_field((string) $request->get_param('staff')));
        if (!in_array($staff, ['solo', 'small', 'large'], true)) $staff = '';
        // Hours arrive as {days:[mon..sun], open:"HH:MM", close:"HH:MM"}. Validated hard,
        // because this ends up deciding which times a stranger can book and what the
        // Smart chat tells them when asked.
        $hoursIn  = $request->get_param('hours');
        $hoursOut = [];
        if (is_array($hoursIn)) {
            $valid = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
            $days  = array_values(array_intersect($valid, array_map(
                static fn($d) => strtolower(substr(sanitize_text_field((string) $d), 0, 3)),
                (array) ($hoursIn['days'] ?? [])
            )));
            $t = static function ($v): string {
                $v = sanitize_text_field((string) $v);
                return preg_match('/^([01]\d|2[0-3]):[0-5]\d$/', $v) ? $v : '';
            };
            $open = $t($hoursIn['open'] ?? '');
            $close = $t($hoursIn['close'] ?? '');
            if ($days && $open !== '' && $close !== '' && $close > $open) {
                $hoursOut = ['days' => $days, 'open' => $open, 'close' => $close];
            }
        }

        // Attribution, whitelisted key by key: which ad or video sent them. Stored
        // on the demo and copied to the owner at claim, so every audience test is
        // judged on demos and tenants rather than clicks (Sergei 2026-07-30).
        $utmIn  = $request->get_param('utm');
        $utmOut = [];
        if (is_array($utmIn)) {
            foreach (['source', 'medium', 'campaign', 'term', 'content'] as $uk) {
                $uv = sanitize_text_field((string) ($utmIn[$uk] ?? ''));
                if ($uv !== '') $utmOut[$uk] = substr($uv, 0, 120);
            }
        }
        $refHost = (string) wp_parse_url(esc_url_raw((string) $request->get_param('ref')), PHP_URL_HOST);
        if ($refHost !== '') $utmOut['ref'] = substr($refHost, 0, 120);

        $facts = [
            'staff'      => $staff,
            // The visitor's own style answer from the start page. Explicit beats
            // the classifier's guess (Sergei 2026-07-29; replaces the switcher
            // that lived on the built demo).
            'archetype'  => preg_replace('/[^a-z]/', '', strtolower((string) $request->get_param('archetype'))),
            'owner_name' => sanitize_text_field((string) $request->get_param('owner_name')),
            'hours'      => $hoursOut,
            'utm'        => $utmOut,
        ];

        $res = \Instinctor\Services\DemoService::startDemo($trade, $name, $city, $url, $email, $phone, $facts);
        if (isset($res['error'])) {
            return new \WP_REST_Response(['success' => false, 'error' => $res['error']], 422);
        }
        return new \WP_REST_Response(['success' => true, 'url' => $res['url'], 'id' => $res['id']], 200);
    }

    /**
     * Persist a visitor's overlay photo swaps onto their demo page so they
     * survive a reload and get baked in when they claim the site. Public but
     * gated: the signed demo token authorises writes to THAT demo page only,
     * and both sides of every swap must be image paths in our storage (no
     * external-URL injection). Value-based: replaces the old image URL with the
     * new one wherever it appears in the element settings — reliable because
     * image URLs are unique. Reused later by the tenant dashboard editor.
     */

    /**
     * POST /demo/archetype — switch a demo's business archetype (face/place/product/
     * digital); DemoService re-picks the hero + gallery. Same token/page_id gate as
     * demoPersist. Body: {token|page_id, archetype}.
     */
    public function demoArchetype(\WP_REST_Request $request): \WP_REST_Response
    {
        $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
        $rlKey = 'inst_demoarch_' . md5($ip);
        $hits = (int) get_transient($rlKey);
        if ($hits >= 60) return new \WP_REST_Response(['success' => false, 'error' => 'rate_limited'], 429);
        set_transient($rlKey, $hits + 1, 3600);

        $archetype = preg_replace('/[^a-z]/', '', strtolower((string) $request->get_param('archetype')));
        $token = preg_replace('/[^a-z0-9-]/', '', strtolower((string) $request->get_param('token')));
        $pid = 0;
        if (preg_match('/^[0-9]+-[a-f0-9]{32}$/', $token)) {
            $pid = \Instinctor\Services\DemoService::resolveToken($token);
            if ($pid <= 0) return new \WP_REST_Response(['success' => false, 'error' => 'invalid_token'], 403);
            $settings = \Instinctor\Services\PageService::getSettings($pid);
            if (empty($settings['is_demo'])) return new \WP_REST_Response(['success' => false, 'error' => 'not_demo'], 403);
        } else {
            $reqPid = (int) $request->get_param('page_id');
            if ($reqPid > 0 && \Instinctor\Core\Auth::check() && \Instinctor\Services\PageService::currentUserCanEdit($reqPid)) {
                $pid = $reqPid;
            }
        }
        if ($pid <= 0) return new \WP_REST_Response(['success' => false, 'error' => 'forbidden'], 403);

        $res = \Instinctor\Services\DemoService::applyArchetype($pid, $archetype);
        if (empty($res['success'])) {
            return new \WP_REST_Response(['success' => false, 'error' => $res['error'] ?? 'failed'], 400);
        }
        return new \WP_REST_Response($res, 200);
    }

    /** GET /track/load - hand the recorded pass back to the player. */
    public function trackLoad(\WP_REST_Request $request): \WP_REST_Response
    {
        $f = dirname(__DIR__, 3) . '/storage/tracks/current.json';
        if (!is_file($f)) {
            return new \WP_REST_Response([], 200);
        }
        $raw = (string) file_get_contents($f);
        $arr = json_decode($raw, true);
        return new \WP_REST_Response(is_array($arr) ? $arr : [], 200);
    }

    /** POST /track/save - store a recorded pass. Write-only, capped, one file. */
    public function trackSave(\WP_REST_Request $request): \WP_REST_Response
    {
        $raw = $request->get_param('track');
        if (!is_array($raw)) return new \WP_REST_Response(['error' => 'bad_track'], 400);
        if (count($raw) > 8000) $raw = array_slice($raw, -8000);
        // Storage::path, not a path derived from __DIR__. On a WordPress install
        // dirname(__DIR__, 3) lands INSIDE the plugin folder, which WordPress
        // deletes and recreates on every upgrade: each recorded pass was being
        // destroyed by the next plugin update, and WP.org rejects the pattern
        // outright. The storage driver resolves to uploads on WP and to the
        // install's own storage dir on standalone. Sergei 2026-08-07.
        $dir = \Instinctor\Core\Storage::path('instinctor-tracks');
        if (!is_dir($dir)) @mkdir($dir, 0775, true);
        if (!is_dir($dir) || !is_writable($dir)) return new \WP_REST_Response(['error' => 'no_dir'], 500);
        $json = wp_json_encode($raw);
        if (strlen((string) $json) > 3000000) return new \WP_REST_Response(['error' => 'too_big'], 413);
        @file_put_contents($dir . '/current.json', $json);
        return new \WP_REST_Response(['success' => true, 'events' => count($raw)], 200);
    }

    public function demoPersist(\WP_REST_Request $request): \WP_REST_Response
    {
        $ip = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
        $rlKey = 'inst_demopersist_' . md5($ip);
        $hits = (int) get_transient($rlKey);
        if ($hits >= 120) {
            return new \WP_REST_Response(['success' => false, 'error' => 'rate_limited'], 429);
        }
        set_transient($rlKey, $hits + 1, 3600);

        $token = preg_replace('/[^a-z0-9-]/', '', strtolower((string) $request->get_param('token')));
        // Target page: EITHER a signed demo token (public demo edits) OR an
        // authenticated site owner editing their own live page (/dashboard/edit).
        $pid = 0;
        if (preg_match('/^[0-9]+-[a-f0-9]{32}$/', $token)) {
            $pid = \Instinctor\Services\DemoService::resolveToken($token);
            if ($pid <= 0) {
                return new \WP_REST_Response(['success' => false, 'error' => 'invalid_token'], 403);
            }
            $settings = \Instinctor\Services\PageService::getSettings($pid);
            if (empty($settings['is_demo'])) {
                return new \WP_REST_Response(['success' => false, 'error' => 'not_demo'], 403);
            }
        } else {
            $reqPid = (int) $request->get_param('page_id');
            if ($reqPid > 0 && \Instinctor\Core\Auth::check()
                && \Instinctor\Services\PageService::currentUserCanEdit($reqPid)) {
                $pid      = $reqPid;
                $settings = \Instinctor\Services\PageService::getSettings($pid);
            }
        }
        if ($pid <= 0) {
            return new \WP_REST_Response(['success' => false, 'error' => 'forbidden'], 403);
        }

        // Photo swaps: both sides must be image paths in our storage (no injection).
        $imgRe  = '#/storage/uploads/[^\s"\')]+\.(?:webp|png|jpe?g)#i';
        $photos = $request->get_param('photos');
        $photoMap = [];
        if (is_array($photos)) {
            foreach ($photos as $pr) {
                $old = (string) ($pr['old'] ?? '');
                $new = (string) ($pr['new'] ?? '');
                if ($old === '' || $new === '' || $old === $new) continue;
                if (strlen($old) > 400 || strlen($new) > 400) continue;
                if (!preg_match($imgRe, $old) || !preg_match($imgRe, $new)) continue;
                $photoMap[$old] = $new;
            }
        }

        // Theme recolor: hex slot remaps (both sides #rrggbb), applied as hex AND
        // rgb() forms to mirror the overlay. Single-pass callback = no chaining.
        $colors = $request->get_param('colors');
        $hexMap = [];   // "#rrggbb" (lower) => "#rrggbb"
        $rgbMap = [];   // "r,g,b" => [r,g,b]
        if (is_array($colors)) {
            foreach ($colors as $cr) {
                $o   = strtolower((string) ($cr['old'] ?? ''));
                $new = strtolower((string) ($cr['new'] ?? ''));
                if (!preg_match('/^#[0-9a-f]{6}$/', $o) || !preg_match('/^#[0-9a-f]{6}$/', $new) || $o === $new) continue;
                $hexMap[$o] = $new;
                $orgb = sscanf($o, "#%02x%02x%02x");
                $nrgb = sscanf($new, "#%02x%02x%02x");
                if ($orgb && $nrgb) $rgbMap[$orgb[0] . ',' . $orgb[1] . ',' . $orgb[2]] = $nrgb;
            }
        }

        $themeIdx = $request->get_param('theme');
        $themeIdx = ($themeIdx !== null && is_numeric($themeIdx)) ? max(0, min(20, (int) $themeIdx)) : null;

        // Single-swatch custom palette (10 hexes): stored so a reload remaps from the
        // palette actually baked into the tree (the `colors` deltas above do the baking).
        // Supersedes the preset `theme` index. Strictly validated: exactly 10 #rrggbb.
        $palette      = $request->get_param('palette');
        $paletteClean = null;
        if (is_array($palette) && count($palette) === 10) {
            $tmp = [];
            foreach ($palette as $hex) {
                $hex = is_string($hex) ? trim($hex) : '';
                if (!preg_match('/^#[0-9a-fA-F]{6}$/', $hex)) { $tmp = null; break; }
                $tmp[] = strtolower($hex);
            }
            $paletteClean = $tmp;
        }
        // SELF-HEALING CANONICAL SLOTS. The chips persist a CHAIN of deltas (Slate ->
        // blue -> green -> back), and the tree is only consistent if every link
        // arrives: pumpTheme drops a failed request and navigation can cut the last
        // one, after which the stored palette and the baked tree disagree forever.
        // The autopilot's swatch play does exactly that chain on camera, and one lost
        // link left a green chip on a slate-blue site (Sergei 2026-08-01). The server
        // KNOWS what the tree is baked to: stored demo_palette is written in the same
        // request as the tree. So for the 10 canonical slots, trust the client only
        // about the DESTINATION and compute the delta ourselves, stored -> sent. Any
        // earlier lost link heals on the next persist. Client deltas still carry the
        // mesh extras (slots the server does not track); a reload re-collects those
        // from the tree, so they re-sync on their own.
        if ($paletteClean !== null) {
            $stored = $settings['demo_palette'] ?? null;
            if (is_array($stored) && count($stored) === 10) {
                foreach ($stored as $slot => $from) {
                    $from = strtolower((string) $from);
                    $to   = $paletteClean[$slot];
                    if (!preg_match('/^#[0-9a-f]{6}$/', $from) || $from === $to) continue;
                    $hexMap[$from] = $to;
                    $orgb = sscanf($from, "#%02x%02x%02x");
                    $nrgb = sscanf($to, "#%02x%02x%02x");
                    if ($orgb && $nrgb) $rgbMap[$orgb[0] . ',' . $orgb[1] . ',' . $orgb[2]] = $nrgb;
                }
            }
        }

        // Layout mode: classic <-> cinematic. Flips the page's body_scroll and the
        // cinematic_bare flag (section renderer floats every section transparent in
        // cinematic). Purely a settings change — no element rewrite.
        $mode = $request->get_param('mode');
        $mode = in_array($mode, ['classic', 'cinematic'], true) ? $mode : null;

        // Inline text edits: value-based old->new replacements in element settings
        // (the visitor edits copy in place; demo copy is unique enough to key on the
        // string). Maps both the plain text AND its HTML-escaped form, since some demo
        // fields store escaped copy (escName) and others raw. Guarded to non-trivial
        // strings so a single common word cannot be mass-replaced.
        $texts   = $request->get_param('texts');
        $textMap = [];   // old => new
        if (is_array($texts)) {
            foreach ($texts as $tr) {
                $o  = (string) ($tr['old'] ?? '');
                $nw = (string) ($tr['new'] ?? '');
                if ($o === '' || $o === $nw || mb_strlen(trim($o)) < 2 || mb_strlen($o) > 4000 || mb_strlen($nw) > 4000) continue;
                $textMap[$o] = $nw;
                $oe = htmlspecialchars($o, ENT_QUOTES);
                if ($oe !== $o) $textMap[$oe] = htmlspecialchars($nw, ENT_QUOTES);
            }
            uksort($textMap, fn($a, $b) => mb_strlen($b) - mb_strlen($a));   // longest first
        }

        // The hours block's address PROMPT ("Add your business address", shown until the
        // visitor types theirs) is rendered, not stored, so value replacement cannot
        // find it in settings. Route that one edit to address_seed explicitly, and drop
        // the placeholder flag: the block then renders the typed address from the seed.
        $addrPrompt = '';
        foreach ($textMap as $o => $nw) {
            // trim(): the prompt leaf's textContent carries template whitespace,
            // so strict equality never matched and the typed address was silently
            // dropped (autopilot pilot 2026-07-25: live site showed only the phone).
            if (trim($o) === 'Add your business address' && trim($nw) !== '') { $addrPrompt = trim($nw); break; }
        }

        // Hero photo swap: the hero is a depth-parallax canvas (bg_depth_image/map) plus
        // the fixed cinematic backdrop, NOT a plain bg — value-based swaps miss it. Set the
        // hero_bg element's images directly. A preset carries a sibling depth map (stays
        // 3D); an uploaded image has none (goes flat, just bg_image).
        $hero = $request->get_param('hero');
        $heroImg = ''; $heroMap = ''; $heroApply = false;
        if (is_array($hero) && !empty($hero['image'])) {
            $hi = (string) $hero['image'];
            if (preg_match('#/storage/uploads/[^\s"\')]+\.(?:webp|png|jpe?g)#i', $hi)) {
                $heroImg = $hi;
                $hm      = (string) ($hero['map'] ?? '');
                $heroMap = preg_match('#/storage/uploads/[^\s"\')]+\.png#i', $hm) ? $hm : '';
                // The client persists a swap the moment it happens, which for an UPLOAD is
                // before its depth map has finished generating, so it sends none. By the time
                // anything re-renders the map usually exists, so look for the sibling here
                // rather than baking the hero flat forever (Sergei 2026-07-26).
                if ($heroMap === '' && class_exists('\Instinctor\Core\Storage')) {
                    $sib = preg_replace('#\.(?:webp|png|jpe?g)$#i', '-depth.png', $heroImg);
                    $rel = ltrim((string) preg_replace('#^.*/storage/uploads/#', '', $sib), '/');
                    if ($rel !== '' && \Instinctor\Core\Storage::exists($rel)) { $heroMap = $sib; }
                }
                $heroApply = true;
            }
        }

        // Per-element text color: the visitor recolored a heading/paragraph via the
        // hover pill. Value-based remaps can't target one element, so match by id
        // (DOM `ct-<id>` -> element id) and set its `color` setting.
        $textColors = $request->get_param('text_colors');
        $colorById = [];
        if (is_array($textColors)) {
            foreach ($textColors as $tc) {
                if (!is_array($tc)) continue;
                $cid = (string) ($tc['id'] ?? '');
                $col = trim((string) ($tc['color'] ?? ''));
                if ($cid === '' || !preg_match('/^#[0-9a-fA-F]{3,8}$|^rgba?\(\s*[\d.,\s%]+\)$/', $col)) continue;
                $colorById[$cid] = $col;
            }
        }

        // Map address: the map is a bare Google iframe keyed off the `address`
        // setting (no visible text to inline-edit). The "Edit address" control sends
        // {id, address}; set that map element's address + clear any lat/lng so the
        // typed address drives the embed query.
        $mapAddr = $request->get_param('map_address');
        $addrById = [];
        if (is_array($mapAddr)) {
            foreach ($mapAddr as $ma) {
                if (!is_array($ma)) continue;
                $mid = (string) ($ma['id'] ?? '');
                $adr = trim((string) ($ma['address'] ?? ''));
                if ($mid === '' || $adr === '' || mb_strlen($adr) > 300) continue;
                $addrById[$mid] = $adr;
            }
        }

        if (!$photoMap && !$hexMap && $themeIdx === null && $mode === null && !$textMap && !$heroApply && !$colorById && !$addrById) {
            return new \WP_REST_Response(['success' => true, 'applied' => 0], 200);
        }

        $count = 0;
        $apply = function (array &$nodes) use (&$apply, $photoMap, $textMap, $hexMap, $rgbMap, &$count, $addrPrompt): void {
            foreach ($nodes as &$n) {
                if (!empty($n['settings']) && is_array($n['settings'])) {
                    if ($addrPrompt !== '' && !empty($n['settings']['address_placeholder'])) {
                        $n['settings']['address_seed'] = $addrPrompt;
                        unset($n['settings']['address_placeholder']);
                        $count++;
                    }
                    foreach ($n['settings'] as &$v) {
                        if (!is_string($v) || $v === '') continue;
                        foreach ($textMap as $old => $new) {
                            if (strpos($v, $old) !== false) { $v = str_replace($old, $new, $v); $count++; }
                        }
                        foreach ($photoMap as $old => $new) {
                            if (strpos($v, $old) !== false) { $v = str_replace($old, $new, $v); $count++; }
                        }
                        if ($hexMap) {
                            $v = preg_replace_callback('/#[0-9a-fA-F]{6}/', function ($m) use ($hexMap, &$count) {
                                $k = strtolower($m[0]);
                                if (isset($hexMap[$k])) { $count++; return $hexMap[$k]; }
                                return $m[0];
                            }, $v);
                        }
                        if ($rgbMap) {
                            $v = preg_replace_callback('/(rgba?\()\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,[^)]*)?(\))/i', function ($m) use ($rgbMap, &$count) {
                                $key = ((int) $m[2]) . ',' . ((int) $m[3]) . ',' . ((int) $m[4]);
                                if (isset($rgbMap[$key])) { $t = $rgbMap[$key]; $count++; return $m[1] . $t[0] . ', ' . $t[1] . ', ' . $t[2] . ($m[5] ?? '') . $m[6]; }
                                return $m[0];
                            }, $v);
                        }
                    }
                    unset($v);
                }
                foreach (['children', 'elements'] as $ck) {
                    if (!empty($n[$ck]) && is_array($n[$ck])) $apply($n[$ck]);
                }
            }
            unset($n);
        };
        if ($photoMap || $hexMap || $textMap || $heroApply || $colorById || $addrById) {
            $els = \Instinctor\Services\PageService::getElements($pid);
            if ($photoMap || $hexMap || $textMap) $apply($els);
            if ($addrById) {
                $walkAddr = function (array &$nodes) use (&$walkAddr, $addrById, &$count): void {
                    foreach ($nodes as &$n) {
                        $nid = (string) ($n['id'] ?? '');
                        if ($nid !== '' && isset($addrById[$nid]) && !empty($n['settings']) && is_array($n['settings'])) {
                            $n['settings']['address'] = $addrById[$nid];
                            if (array_key_exists('address_query', $n['settings'])) $n['settings']['address_query'] = $addrById[$nid];
                            unset($n['settings']['latitude'], $n['settings']['longitude']);   // address drives the query
                            $count++;
                        }
                        foreach (['children', 'elements'] as $ck) {
                            if (!empty($n[$ck]) && is_array($n[$ck])) $walkAddr($n[$ck]);
                        }
                    }
                    unset($n);
                };
                $walkAddr($els);
            }
            if ($colorById) {
                $walkColor = function (array &$nodes) use (&$walkColor, $colorById, &$count): void {
                    foreach ($nodes as &$n) {
                        $nid = (string) ($n['id'] ?? '');
                        if ($nid !== '' && isset($colorById[$nid]) && !empty($n['settings']) && is_array($n['settings'])) {
                            $n['settings']['color'] = $colorById[$nid];
                            unset($n['settings']['color_tablet'], $n['settings']['color_mobile']);   // apply on all devices
                            $count++;
                        }
                        foreach (['children', 'elements'] as $ck) {
                            if (!empty($n[$ck]) && is_array($n[$ck])) $walkColor($n[$ck]);
                        }
                    }
                    unset($n);
                };
                $walkColor($els);
            }
            if ($heroApply) {
                $walkHero = function (array &$nodes) use (&$walkHero, $heroImg, $heroMap, &$count): void {
                    foreach ($nodes as &$n) {
                        if (!empty($n['settings']) && is_array($n['settings']) && (($n['settings']['demo_role'] ?? '') === 'hero_bg')) {
                            $n['settings']['bg_image'] = $heroImg;
                            unset($n['settings']['bg_image_tablet'], $n['settings']['bg_image_mobile']);
                            if ($heroMap !== '') {
                                $n['settings']['bg_depth_image'] = $heroImg;
                                $n['settings']['bg_depth_map']   = $heroMap;
                            } else {
                                unset($n['settings']['bg_depth_image'], $n['settings']['bg_depth_map']);
                            }
                            $count++;
                        }
                        foreach (['children', 'elements'] as $ck) {
                            if (!empty($n[$ck]) && is_array($n[$ck])) $walkHero($n[$ck]);
                        }
                    }
                    unset($n);
                };
                $walkHero($els);
            }
            \Instinctor\Services\PageService::saveElements($pid, $els);
        }

        // Persist settings changes (baked palette index + layout mode) in one write.
        $settingsDirty = false;
        if ($themeIdx !== null) { $settings['demo_theme'] = $themeIdx; $settingsDirty = true; }
        if ($paletteClean !== null) { $settings['demo_palette'] = $paletteClean; $settingsDirty = true; }
        if ($mode !== null) {
            $settings['body_scroll']    = ($mode === 'cinematic') ? 'snap' : 'classic';
            $settings['cinematic_bare'] = ($mode === 'cinematic') ? 1 : 0;
            $settingsDirty = true;
        }
        if ($heroApply) { $settings['cinematic_backdrop'] = $heroImg; $settingsDirty = true; }   // fixed backdrop follows the hero
        if ($settingsDirty) \Instinctor\Services\PageService::saveSettings($pid, $settings);

        return new \WP_REST_Response(['success' => true, 'applied' => $count], 200);
    }

    /**
     * POST /demo/upload — public. Body: {token|page_id, image:"data:image/...;base64,..."}.
     * Stores a visitor's OWN photo so they can use it in the demo/edit overlay in place
     * of a preset. Gated (signed demo token OR authenticated owner editing their page),
     * rate-limited, validated to a real image with a size cap. Returns {success, url}.
     * The client resizes/compresses before sending, so payloads stay small.
     */
    /**
     * Ask the depth service for a map, in the background, and store it beside the image.
     *
     * Fire and forget by design: the caller has already answered the upload, and this
     * takes minutes. Runs detached so neither PHP-FPM nor nginx is holding a worker for
     * the duration, which on a box with pm.max_children=25 matters more than the CPU.
     */
    private function queueHeroDepth(string $rel): void
    {
        try {
            $root = dirname(__DIR__, 3);
            $php  = PHP_BINARY ?: 'php';
            $cmd  = escapeshellcmd($php) . ' ' . escapeshellarg($root . '/bin/hero_depth.php')
                  . ' ' . escapeshellarg($rel) . ' > /dev/null 2>&1 &';
            if (function_exists('exec')) { @exec($cmd); }
        } catch (\Throwable $e) { /* a flat hero is a fine outcome; never fail an upload */ }
    }

    public function demoUpload(\WP_REST_Request $request): \WP_REST_Response
    {
        $ip    = isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : '';
        $rlKey = 'inst_demoupload_' . md5($ip);
        $hits  = (int) get_transient($rlKey);
        if ($hits >= 80) return new \WP_REST_Response(['success' => false, 'error' => 'rate_limited'], 429);
        set_transient($rlKey, $hits + 1, 3600);

        // Target page: signed demo token, OR an authenticated owner editing their page.
        $token = preg_replace('/[^a-z0-9-]/', '', strtolower((string) $request->get_param('token')));
        $pid   = 0;
        if (preg_match('/^[0-9]+-[a-f0-9]{32}$/', $token)) {
            $pid = \Instinctor\Services\DemoService::resolveToken($token);
            if ($pid <= 0) return new \WP_REST_Response(['success' => false, 'error' => 'invalid_token'], 403);
            $s = \Instinctor\Services\PageService::getSettings($pid);
            if (empty($s['is_demo'])) return new \WP_REST_Response(['success' => false, 'error' => 'not_demo'], 403);
        } else {
            $reqPid = (int) $request->get_param('page_id');
            if ($reqPid > 0 && \Instinctor\Core\Auth::check() && \Instinctor\Services\PageService::currentUserCanEdit($reqPid)) {
                $pid = $reqPid;
            }
        }
        if ($pid <= 0) return new \WP_REST_Response(['success' => false, 'error' => 'forbidden'], 403);

        $data = (string) $request->get_param('image');
        if (!preg_match('#^data:image/(jpe?g|png|webp);base64,#i', $data, $m)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'bad_format'], 422);
        }
        $ext = strtolower($m[1]) === 'png' ? 'png' : (strtolower($m[1]) === 'webp' ? 'webp' : 'jpg');
        $raw = base64_decode(substr($data, strpos($data, ',') + 1), true);
        if ($raw === false || strlen($raw) < 64) return new \WP_REST_Response(['success' => false, 'error' => 'decode_failed'], 422);
        if (strlen($raw) > 8 * 1024 * 1024)      return new \WP_REST_Response(['success' => false, 'error' => 'too_large'], 413);
        $info = @getimagesizefromstring($raw);
        if ($info === false || empty($info[0]) || empty($info[1])) {
            return new \WP_REST_Response(['success' => false, 'error' => 'not_image'], 422);
        }

        $rel = 'instinctor-images/demo-uploads/' . gmdate('Y/m') . '/' . bin2hex(random_bytes(12)) . '.' . $ext;
        if (!\Instinctor\Core\Storage::put($rel, $raw)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'store_failed'], 500);
        }

        // OPTIMISE, as every other ingest path already does. This one wrote the raw
        // bytes straight through: a 4000x3000 phone photo was stored byte for byte at
        // ~1MB as a .jpg and served to every visitor of that site, while the whole
        // preset library is capped webp (measured 2026-07-31). optimizeStoredFile is
        // the existing chokepoint: resize cap, webp conversion, EXIF strip, original
        // removed. Reused rather than reimplemented so demo uploads obey the same
        // instinctor_image_optimizer setting as everything else.
        //
        // Failure is non-fatal by design: it returns the untouched path, so a photo
        // GD cannot handle still uploads and still shows, just unoptimised.
        $abs  = \Instinctor\Core\Storage::path($rel);
        $mime = $ext === 'png' ? 'image/png' : ($ext === 'webp' ? 'image/webp' : 'image/jpeg');
        $opt  = \Instinctor\Services\ImageLibraryService::optimizeStoredFile($abs, $mime, 1600);
        if (!empty($opt['path']) && $opt['path'] !== $abs) {
            // Converted to webp: the filename changed, so the stored URL must follow.
            $rel = ltrim(str_replace(\Instinctor\Core\Storage::path(''), '', $opt['path']), '/\\');
            $rel = strtr($rel, DIRECTORY_SEPARATOR, '/');
        }

        $url = \Instinctor\Core\Storage::url($rel);

        // HERO UPLOADS GET A DEPTH MAP (Sergei 2026-07-26: "it can really make them feel
        // the wow effect"). Their own photo, moving, is the strongest moment we have.
        //
        // NOT synchronously, and nothing waits on it. Depth is CPU inference on this box
        // and AdminRouter::handleDepthMap allows 300 SECONDS for it, so blocking the
        // upload would hang the picker for minutes. The hero therefore appears flat at
        // once and the map is built behind it; the next render picks it up.
        //
        // Hero only: one image per site bounds the cost. Doing it for gallery and team
        // photos would multiply CPU on a two-core box that is already at its worker
        // ceiling, for images nobody looks at in 3D anyway.
        if ((string) $request->get_param('hero') === '1') {
            $this->queueHeroDepth($rel);
        }

        return new \WP_REST_Response(['success' => true, 'url' => $url], 200);
    }

    public function submitDemoLead(\WP_REST_Request $request): \WP_REST_Response
    {
        // Honeypot — a hidden field real users never fill; bots fill everything.
        if (trim((string) $request->get_param('company_url')) !== '') {
            return new \WP_REST_Response(['success' => true], 200); // accept + drop
        }
        $name    = mb_substr(trim((string) $request->get_param('name')), 0, 120);
        $rest    = mb_substr(trim((string) $request->get_param('restaurant')), 0, 160);
        $phone   = mb_substr(trim((string) $request->get_param('phone')), 0, 40);
        $email   = mb_substr(trim((string) $request->get_param('email')), 0, 160);
        $message = mb_substr(trim((string) $request->get_param('message')), 0, 1500);
        $source  = mb_substr(trim((string) $request->get_param('source')), 0, 300);

        if ($name === '' || ($phone === '' && $email === '')) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Add your name and a phone or email.'], 400);
        }

        $subject = 'Pizza demo lead' . ($rest !== '' ? ': ' . $rest : '');
        $body = "New enquiry from the pizza demo.\n\n"
              . 'Name: ' . $name . "\n"
              . 'Restaurant: ' . ($rest !== '' ? $rest : '(not given)') . "\n"
              . 'Phone: ' . ($phone !== '' ? $phone : '(not given)') . "\n"
              . 'Email: ' . ($email !== '' ? $email : '(not given)') . "\n"
              . ($message !== '' ? 'Message: ' . $message . "\n" : '')
              . ($source !== '' ? "\nFrom: " . $source . "\n" : '');

        $sent = \Instinctor\Core\Mailer::send('sergeitiden@gmail.com', $subject, $body);

        // Optional SMS ping to the owner. Number lives in config (not hardcoded);
        // when unset, SMS is simply skipped and the email still goes out.
        $smsTo = trim((string) \Instinctor\Core\Config::get('instinctor_demo_lead_phone', ''));
        if ($smsTo !== '') {
            $sms = 'New pizza demo lead' . ($rest !== '' ? ': ' . $rest : '') . '. ' . $name
                 . ($phone !== '' ? ', ' . $phone : '') . ($email !== '' ? ', ' . $email : '');
            $this->sendLeadSms($smsTo, $sms);
        }

        return new \WP_REST_Response(['success' => (bool) $sent], $sent ? 200 : 500);
    }

    /**
     * Owner SMS notification for a demo lead, via the platform relay (same path as
     * booking SMS). Best-effort: failures are logged, never block the email/response.
     */
    private function sendLeadSms(string $to, string $message): bool
    {
        $digits = preg_replace('/\D/', '', $to);
        if (strlen($digits) < 10) return false;
        $to = (strpos($to, '+') === 0) ? '+' . $digits : (strlen($digits) === 10 ? '+1' . $digits : '+' . $digits);
        $siteUrl = function_exists('home_url') ? home_url() : (defined('INSTINCTOR_STANDALONE_URL') ? INSTINCTOR_STANDALONE_URL : '');
        $response = wp_remote_post('https://platform.instinctor.com/api/relay/sms', [
            'body' => [
                'site_url'    => $siteUrl,
                'license_key' => (string) \Instinctor\Core\Config::get('instinctor_license_key', ''),
                'to'          => $to,
                'message'     => $message,
                'channel'     => 'sms',
            ],
            'timeout' => 15,
        ]);
        if (is_wp_error($response)) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture or migration logging, not debug output
            error_log('[Demo lead SMS] relay error: ' . $response->get_error_message());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
            return false;
        }
        $code = wp_remote_retrieve_response_code($response);
        return $code >= 200 && $code < 300;
    }

    /**
     * POST /solo/provision   (platform-token auth)
     *
     * Called by Platform after a solo subscription is paid. Creates
     *   - an instinctor_users row (if email not already registered),
     *   - a blank instinctor_pages row under owner_id = instinctor-team
     *     with the user's chosen slug,
     * then returns the user id, page id, and an SSO HMAC token Platform
     * can redirect the browser to (lands on instinctor.com/sso?token=…
     * logged in as the new user, on the editor for their new page).
     *
     * Inputs: email, name (optional), slug, license_key (stored as a note
     * on the page for later reference).
     *
     * Idempotent on email: if a user already exists with this email we
     * reuse them — but slug collision still returns an error rather than
     * silently attach to an existing page.
     *
     * @since 2.1.238
     */
    /**
     * Mint a passwordless sign-in URL for a user, in the exact HMAC format
     * Frontend\SSOController::sso consumes (base64url(payload) . hmac_sha256,
     * signed with the shared INSTINCTOR_PLATFORM_TOKEN). $ttl seconds until it
     * expires; $return is the relative path to land on after sign-in.
     */
    private function mintSsoUrl(int $userId, string $email, string $name, string $return, int $ttl): string
    {
        $secret  = defined('INSTINCTOR_PLATFORM_TOKEN') ? INSTINCTOR_PLATFORM_TOKEN : '';
        $payload = ['user_id' => $userId, 'email' => $email, 'name' => $name, 'exp' => time() + $ttl];
        // phpcs:disable WordPress.WP.AlternativeFunctions -- native string ops, no WP_Filesystem in this path
        $payloadB64 = rtrim(strtr(base64_encode((string) json_encode($payload)), '+/', '-_'), '=');
        // phpcs:enable WordPress.WP.AlternativeFunctions
        $sig     = hash_hmac('sha256', $payloadB64, $secret);
        $baseUrl = defined('INSTINCTOR_STANDALONE_URL') ? rtrim(INSTINCTOR_STANDALONE_URL, '/') : 'https://instinctor.com';
        return $baseUrl . '/sso?token=' . rawurlencode($payloadB64 . '.' . $sig)
             . '&return=' . rawurlencode($return);
    }

    /**
     * Create-or-reuse a tenant's own site_owner account and a published page for
     * them (seeded from a claimed demo when a demo_token is given). Shared by the
     * platform-paid path (soloProvision) and the free-publish path (publishFree).
     *
     * @return array{user_id:int,page_id:int}|array{error:string,code:int}
     */
    // $phone: the publish form collects it and the profile seeding below needs it.
    // It was referenced here without ever being a parameter, so it evaluated to null
    // and the string-typed seed closure threw a TypeError: free publish 500'd for
    // EVERYONE, after the account and page had already been created, so every retry
    // then reported email_in_use (Sergei 2026-07-26).
    private function provisionSoloSite(string $email, string $name, string $slug, string $licenseKey, string $demoToken, string $source, string $phone = ''): array
    {
        $usersT = \Instinctor\Core\DB::prefix() . 'instinctor_users';
        $pagesT = \Instinctor\Core\DB::prefix() . 'instinctor_pages';

        // Create or reuse the customer's OWN account (role site_owner). Their pages
        // carry owner_id = their id; the data layer scopes bookings/CRM/chat/settings
        // to it. Password is unusable — they sign in by magic link / SSO.
        $userId = (int) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
            "SELECT id FROM {$usersT} WHERE email = %s LIMIT 1", $email
        ));
        if (!$userId) {
            $unusableHash = '!' . bin2hex(random_bytes(16));
            // Acquisition attribution: the demo carried the utm it was born with;
            // copy it onto the owner so the source survives the demo's deletion and
            // every audience test can count TENANTS, not clicks (Sergei 2026-07-30).
            $acqUtm = [];
            if ($demoToken !== '') {
                $demoPid = \Instinctor\Services\DemoService::resolveToken($demoToken);
                if ($demoPid > 0) {
                    $demoSettings = json_decode((string) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
                        "SELECT settings FROM {$pagesT} WHERE id = %d", $demoPid)), true) ?: [];
                    if (!empty($demoSettings['demo_utm']) && is_array($demoSettings['demo_utm'])) $acqUtm = $demoSettings['demo_utm'];
                }
            }
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native string ops, no WP_Filesystem in this path
            $meta = json_encode(array_filter(['source' => $source, 'license_key' => $licenseKey, 'utm' => $acqUtm ?: null]));
            // phpcs:enable WordPress.WP.AlternativeFunctions
            \Instinctor\Core\DB::query(\Instinctor\Core\DB::prepare(
                "INSERT INTO {$usersT} (email, password_hash, display_name, role, status, email_verified, meta)
                 VALUES (%s, %s, %s, 'site_owner', 'active', 1, %s)",
                $email, $unusableHash, $name !== '' ? $name : $email, $meta
            ));
            $userId = (int) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
                "SELECT id FROM {$usersT} WHERE email = %s LIMIT 1", $email
            ));
        }
        if ($userId <= 0) {
            return ['error' => 'user_create_failed', 'code' => 500];
        }
        $ownerId = $userId;

        // Slug must be unique across the whole site (the tenant's URL prefix,
        // instinctor.com/<slug>). Form live-validates; guard the race here.
        // Reserved words fence HERE too, not only in the live availability check:
        // this is the door the autopilot and any direct POST walk through, and a
        // fence only on the form is a fence only for the honest (2026-08-02).
        if (in_array($slug, self::RESERVED_SLUGS, true)) {
            return ['error' => 'slug_reserved', 'code' => 409];
        }
        $taken = (int) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
            "SELECT COUNT(*) FROM {$pagesT} WHERE slug = %s AND status != 'trash'",
            $slug
        ));
        if ($taken > 0) {
            return ['error' => 'slug_taken', 'code' => 409];
        }

        // "Make it yours" — seed the page with the claimed demo's generated content
        // when a demo token is supplied; otherwise a blank page.
        $seedEls = '[]';
        $seedSettings = '{}';
        $demoBiz = ''; $demoCity = ''; $demoTrade = ''; $demoSolo = null;
        if ($demoToken !== '' && class_exists('\\Instinctor\\Services\\DemoService')) {
            $demoId = (int) \Instinctor\Services\DemoService::resolveToken($demoToken);
            if ($demoId > 0) {
                $drow = \Instinctor\Core\DB::get_row(\Instinctor\Core\DB::prepare(
                    "SELECT name, elements, settings FROM {$pagesT} WHERE id = %d", $demoId
                ), ARRAY_A);
                if ($drow && !empty($drow['elements']) && $drow['elements'] !== '[]') {
                    $seedEls = (string) $drow['elements'];
                    // Publish guard: swap an unfilled "add your address" prompt for the
                    // city so the placeholder never leaks onto the live site.
                    $seedEls = \Instinctor\Services\DemoService::bakePublishedElements(
                        $seedEls,
                        // Recording tenants keep stock photos; see bakePublishedElements.
                        (bool) preg_match('/\+ap-[a-z0-9-]+@gmail\.com$/i', $email)
                    );
                    $ds = json_decode((string) ($drow['settings'] ?? '{}'), true) ?: [];
                    $demoBiz = trim((string) ($ds['demo_business_name'] ?? ''));
                    $demoCity  = trim((string) ($ds['demo_city'] ?? ''));
                    $demoTrade = trim((string) ($ds['demo_trade'] ?? ''));
                    // "Just me" from the entry form, stamped on the demo by
                    // DemoService. Carried out here so it can be stored against the
                    // owner below: the dashboard needs it to decide whether a team
                    // tile is honest, and it is the only place the answer survives.
                    if (array_key_exists('demo_solo', $ds)) $demoSolo = (int) $ds['demo_solo'];
                    unset($ds['is_demo']);                 // it's a real site now, not a demo
                    $seedSettings = function_exists('wp_json_encode') ? wp_json_encode($ds) : json_encode($ds);
                }
            }
        }

        // Create the page. Blank unless a claimed demo seeded it above.
        $siteName = $demoBiz !== '' ? $demoBiz : ($name !== '' ? $name . "'s Site" : ucfirst($slug));
        \Instinctor\Core\DB::query(\Instinctor\Core\DB::prepare(
            "INSERT INTO {$pagesT} (owner_id, name, slug, status, type, elements, settings, thumbnail, sort_order)
             VALUES (%d, %s, %s, 'published', 'page', %s, %s, '', 0)",
            $ownerId, $siteName, $slug, $seedEls, $seedSettings
        ));
        $pageId = (int) \Instinctor\Core\DB::get_var('SELECT LAST_INSERT_ID()');
        if ($pageId <= 0) {
            return ['error' => 'page_create_failed', 'code' => 500];
        }

        // Pre-fill the dashboard BusinessProfile with the business name (the demo's
        // generated name wins, else what they typed) so Settings + auto-SEO + booking
        // texts start populated instead of falling back to the vendor site name.
        $bpName = $demoBiz !== '' ? $demoBiz : $name;
        if ($bpName !== '' && class_exists('\\Instinctor\\Services\\BusinessProfile')) {
            \Instinctor\Services\BusinessProfile::set('business_name', $bpName, $ownerId);
        }
        // STAFFING, from the entry form's "Just me" chip. Stored per owner so the
        // dashboard can hide a team tile that would otherwise promise "names and
        // photos, on your site and your booking page" to a business of one. Only
        // written when the demo actually carried an answer, so an owner who arrives
        // by another route keeps the old behaviour rather than being guessed at.
        if ($demoSolo !== null) {
            \Instinctor\Core\Config::setForOwner($ownerId, 'team_mode', $demoSolo ? 'solo' : 'team');
        }

        // Carry the address they typed on the demo into their profile. It lived only
        // in the hours block's address_seed, which the profile-first read on a
        // published site never consults once an owner exists — so a site whose demo
        // showed a street address published with a bare phone number under "Visit us"
        // (Sergei 2026-07-25). Same value, now in the field the dashboard edits.
        if (class_exists('\\Instinctor\\Services\\BusinessProfile')
            && trim((string) \Instinctor\Services\BusinessProfile::get('address', $ownerId)) === '') {
            $seedAddr = '';
            $findAddr = function ($nodes) use (&$findAddr, &$seedAddr) {
                foreach ($nodes as $n) {
                    if ($seedAddr !== '') return;
                    $a = trim((string) ($n['settings']['address_seed'] ?? ''));
                    if ($a !== '') { $seedAddr = $a; return; }
                    foreach (['children', 'elements'] as $ck) {
                        if (!empty($n[$ck]) && is_array($n[$ck])) $findAddr($n[$ck]);
                    }
                }
            };
            $findAddr((array) (json_decode($seedEls, true) ?: []));
            if ($seedAddr !== '') \Instinctor\Services\BusinessProfile::set('address', $seedAddr, $ownerId);
        }

        // OPENING HOURS THEY ANSWERED AT THE ENTRY FORM. Until this existed the booking
        // settings kept their Mon-Fri 9-5 default, which decided the times a stranger
        // could book AND was read back to visitors by the Smart chat as if it were fact
        // (Sergei 2026-07-28). Harvested the same way as the address, since a demo has no
        // owner to hang it on.
        $seedHours = [];
        $findHours = function ($nodes) use (&$findHours, &$seedHours) {
            foreach ($nodes as $n) {
                if ($seedHours) return;
                $h = json_decode((string) ($n['settings']['hours_seed'] ?? ''), true);
                if (is_array($h) && !empty($h['days'])) { $seedHours = $h; return; }
                foreach (['children', 'elements'] as $ck) {
                    if (!empty($n[$ck]) && is_array($n[$ck])) $findHours($n[$ck]);
                }
            }
        };
        $findHours((array) (json_decode($seedEls, true) ?: []));
        if ($seedHours) {
            try {
                \Instinctor\Core\Tenant::withOwner($ownerId, static function () use ($seedHours, $ownerId) {
                    $bs  = \Instinctor\Services\BookingService::instance();
                    $set = (array) $bs->getSettings();
                    $map = ['mon' => 'monday', 'tue' => 'tuesday', 'wed' => 'wednesday',
                            'thu' => 'thursday', 'fri' => 'friday', 'sat' => 'saturday', 'sun' => 'sunday'];
                    $bh = (array) ($set['business_hours'] ?? []);
                    foreach ($map as $short => $long) {
                        $on = in_array($short, (array) $seedHours['days'], true);
                        $bh[$long] = [
                            'enabled' => $on,
                            'start'   => (string) $seedHours['open'],
                            'end'     => (string) $seedHours['close'],
                        ];
                    }
                    $set['business_hours'] = $bh;
                    \Instinctor\Core\Config::setForOwner($ownerId, 'instinctor_booking_settings', $set);
                });
                // And in the profile, so Settings opens showing what they told us.
                if (class_exists('\Instinctor\Services\DemoService')) {
                    $line = \Instinctor\Services\DemoService::hoursLine($seedHours);
                    if ($line !== '' && trim((string) \Instinctor\Services\BusinessProfile::get('hours', $ownerId)) === '') {
                        \Instinctor\Services\BusinessProfile::set('hours', $line, $ownerId);
                    }
                }
            } catch (\Throwable $e) { /* never fail a publish over opening hours */ }
        }

        // Seed the rest of the profile from what they ALREADY told us, so Settings
        // opens filled in instead of showing Instinctor's own tagline and helpdesk
        // address as fallbacks (Sergei 2026-07-26). Only ever fills a blank: an
        // owner's own value is never overwritten.
        if (class_exists('\Instinctor\Services\BusinessProfile')) {
            // Tolerate null (preg_replace can return it) rather than fataling mid-provision:
            // by this point the account and page EXIST, so a throw here strands the owner.
            $seedIfBlank = static function (string $key, ?string $val) use ($ownerId): void {
                $val = (string) $val;
                if ($val === '') return;
                if (trim((string) \Instinctor\Services\BusinessProfile::get($key, $ownerId)) !== '') return;
                \Instinctor\Services\BusinessProfile::set($key, $val, $ownerId);
            };
            $seedIfBlank('city', $demoCity);
            // Their own words from the entry form, not our product description.
            $seedIfBlank('tagline', mb_substr($demoTrade, 0, 160));
            // The address they publish with is the one that should receive alerts.
            $seedIfBlank('email', $email);
            // THE PUBLISH CARD'S NUMBER IS THE ALERT NUMBER, NOT THE PUBLIC ONE.
            // The card asks for it as "Your mobile number: we text you when a customer
            // contacts your site", so it belongs in notify_phone. Seeding `phone`
            // instead did two wrong things at once: no lead alert could ever fire,
            // because Sms::sendLeadAlert reads notify_phone and that stayed empty, and
            // once the call bar shipped (2026-07-31, default on) the private mobile
            // they gave us was published at the foot of their own site. The public
            // number stays blank until an owner deliberately fills it in Settings,
            // which is also what keeps the bar hidden (Sergei 2026-08-01).
            if ($phone !== '') {
                $pretty = function_exists('impulse_hours_phone_pretty')
                    ? impulse_hours_phone_pretty($phone) : $phone;
                $seedIfBlank('notify_phone', $pretty);
                // Same number, in the place the booking notifier actually reads.
                $bs = (array) \Instinctor\Core\Config::getForOwner($ownerId, 'instinctor_booking_settings', []);
                if (trim((string) ($bs['notify_sms'] ?? '')) === '') {
                    $bs['notify_sms'] = $pretty;
                    \Instinctor\Core\Config::setForOwner($ownerId, 'instinctor_booking_settings', $bs);
                }
            }
        }

        // Seed the owner's services from the claimed demo, so the booking form works on
        // their new site immediately and, on a quote-first trade, so their services
        // EXIST at all. Owner-scoped, editable later, idempotent: only when they have
        // none yet.
        //
        // This read booking_form only, and quote-first verticals do not have one: a
        // handyman published with zero rows in instinctor_services while his services
        // sat on the page as six flip-boxes. Everything keyed on a service id (work
        // photos, and anything later) therefore had nothing to attach to for exactly
        // the trades it was built for (Sergei 2026-07-28). The quote form already
        // carries the same demo_services array; nothing new had to be generated.
        $svcT = \Instinctor\Core\DB::prefix() . 'instinctor_services';
        $hasSvc = (int) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
            "SELECT COUNT(*) FROM {$svcT} WHERE owner_id = %d", $ownerId
        ));
        if ($hasSvc === 0 && $seedEls !== '' && $seedEls !== '[]') {
            foreach ((json_decode($seedEls, true) ?: []) as $se) {
                $seRole = (string) ($se['settings']['demo_role'] ?? '');
                if ($seRole !== 'booking_form' && $seRole !== 'quote_form') continue;
                $svcs = $se['settings']['demo_services'] ?? null;
                if (is_string($svcs)) $svcs = json_decode($svcs, true);
                // A quote form may sit above the booking form in element order on a
                // template that has both; an empty list must not stop the search.
                if (!is_array($svcs) || !$svcs) continue;
                $so = 0;
                foreach ($svcs as $sv) {
                    $nm = trim((string) ($sv['name'] ?? ''));
                    if ($nm === '') continue;
                    \Instinctor\Core\DB::query(\Instinctor\Core\DB::prepare(
                        "INSERT INTO {$svcT} (name, duration, price, capacity, status, owner_id, sort_order, created_at)
                         VALUES (%s, %d, %f, 1, 'active', %d, %d, %s)",
                        // A trade quotes a job, it does not sell a 60-minute slot, so a
                        // quote-first service gets zero duration rather than a fake one.
                        $nm,
                        (int) ($sv['duration'] ?? ($seRole === 'quote_form' ? 0 : 60)),
                        (float) ($sv['price'] ?? 0), $ownerId, $so++, current_time('mysql')
                    ));
                }
                break;
            }
        }

        // Brief their chat assistant before anyone can talk to it. The widget goes live
        // with the site, so an unbriefed assistant is not a blank slate: it is whatever
        // the relay falls back to. Last in provisioning, so the drafter can read the
        // profile and the services seeded above. Best-effort: never fail a publish over
        // the assistant's opening notes (Sergei 2026-07-26).
        try {
            if ($ownerId > 0
                && trim((string) \Instinctor\Core\Config::ownerOnly($ownerId, 'instinctor_chat_ai_store_info', '')) === ''
                && class_exists('\Instinctor\Frontend\TenantDashboardController')) {
                $draft = \Instinctor\Frontend\TenantDashboardController::draftAssistantInstructions($ownerId);
                if (trim($draft) !== '') {
                    \Instinctor\Core\Config::setForOwner($ownerId, 'instinctor_chat_ai_store_info', $draft);
                    // AND TURN IT ON. The brief was being written and then left switched
                    // off, so every published site shipped with a briefed assistant that
                    // never said a word, and the owner had to find a toggle to discover a
                    // feature they were never told they were missing (Sergei 2026-07-27,
                    // asking whether a real visitor's site works).
                    //
                    // Only here, on a fresh publish, and only when the owner has expressed
                    // no preference: an owner who has switched it OFF stays off.
                    if (\Instinctor\Core\Config::ownerOnly($ownerId, 'instinctor_chat_ai_enabled', null) === null) {
                        \Instinctor\Core\Config::setForOwner($ownerId, 'instinctor_chat_ai_enabled', '1');
                    }
                }
            }
        } catch (\Throwable $e) { /* the assistant can be briefed later from the dashboard */ }

        return ['user_id' => $userId, 'page_id' => $pageId];
    }

    public function soloProvision(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params() ?: [];
        $email = strtolower(trim((string) ($data['email'] ?? '')));
        $name  = trim((string) ($data['name']  ?? ''));
        $slug  = strtolower(trim((string) ($data['slug'] ?? '')));
        $lic   = trim((string) ($data['license_key'] ?? ''));

        if ($email === '' || $slug === '') {
            return new \WP_REST_Response(['error' => 'missing_fields'], 400);
        }
        if (!preg_match('/^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/', $slug)) {
            return new \WP_REST_Response(['error' => 'bad_slug'], 400);
        }

        $prov = $this->provisionSoloSite($email, $name, $slug, $lic, trim((string) ($data['demo_token'] ?? '')), 'solo_signup');
        if (isset($prov['error'])) {
            return new \WP_REST_Response(['error' => $prov['error']], $prov['code'] ?? 500);
        }
        $userId = $prov['user_id'];
        $pageId = $prov['page_id'];

        // Log the new tenant in on their editor (short-lived SSO hand-off).
        $ssoUrl  = $this->mintSsoUrl($userId, $email, $name, '/admin/builder/' . $pageId, 300);
        $baseUrl = defined('INSTINCTOR_STANDALONE_URL') ? rtrim(INSTINCTOR_STANDALONE_URL, '/') : 'https://instinctor.com';
        return new \WP_REST_Response([
            'success'    => true,
            'user_id'    => $userId,
            'page_id'    => $pageId,
            'slug'       => $slug,
            'sso_url'    => $ssoUrl,
            'site_url'   => $baseUrl . '/instinctor/' . $slug,
        ], 200);
    }

    /**
     * POST /demo/publish-free   (public; authenticated by the SIGNED demo token)
     *
     * Free-publish: the visitor turns their live demo into a real, published FREE
     * site with no payment. The signed demo token proves it's a genuine demo (only
     * DemoService can mint it), so no platform token is needed. Provisions the free
     * site_owner + page, logs them in immediately (short SSO), and emails a 7-day
     * magic-link so they can get back in. Free tier: Pro flag stays off, so SMS is
     * gated and the ribbon shows.
     *
     * Body: { demo_token, email, slug, name? }
     */
    /**
     * Slugs no tenant may claim as their URL (instinctor.com/<slug>). Three
     * families: system paths (routing would shadow or confuse them), brand
     * words (a tenant at /instinctor or /promo reads as official), and generic
     * category words (Sergei 2026-08-02: nobody owns /salon or /video; those
     * stay available for future vendor pages). Checked in BOTH the live
     * availability endpoint and the provisioning race-guard, so a direct POST
     * cannot claim what the form would refuse.
     */
    private const RESERVED_SLUGS = [
        // system + routing
        'admin','api','assets','cart','checkout','d','dashboard','demo','demos',
        'depth-maps','designers','directory','feed','help','hire','login','logout',
        'mail','make-a-site','partners','pricing','r','register','reset-password',
        'robots','rss','search','sitemap','sso','start','static','status','storage',
        'subscribe','support','talk-to-us','uploads','www',
        // brand + funnel
        'instinctor','app','billing','blog','docs','download','downloads','free',
        'install','platform','pro','promo','setup','upgrade',
        // generic category words
        'agency','booking','builder','chat','design','domains','email','hosting',
        'invoice','news','salon','seo','shop','store','studio','team','test',
        'video','videos','website','websites',
        // common service phrases (added 2026-08-11 after a throwaway signup
        // burned /hair-care): generic trade and service names read as category
        // pages, so no single tenant may own one. Their business name still
        // works as a slug the moment it is actually a name, not a category.
        'auto-repair','auto-shop','bakery','barber','barbershop','beauty',
        'car-wash','catering','cleaning','clinic','coaching','coffee',
        'contractor','dental','dentist','detailing','doctor','electrician',
        'fitness','food','gym','hair','hair-care','hair-salon','haircut',
        'haircuts','handyman','hvac','inspection','insurance','landscaping',
        'lashes','lawyer','lessons','locksmith','makeup','massage','mechanic',
        'moving','nail-salon','nails','painting','personal-trainer',
        'pest-control','photographer','photography','pizza','plumber',
        'plumbing','real-estate','realtor','remodeling','restaurant','roofing',
        'skincare','spa','towing','tutor','tutoring','yoga',
    ];

    public function publishFree(\WP_REST_Request $request): \WP_REST_Response
    {
        $data      = $request->get_json_params() ?: [];
        $demoToken = trim((string) ($data['demo_token'] ?? ''));
        $email     = strtolower(trim((string) ($data['email'] ?? '')));
        $slug      = strtolower(trim((string) ($data['slug'] ?? '')));
        $name      = trim((string) ($data['name'] ?? ''));

        if ($demoToken === '' || $email === '' || $slug === '') {
            return new \WP_REST_Response(['success' => false, 'error' => 'missing_fields'], 400);
        }
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'bad_email'], 400);
        }
        if (!preg_match('/^[a-z0-9][a-z0-9-]{1,58}[a-z0-9]$/', $slug)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'bad_slug'], 400);
        }
        // Mobile number is required (mirrors the publish form's client check): it is
        // how the owner gets SMS alerts when a customer contacts their site, and the
        // reliable channel for the dashboard link (the "your site is live" email often
        // lands in spam). Validated before any account is created. 2026-07-22.
        $phone = preg_replace('/[^0-9+]/', '', (string) ($data['phone'] ?? ''));
        if (strlen(preg_replace('/\D/', '', (string) $phone)) < 10) {
            return new \WP_REST_Response(['success' => false, 'error' => 'bad_phone'], 400);
        }
        // The signed token must resolve to a real demo — this is the auth gate.
        if (!class_exists('\\Instinctor\\Services\\DemoService')
            || (int) \Instinctor\Services\DemoService::resolveToken($demoToken) <= 0) {
            return new \WP_REST_Response(['success' => false, 'error' => 'bad_demo_token'], 403);
        }
        // Light anti-abuse: cap publishes per IP.
        if (class_exists('\\Instinctor\\Core\\Security')) {
            $ip = \Instinctor\Core\Security::clientIp();
            if (!\Instinctor\Core\Security::rateLimit('publish_free', $ip)) {
                return new \WP_REST_Response(['success' => false, 'error' => 'rate_limited'], 429);
            }
        }

        // SECURITY: this endpoint is public (auth is only the signed demo token, which
        // proves a demo exists, NOT who the caller is). It may therefore ONLY create a
        // brand-new account. If the email already belongs to someone, refuse — otherwise
        // an anonymous caller could publish with a victim's email and receive a working
        // SSO login token for that existing account (provisionSoloSite reuses accounts by
        // email; /sso resolves the session by email). Existing owners sign in normally.
        $usersT = \Instinctor\Core\DB::prefix() . 'instinctor_users';
        $existingUid = (int) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
            "SELECT id FROM {$usersT} WHERE email = %s LIMIT 1", $email
        ));
        if ($existingUid > 0) {
            return new \WP_REST_Response(['success' => false, 'error' => 'email_in_use'], 409);
        }

        $prov = $this->provisionSoloSite($email, $name, $slug, '', $demoToken, 'free_publish', (string) $phone);
        if (isset($prov['error'])) {
            return new \WP_REST_Response(['success' => false, 'error' => $prov['error']], $prov['code'] ?? 500);
        }
        $userId = $prov['user_id'];
        $pageId = $prov['page_id'];

        // Phone (validated + required above) is stored on the owner so lifecycle
        // nudges can reach them and the "your site is live" link can be texted: a
        // free owner's email often lands in spam and is never seen, so a text is how
        // they actually get back in.
        if (class_exists('\\Instinctor\\Core\\Config')) {
            \Instinctor\Core\Config::setForOwner((int) $userId, 'instinctor_store_phone', $phone);
        }

        $baseUrl  = defined('INSTINCTOR_STANDALONE_URL') ? rtrim(INSTINCTOR_STANDALONE_URL, '/') : 'https://instinctor.com';
        $siteUrl  = $baseUrl . '/' . $slug;
        $loginNow = $this->mintSsoUrl($userId, $email, $name, '/dashboard?welcome=1', 600); // 10-min immediate hand-off (welcome=1 -> "check your email/spam" banner)
        $magic    = $this->mintSsoUrl($userId, $email, $name, '/dashboard', 7 * 86400);    // 7-day return link

        // "Your site is live" email with the magic-link back in.
        if (class_exists('\\Instinctor\\Core\\Mailer')) {
            $hi   = $name !== '' ? 'Hi ' . htmlspecialchars($name) . ',' : 'Hi,';
            $body = '<div style="font-family:system-ui,-apple-system,sans-serif;max-width:520px;margin:0 auto;color:#1d2327;line-height:1.6">'
                  . '<p>' . $hi . '</p>'
                  . '<p>Your website is live: <a href="' . htmlspecialchars($siteUrl) . '">' . htmlspecialchars($siteUrl) . '</a></p>'
                  . '<p><a href="' . htmlspecialchars($magic) . '" style="display:inline-block;background:#1d2327;color:#fff;padding:11px 20px;border-radius:8px;text-decoration:none">Open your dashboard</a></p>'
                  . '<p style="color:#666;font-size:13px">This button signs you in for the next 7 days. From your dashboard you can edit your site, get text alerts (with Pro), and put it on your own domain.</p>'
                  . '<p style="color:#888;font-size:12px;margin-top:24px">Instinctor</p></div>';
            try { \Instinctor\Core\Mailer::send($email, 'Your website is live', $body, ['html' => true]); } catch (\Throwable $e) {}
        }

        // SMS the same link. Deliverability: the email can spam-filter and never be seen,
        // so a text is the reliable way a free owner gets back to their dashboard. Vendor
        // context (us texting our own new user), so the free-tier SMS gate must not apply.
        if ($phone !== '' && class_exists('\\Instinctor\\Core\\Sms')) {
            \Instinctor\Core\Tenant::withOwner(null, function () use ($phone, $siteUrl, $magic) {
                try {
                    \Instinctor\Core\Sms::send($phone, 'Your website is live at ' . $siteUrl
                        . ' . Open your dashboard (keeps you signed in for 7 days): ' . $magic);
                } catch (\Throwable $e) {}
            });
        }

        // Register the new free site on the platform so it shows in Customers
        // (free-publish has no Stripe step, so these tenants are otherwise invisible).
        $this->registerFreeSiteOnPlatform($email, $name, $slug, $siteUrl);

        return new \WP_REST_Response([
            'success'      => true,
            'user_id'      => $userId,
            'page_id'      => $pageId,
            'slug'         => $slug,
            'site_url'     => $siteUrl,
            'redirect_url' => $loginNow,   // browser follows this to land signed-in on /dashboard
        ], 200);
    }

    /**
     * Tell the platform about a freshly free-published site so it appears in
     * Platform > Customers (tier=free). Best-effort: a failure never blocks the
     * publish. Authed with the shared platform token (same bridge as demo-build).
     */
    private function registerFreeSiteOnPlatform(string $email, string $name, string $slug, string $siteUrl): void
    {
        $token = defined('INSTINCTOR_PLATFORM_TOKEN') ? (string) INSTINCTOR_PLATFORM_TOKEN : '';
        if ($token === '') return;
        try {
            wp_remote_post('https://platform.instinctor.com/api/register-free-site', [
                'headers'  => ['X-Platform-Token' => $token, 'Content-Type' => 'application/json'],
                'body'     => wp_json_encode(['email' => $email, 'name' => $name, 'slug' => $slug, 'site_url' => $siteUrl]),
                'timeout'  => 8,
            ]);
        } catch (\Throwable $e) {}
    }

    /**
     * POST /login/magic   (public)
     *
     * A returning site owner enters their email; if an account exists we email a
     * 7-day magic link back into /dashboard. ALWAYS returns success (never reveals
     * whether the email has an account) and is rate-limited per IP. Restricted to
     * site_owner accounts: vendors/staff sign in with a password at /admin/login.
     */
    public function loginMagic(\WP_REST_Request $request): \WP_REST_Response
    {
        $data  = $request->get_json_params() ?: [];
        $email = strtolower(trim((string) ($data['email'] ?? '')));
        if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'bad_email'], 400);
        }
        // Per-IP throttle (anti-abuse + anti-enumeration): 8 sends / hour.
        if (class_exists('\\Instinctor\\Core\\Security')) {
            $ip = \Instinctor\Core\Security::clientIp();
            if (!\Instinctor\Core\Security::rateLimit('login_magic', $ip, 8, 3600)) {
                return new \WP_REST_Response(['success' => false, 'error' => 'rate_limited'], 429);
            }
        }
        // Resolve a site_owner by email. On a hit, mint + email the link; on a miss,
        // do nothing. Either way the response is identical (no email enumeration).
        $usersT = \Instinctor\Core\DB::prefix() . 'instinctor_users';
        $row = \Instinctor\Core\DB::get_row(\Instinctor\Core\DB::prepare(
            "SELECT id, email, display_name FROM {$usersT} WHERE email = %s AND role = 'site_owner' LIMIT 1",
            $email
        ));
        if ($row && (int) $row->id > 0) {
            $name  = trim((string) ($row->display_name ?? ''));
            $magic = $this->mintSsoUrl((int) $row->id, (string) $row->email, $name, '/dashboard', 7 * 86400);
            if (class_exists('\\Instinctor\\Core\\Mailer')) {
                $hi   = $name !== '' ? 'Hi ' . htmlspecialchars($name) . ',' : 'Hi,';
                $body = '<div style="font-family:system-ui,-apple-system,sans-serif;max-width:520px;margin:0 auto;color:#1d2327;line-height:1.6">'
                      . '<p>' . $hi . '</p>'
                      . '<p>Here is your link back into your dashboard:</p>'
                      . '<p><a href="' . htmlspecialchars($magic) . '" style="display:inline-block;background:#1d2327;color:#fff;padding:11px 20px;border-radius:8px;text-decoration:none">Open your dashboard</a></p>'
                      . '<p style="color:#666;font-size:13px">This link signs you in for the next 7 days. If you did not ask for it, you can ignore this email.</p>'
                      . '<p style="color:#888;font-size:12px;margin-top:24px">Instinctor</p></div>';
                try { \Instinctor\Core\Mailer::send((string) $row->email, 'Your dashboard link', $body, ['html' => true]); } catch (\Throwable $e) {}
            }
        }
        return new \WP_REST_Response(['success' => true], 200);
    }

    /**
     * POST /domain/request   (public)
     *
     * A visitor asks us to put their site on their own .com. Custom domains need
     * DNS + SSL work on our side, so this is a done-for-you request, NOT self-serve.
     * The request is STORED in form_submissions (shows in the admin inbox) so it
     * survives even if email delivery is down; an email ping is best-effort on top.
     */
    public function domainRequest(\WP_REST_Request $request): \WP_REST_Response
    {
        $data   = $request->get_json_params() ?: [];
        $email  = strtolower(trim((string) ($data['email'] ?? '')));
        $domain = mb_substr(trim((string) ($data['domain'] ?? '')), 0, 253);
        $name   = mb_substr(trim((string) ($data['name'] ?? '')), 0, 120);

        if ($email === '' || !filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'bad_email'], 400);
        }
        // Loose domain sanity check (label.tld). We verify availability by hand later.
        if (!preg_match('/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)+$/i', $domain)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'bad_domain'], 400);
        }
        if (class_exists('\\Instinctor\\Core\\Security')) {
            $ip = \Instinctor\Core\Security::clientIp();
            if (!\Instinctor\Core\Security::rateLimit('domain_request', $ip, 10, 3600)) {
                return new \WP_REST_Response(['success' => false, 'error' => 'rate_limited'], 429);
            }
        }

        $ip    = class_exists('\\Instinctor\\Core\\Security') ? \Instinctor\Core\Security::clientIp() : '';
        $table = \Instinctor\Core\DB::prefix() . 'instinctor_form_submissions';
        $stored = \Instinctor\Core\DB::insert($table, [
            'form_name'  => 'domain_request',
            'form_id'    => 0,
            'data'       => wp_json_encode(['email' => $email, 'domain' => $domain, 'name' => $name, 'source' => '/instudio']),
            'ip_address' => $ip,
            'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '',
            'user_id'    => 0,
            'created_at' => current_time('mysql'),
        ]);
        if (!$stored) {
            return new \WP_REST_Response(['success' => false, 'error' => 'store_failed'], 500);
        }

        // Best-effort notification (never affects the response).
        if (class_exists('\\Instinctor\\Core\\Mailer')) {
            $body = "Own-domain request from /instudio.\n\n"
                  . 'Domain: ' . $domain . "\n"
                  . 'Email: ' . $email . "\n"
                  . 'Name: ' . ($name !== '' ? $name : '(not given)') . "\n";
            try { \Instinctor\Core\Mailer::send('sergeitiden@gmail.com', 'Own-domain request: ' . $domain, $body); } catch (\Throwable $e) {}
        }

        return new \WP_REST_Response(['success' => true], 200);
    }

    /**
     * POST /solo/set-pro   (platform-token auth)
     *
     * Flip a tenant owner's Pro plan. The platform calls this on Pro checkout
     * (pro=1) and on subscription cancel / lapse (pro=0). Writes the per-owner
     * flag that TenantPlan reads to gate SMS in the free-publish model.
     *
     * Body: { email | slug, pro: 1|0 }. Owner resolved by email (their account)
     * or by the slug of a page they own. Idempotent.
     */
    public function soloSetPro(\WP_REST_Request $request): \WP_REST_Response
    {
        $data  = $request->get_json_params() ?: [];
        $email = strtolower(trim((string) ($data['email'] ?? '')));
        $slug  = strtolower(trim((string) ($data['slug'] ?? '')));
        $pro   = !empty($data['pro']) && (string) $data['pro'] !== '0' ? '1' : '0';

        $usersT = \Instinctor\Core\DB::prefix() . 'instinctor_users';
        $pagesT = \Instinctor\Core\DB::prefix() . 'instinctor_pages';

        $ownerId = 0;
        if ($email !== '') {
            $ownerId = (int) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
                "SELECT id FROM {$usersT} WHERE email = %s LIMIT 1", $email
            ));
        }
        if ($ownerId <= 0 && $slug !== '') {
            $ownerId = (int) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
                "SELECT owner_id FROM {$pagesT} WHERE slug = %s AND owner_id IS NOT NULL ORDER BY id ASC LIMIT 1", $slug
            ));
        }
        if ($ownerId <= 0) {
            return new \WP_REST_Response(['success' => false, 'error' => 'owner_not_found'], 404);
        }

        \Instinctor\Core\Config::setForOwner($ownerId, \Instinctor\Core\TenantPlan::FLAG, $pro);

        // Directory membership follows the Pro flag (LOCKED: Pro-gated).
        // Best-effort: a miss here is fixable on the platform curation screen.
        try {
            \Instinctor\Services\DirectoryService::syncTenantListing($ownerId, $pro === '1');
        } catch (\Throwable $e) {
            error_log('[Directory] tenant listing sync failed for owner ' . $ownerId . ': ' . $e->getMessage());
        }

        // Pro subdomain: on activation, enqueue provisioning of <slug>.instinctor.com
        // (nginx block + Let's Encrypt cert). PHP (www-data) only writes a spool file;
        // a root cron worker does the privileged nginx/certbot work. The path->subdomain
        // redirect works instantly via HostRouter; the cert lands within a cron cycle.
        if ($pro === '1') {
            self::enqueueSubdomainProvision($ownerId);
            // ...and their OWN standalone install behind that host. Pro has always
            // meant a standalone; until now the install was done by hand while the
            // subdomain served the shared app. Same spool-plus-root-cron shape as
            // above: www-data only drops a job file, the privileged work (DB, code,
            // nginx docroot swap, cert) happens in provision_pro_standalone.sh.
            // The subdomain keeps serving the shared app until the swap lands, so
            // there is no window where the owner's site is unreachable.
            self::enqueueStandaloneProvision($ownerId);
        }

        return new \WP_REST_Response([
            'success'  => true,
            'owner_id' => $ownerId,
            'pro'      => $pro === '1',
        ], 200);
    }

    /**
     * AI Domain Finder — platform payment callback (X-Platform-Token).
     * The platform calls this after an owner completes the Stripe Checkout for a
     * domain; it resolves the owner and drives NameSilo registration through the
     * shared DomainService::fulfill(). Idempotent on purchase_ref, so a Stripe
     * webhook retry (or a platform re-call) is a safe no-op. The platform caller
     * MUST use a >= 30s timeout: NameSilo registration is slower than a flag flip.
     *
     * Always returns 200 with a status the platform branches on:
     *   registered   -> done
     *   unavailable  -> the buyer paid but the name was taken in the gap; the
     *                   platform must refund or offer an alternative
     *   failed       -> NameSilo error; platform may retry (safe) or alert
     *
     * Body: {session_id, email|slug, domain, years, page_id, amount_cents}.
     */
    public function soloDomainFulfill(\WP_REST_Request $request): \WP_REST_Response
    {
        $data      = $request->get_json_params() ?: [];
        $sessionId = trim((string) ($data['session_id'] ?? ''));
        $email     = strtolower(trim((string) ($data['email'] ?? '')));
        $slug      = strtolower(trim((string) ($data['slug'] ?? '')));
        $domain    = strtolower(trim((string) ($data['domain'] ?? '')));
        $years     = max(1, min(10, (int) ($data['years'] ?? 1)));
        $pageId    = (int) ($data['page_id'] ?? 0) ?: null;
        $cents     = (int) ($data['amount_cents'] ?? 0);

        if ($sessionId === '' || $domain === '') {
            return new \WP_REST_Response(['success' => false, 'error' => 'missing_session_or_domain'], 422);
        }

        $usersT = \Instinctor\Core\DB::prefix() . 'instinctor_users';
        $pagesT = \Instinctor\Core\DB::prefix() . 'instinctor_pages';

        $ownerId = 0;
        if ($email !== '') {
            $ownerId = (int) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
                "SELECT id FROM {$usersT} WHERE email = %s LIMIT 1", $email
            ));
        }
        if ($ownerId <= 0 && $slug !== '') {
            $ownerId = (int) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
                "SELECT owner_id FROM {$pagesT} WHERE slug = %s AND owner_id IS NOT NULL ORDER BY id ASC LIMIT 1", $slug
            ));
        }
        // A missing owner must NOT abort a paid registration — the buyer paid, so
        // we still register (owner_id null) and attach it later.

        $result = \Instinctor\Services\DomainService::fulfill([
            'purchase_ref'   => 'stripe:' . $sessionId . ':' . $domain,
            'source'         => 'platform',
            'domain'         => $domain,
            'years'          => $years,
            'owner_id'       => $ownerId ?: null,
            'page_id'        => $pageId,
            'retail_charged' => $cents > 0 ? round($cents / 100, 2) : null,
        ]);

        $status = (string) ($result['status'] ?? 'unknown');
        return new \WP_REST_Response([
            'success'  => $status === 'registered',
            'status'   => $status,
            'domain'   => $domain,
            'owner_id' => $ownerId,
            'detail'   => $result['detail'] ?? null,
        ], 200);
    }

    /**
     * Drop a spool file named <slug> into storage/subdomain-queue/ for the root
     * cron worker (bin/provision_subdomains.sh) to pick up. Best-effort; the
     * subdomain redirect does not depend on it.
     */
    /** Spool dir under storage/, created on demand. '' when storage is unwritable. */
    private static function provisionSpool(string $name): string
    {
        $base = '';
        if (defined('INSTINCTOR_STORAGE_PATH'))     $base = (string) INSTINCTOR_STORAGE_PATH;
        elseif (defined('INSTINCTOR_STORAGE_DIR'))  $base = (string) INSTINCTOR_STORAGE_DIR;
        if ($base === '') $base = dirname(__DIR__, 3) . '/storage/uploads';
        $base = rtrim($base, '/');
        if (substr($base, -8) === '/uploads') $base = substr($base, 0, -8);
        $dir = $base . '/' . $name;
        if (!is_dir($dir)) @mkdir($dir, 0775, true);
        return (is_dir($dir) && is_writable($dir)) ? $dir : '';
    }

    /**
     * Queue the owner's own standalone install (the thing Pro actually is).
     * Job file is named for the slug and contains the owner id; the root worker
     * needs both and must not have to query the database to get them.
     */
    private static function enqueueStandaloneProvision(int $ownerId): void
    {
        try {
            $pagesT = \Instinctor\Core\DB::prefix() . 'instinctor_pages';
            $slug = strtolower(trim((string) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
                "SELECT slug FROM {$pagesT} WHERE owner_id = %d AND status = 'published' AND slug <> '' ORDER BY id ASC LIMIT 1",
                $ownerId
            ))));
            if ($slug === '' || !preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $slug)) return;
            $dir = self::provisionSpool('standalone-queue');
            if ($dir !== '') @file_put_contents($dir . '/' . $slug, (string) $ownerId);
        } catch (\Throwable $e) {
            // best-effort; re-running set-pro re-queues it
        }
    }

    private static function enqueueSubdomainProvision(int $ownerId): void
    {
        try {
            $pagesT = \Instinctor\Core\DB::prefix() . 'instinctor_pages';
            $slug = strtolower(trim((string) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
                "SELECT slug FROM {$pagesT} WHERE owner_id = %d AND status = 'published' AND slug <> '' ORDER BY id ASC LIMIT 1",
                $ownerId
            ))));
            if ($slug === '' || !preg_match('/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/', $slug)) return;

            $base = '';
            if (defined('INSTINCTOR_STORAGE_PATH'))     $base = (string) INSTINCTOR_STORAGE_PATH;
            elseif (defined('INSTINCTOR_STORAGE_DIR'))  $base = (string) INSTINCTOR_STORAGE_DIR;
            if ($base === '') {
                // Fallback (standalone layout: public_html/includes/Api -> install root).
                $base = dirname(__DIR__, 3) . '/storage/uploads';
            }
            $base = rtrim($base, '/');
            if (substr($base, -8) === '/uploads') $base = substr($base, 0, -8);   // storage root
            $spoolDir = $base . '/subdomain-queue';
            if (!is_dir($spoolDir)) @mkdir($spoolDir, 0775, true);
            if (is_dir($spoolDir) && is_writable($spoolDir)) {
                @file_put_contents($spoolDir . '/' . $slug, (string) time());
            }
        } catch (\Throwable $e) {
            // best-effort; provisioning can also be triggered by re-running set-pro
        }
    }

    // ── Directory admin bridge ─────────────────────────────────────────────
    // Called server-to-server by the platform's DirectoryController proxies.
    // Validation and the trade taxonomy stay in ONE place (DirectoryService /
    // DirectoryTrades); the platform UI knows nothing about either.

    /** GET /directory/admin/list — every row + trade registry + config. */
    public function directoryAdminList(\WP_REST_Request $request): \WP_REST_Response
    {
        return new \WP_REST_Response([
            'success'  => true,
            'listings' => \Instinctor\Services\DirectoryService::allForAdmin(),
            'stats'    => \Instinctor\Services\DirectoryService::stats30(),
            'trades'   => \Instinctor\Core\DirectoryTrades::all(),
            'config'   => [
                'public'       => \Instinctor\Services\DirectoryService::publicEnabled() ? 1 : 0,
                'min_listings' => \Instinctor\Services\DirectoryService::minListings(),
            ],
        ], 200);
    }

    /** POST /directory/admin/save — body: listing columns, + id to update. */
    public function directoryAdminSave(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params() ?: [];
        $id   = \Instinctor\Services\DirectoryService::save($data, (int) ($data['id'] ?? 0));
        if ($id === 0) {
            return new \WP_REST_Response([
                'success' => false,
                'error'   => 'Business name is required and the slug must be unique.',
            ], 400);
        }
        return new \WP_REST_Response(['success' => true, 'id' => $id], 200);
    }

    /** POST /directory/admin/delete — body: { id }. */
    public function directoryAdminDelete(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params() ?: [];
        $id   = (int) ($data['id'] ?? 0);
        if ($id <= 0) {
            return new \WP_REST_Response(['success' => false, 'error' => 'id required'], 400);
        }
        \Instinctor\Services\DirectoryService::delete($id);
        return new \WP_REST_Response(['success' => true], 200);
    }

    /** POST /directory/admin/config — body: { public?, min_listings? }. */
    public function directoryAdminConfig(\WP_REST_Request $request): \WP_REST_Response
    {
        $data = $request->get_json_params() ?: [];
        if (array_key_exists('public', $data)) {
            \Instinctor\Core\Config::set('instinctor_directory_public', !empty($data['public']) ? 1 : 0);
        }
        if (array_key_exists('min_listings', $data)) {
            \Instinctor\Core\Config::set('instinctor_directory_min_listings', max(1, (int) $data['min_listings']));
        }
        return new \WP_REST_Response([
            'success' => true,
            'config'  => [
                'public'       => \Instinctor\Services\DirectoryService::publicEnabled() ? 1 : 0,
                'min_listings' => \Instinctor\Services\DirectoryService::minListings(),
            ],
        ], 200);
    }

    /** GET /directory/admin/messages — contact messages, newest first. */
    public function directoryAdminMessages(\WP_REST_Request $request): \WP_REST_Response
    {
        return new \WP_REST_Response([
            'success'  => true,
            'messages' => \Instinctor\Services\DirectoryService::messagesForAdmin(),
        ], 200);
    }

    /** POST /directory/admin/messages/resend — body: { id }. */
    public function directoryAdminResend(\WP_REST_Request $request): \WP_REST_Response
    {
        $id = (int) (($request->get_json_params() ?: [])['id'] ?? 0);
        if ($id <= 0) {
            return new \WP_REST_Response(['success' => false, 'error' => 'id required'], 400);
        }
        $ok = \Instinctor\Services\DirectoryService::resendMessage($id);
        return new \WP_REST_Response([
            'success' => $ok,
            'error'   => $ok ? null : 'Nothing delivered. Does the listing have notify contacts?',
        ], 200);
    }

    /** POST /directory/admin/rotate-token — body: { id }. Returns the new token. */
    public function directoryAdminRotateToken(\WP_REST_Request $request): \WP_REST_Response
    {
        $id = (int) (($request->get_json_params() ?: [])['id'] ?? 0);
        if ($id <= 0 || !\Instinctor\Services\DirectoryService::get($id)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'listing not found'], 404);
        }
        return new \WP_REST_Response([
            'success' => true,
            'token'   => \Instinctor\Services\DirectoryService::rotateManageToken($id),
        ], 200);
    }

    /**
     * POST /solo/deprovision   (platform-token auth)
     *
     * Cascade from the platform's "delete customer": trash every page owned by
     * that customer's account, so the site drops out of the page grid, the
     * sitemap, and instinctor.com/<slug>. Recoverable (status 'trash', not a hard
     * delete), and the owner's Pro flag is cleared. Owner resolved by email, else
     * by the slug of a page they own.
     *
     * Idempotent: a customer with no instinctor site (managed / DIY-WP only)
     * returns success with trashed=0, so the platform's delete never fails on it.
     *
     * Guarded: only pages owned by a 'site_owner' account are ever touched, so a
     * stray email/slug cannot trash the vendor's marketing pages or demo templates.
     *
     * Body: { email | slug }
     */
    public function soloDeprovision(\WP_REST_Request $request): \WP_REST_Response
    {
        $data  = $request->get_json_params() ?: [];
        $email = strtolower(trim((string) ($data['email'] ?? '')));
        $slug  = strtolower(trim((string) ($data['slug'] ?? '')));

        $usersT = \Instinctor\Core\DB::prefix() . 'instinctor_users';
        $pagesT = \Instinctor\Core\DB::prefix() . 'instinctor_pages';

        // Resolve the customer's OWN account (role site_owner). Email first (their
        // account), then the owner of a page carrying this slug.
        $owner = null;
        if ($email !== '') {
            $owner = \Instinctor\Core\DB::get_row(\Instinctor\Core\DB::prepare(
                "SELECT id, role FROM {$usersT} WHERE email = %s LIMIT 1", $email
            ), ARRAY_A);
        }
        if (!$owner && $slug !== '') {
            $oid = (int) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
                "SELECT owner_id FROM {$pagesT} WHERE slug = %s AND owner_id IS NOT NULL ORDER BY id ASC LIMIT 1", $slug
            ));
            if ($oid > 0) {
                $owner = \Instinctor\Core\DB::get_row(\Instinctor\Core\DB::prepare(
                    "SELECT id, role FROM {$usersT} WHERE id = %d LIMIT 1", $oid
                ), ARRAY_A);
            }
        }

        // Nothing on instinctor.com behind this customer — a no-op success so the
        // platform's customer-delete proceeds regardless.
        if (!$owner) {
            return new \WP_REST_Response(['success' => true, 'trashed' => 0, 'owner_id' => 0], 200);
        }

        // Never let a stray email/slug that resolves to the vendor/admin (marketing
        // pages, demo templates) be trashed by a customer-delete cascade.
        if (($owner['role'] ?? '') !== 'site_owner') {
            return new \WP_REST_Response(['success' => false, 'error' => 'owner_not_site_owner'], 409);
        }
        $ownerId = (int) $owner['id'];
        // Same guard as DunningService::removeOwnerSite: owner 0 would sweep the
        // vendor's registry templates as "this owner's pages".
        if ($ownerId <= 0) {
            return new \WP_REST_Response(['success' => false, 'error' => 'owner_invalid'], 409);
        }

        // Slugs first (for the log), then trash every non-trash page they own.
        $slugs = \Instinctor\Core\DB::get_col(\Instinctor\Core\DB::prepare(
            "SELECT slug FROM {$pagesT} WHERE owner_id = %d AND status != 'trash'", $ownerId
        ));
        \Instinctor\Core\DB::query(\Instinctor\Core\DB::prepare(
            "UPDATE {$pagesT} SET status = 'trash' WHERE owner_id = %d AND status != 'trash'", $ownerId
        ));

        // The customer is gone; clear any lingering Pro flag and hide their
        // directory listing (never deleted: the profile URL is permanent).
        \Instinctor\Core\Config::setForOwner($ownerId, \Instinctor\Core\TenantPlan::FLAG, '0');
        try {
            \Instinctor\Services\DirectoryService::syncTenantListing($ownerId, false);
        } catch (\Throwable $e) {
            error_log('[Directory] deprovision listing sync failed for owner ' . $ownerId . ': ' . $e->getMessage());
        }

        error_log('[Instinctor/deprovision] owner ' . $ownerId . ' trashed ' . count($slugs)
            . ' page(s): ' . implode(',', (array) $slugs));

        return new \WP_REST_Response([
            'success'  => true,
            'owner_id' => $ownerId,
            'trashed'  => count($slugs),
            'slugs'    => array_values((array) $slugs),
        ], 200);
    }

    // Roll endpoints removed 2026-05-23 (no scene mode).
}
