<?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',
            ],
        ]);
        // 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',
            ],
        ]);
        // 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',
            ],
        ]);
        // 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'],
            ],
        ]);
        // 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'],
            ],
        ]);

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

        // 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'],
            ],
        ]);
        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.
        // Free build: soft-cap at 3 pages. Build-time, not license-gated; the
        // editor opens the upgrade modal on this response.
        if (\instinctor_locked('multipage')
            && \Instinctor\Services\PageService::countType('page') >= 3) {
            return new \WP_REST_Response([
                'error'   => 'page_limit',
                'upgrade' => true,
                'message' => 'The free plan includes 3 pages. Upgrade to Instinctor Pro for unlimited pages.',
            ], 403);
        }

        $_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 (!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['address_autocomplete'])) $config['address_autocomplete'] = (bool) $cd['address_autocomplete'];
                if (isset($cd['google_places_key']))   $config['google_places_key']     = sanitize_text_field($cd['google_places_key']);
                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);
                if ($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 ───────────────────────────────────────────────────────
            'section'      => ['bg_color' => 'transparent', 'padding_top' => 20, 'padding_bottom' => 20, 'padding_left' => 20, 'padding_right' => 20, 'content_align' => 'center', 'valign' => 'center'],
            '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],
            '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);
            }
        }
    }

    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);

        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' => $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);
                }
            }

            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'] : '',
        ]);
    }

    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);
            return $pg ? (string) ($pg['name'] ?? '') : '';
        } 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;
            $exitPages[] = [
                'page_id'  => $pid,
                'label'    => $lbl,
                'screens'  => self::countPageScreens($pid),
                '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],
            'page_id' => $pageId,
            'totals' => $svc::totals($from, $today),
            'viewsOverTime' => $svc::viewsOverTime($from, $today, $pageId),
            '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
                );
            }
        }
        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);
        }
        $reserved = [
            'admin','api','assets','d','depth-maps','designers','help','hire',
            'mail','partners','pricing','r','reset-password','sso','start',
            'status','subscribe','support','uploads','www','instinctor'
        ];
        if (in_array($slug, $reserved, 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);

        $trade = sanitize_text_field((string) $request->get_param('trade'));
        if ($trade === '') $trade = 'salon';
        $name = (string) $request->get_param('name');
        $city = sanitize_text_field((string) $request->get_param('city'));
        $url  = esc_url_raw((string) $request->get_param('url'));

        $res = \Instinctor\Services\DemoService::startDemo($trade, $name, $city, $url);
        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.
     */
    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;

        // 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
        }

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

        $count = 0;
        $apply = function (array &$nodes) use (&$apply, $photoMap, $textMap, $hexMap, $rgbMap, &$count): void {
            foreach ($nodes as &$n) {
                if (!empty($n['settings']) && is_array($n['settings'])) {
                    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) {
            $els = \Instinctor\Services\PageService::getElements($pid);
            $apply($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 ($mode !== null) {
            $settings['body_scroll']    = ($mode === 'cinematic') ? 'snap' : 'classic';
            $settings['cinematic_bare'] = ($mode === 'cinematic') ? 1 : 0;
            $settingsDirty = true;
        }
        if ($settingsDirty) \Instinctor\Services\PageService::saveSettings($pid, $settings);

        return new \WP_REST_Response(['success' => true, 'applied' => $count], 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
     */
    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);
        }

        $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). They are
        // the tenant owner of their site; their pages carry owner_id = their id,
        // and the data layer scopes their bookings/CRM/chat/settings to it.
        $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));
            // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, no WP_Filesystem in this path
            $meta = json_encode(['source' => 'solo_signup', 'license_key' => $lic]);
            // 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 new \WP_REST_Response(['error' => 'user_create_failed'], 500);
        }

        // The tenant owns their own pages.
        $ownerId = $userId;

        // Slug must be unique across the whole site (it is the tenant's URL
        // prefix, instinctor.com/<slug>). Form live-validates; guard the race.
        $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(['error' => 'slug_taken'], 409);
        }

        // "Make it yours" — if a demo token is supplied, seed the new page with
        // the demo's generated content (their personalized site) instead of a
        // blank page. Purely additive: no token => blank page, as before.
        $seedEls = '[]';
        $seedSettings = '{}';
        $demoBiz = '';
        $demoToken = trim((string) ($data['demo_token'] ?? ''));
        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'];
                    $ds = json_decode((string) ($drow['settings'] ?? '{}'), true) ?: [];
                    $demoBiz = trim((string) ($ds['demo_business_name'] ?? ''));
                    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 new \WP_REST_Response(['error' => 'page_create_failed'], 500);
        }

        // Mint an SSO token so Platform can redirect the browser to
        // /sso?token=… and land the user logged in on the editor for
        // this new page. Reuse the same HMAC format consumed by
        // Frontend\SSOController::sso (payload + base64url + HMAC-SHA256
        // signed with INSTINCTOR_PLATFORM_TOKEN).
        $secret = defined('INSTINCTOR_PLATFORM_TOKEN') ? INSTINCTOR_PLATFORM_TOKEN : '';
        $payload = [
            'user_id' => $userId,
            'email'   => $email,
            'name'    => $name,
            'exp'     => time() + 300, // 5-min window for checkout → SSO hand-off
        ];
        // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string ops required for standalone, 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);
        $ssoToken = $payloadB64 . '.' . $sig;

        $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'    => $baseUrl . '/sso?token=' . rawurlencode($ssoToken) .
                            '&return=' . rawurlencode('/admin/builder/' . $pageId),
            'site_url'   => $baseUrl . '/instinctor/' . $slug,
        ], 200);
    }

    // Roll endpoints removed 2026-05-23 (no scene mode).
}
