<?php

declare(strict_types=1);

namespace Instinctor\Frontend;

use Instinctor\Core\Config;
use Instinctor\Core\DB;
use Instinctor\Core\Auth;
use Instinctor\Core\Session;
use Instinctor\Core\Security;
use Instinctor\Core\Mailer;

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

/**
 * Frontend Controller - handles the storefront
 */
class FrontendController
{
    public function __construct()
    {
        add_action('wp_enqueue_scripts', [$this, 'enqueueAssets']);
        add_action('init', [$this, 'registerShortcodes']);
        add_action('init', [$this, 'registerEndpoints']);
        add_action('init', [$this, 'handleSubscriptionCallback']);
        // Google OAuth handoff removed 2026-07-05 (SocialLoginService gone).

        // Standalone: add_action/do_action are no-op shims, so init-hooked
        // registrations never fire. Register shortcodes directly; in WP the
        // init-hooked call still runs and add_shortcode overwrites idempotently.
        if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
            $this->registerShortcodes();
        }

        // Suppress WP admin bar and body margin in Impulse preview mode
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        if (!empty($_GET['impulse_preview'])) {
            add_filter('show_admin_bar', '__return_false');
            add_action('wp_enqueue_scripts', function () {
                wp_add_inline_style('instinctor-page-inline', 'html,body{margin-top:0!important;padding-top:0!important;overflow:hidden!important;scrollbar-width:none!important}body::-webkit-scrollbar{display:none!important}#wpadminbar{display:none!important}');
            }, 99);
        }

        // SEO: redirects, sitemap, robots.txt
        add_action('init', [$this, 'handleSEOURLs'], 1);
        add_action('template_redirect', ['\Instinctor\Services\SEOService', 'handle404Redirect'], 5);
        add_filter('template_include', [$this, 'loadTemplates']);
        add_action('pre_get_posts', [$this, 'handleImpulsePreview']);
        
        // Addon AJAX handlers (new ones not already registered elsewhere)
        $this->registerAjax('instinctor_buy_now', [$this, 'ajaxBuyNow']);
        add_action('wp_ajax_nopriv_instinctor_buy_now', [$this, 'ajaxBuyNow']);
        $this->registerAjax('instinctor_apply_coupon', [$this, 'ajaxApplyCoupon']);
        add_action('wp_ajax_nopriv_instinctor_apply_coupon', [$this, 'ajaxApplyCoupon']);
        $this->registerAjax('instinctor_submit_form', [$this, 'ajaxSubmitForm']);
        add_action('wp_ajax_nopriv_instinctor_submit_form', [$this, 'ajaxSubmitForm']);
        $this->registerAjax('instinctor_public_book', [$this, 'ajaxPublicBook']);
        add_action('wp_ajax_nopriv_instinctor_public_book', [$this, 'ajaxPublicBook']);
        $this->registerAjax('instinctor_booking_deposit_intent', [$this, 'ajaxBookingDepositIntent']);
        add_action('wp_ajax_nopriv_instinctor_booking_deposit_intent', [$this, 'ajaxBookingDepositIntent']);
        
        

        // Quick View AJAX handlers
        add_action('wp_ajax_instinctor_get_quickview', [$this, 'ajaxGetQuickView']);
        add_action('wp_ajax_nopriv_instinctor_get_quickview', [$this, 'ajaxGetQuickView']);
        add_action('wp_ajax_instinctor_add_to_cart', [$this, 'ajaxAddToCart']);
        add_action('wp_ajax_nopriv_instinctor_add_to_cart', [$this, 'ajaxAddToCart']);
        
        // Order tracking lookup (public)
        $this->registerAjax('instinctor_track_order', [$this, 'ajaxTrackOrder']);
        add_action('wp_ajax_nopriv_instinctor_track_order', [$this, 'ajaxTrackOrder']);
        
        // AJAX Login/Registration — nopriv (guests) + priv (already-logged-in refresh) + standalone
        add_action('wp_ajax_nopriv_instinctor_ajax_login',    [$this, 'ajaxLogin']);
        add_action('wp_ajax_instinctor_ajax_login',           [$this, 'ajaxLogin']);
        add_action('wp_ajax_nopriv_instinctor_ajax_register', [$this, 'ajaxRegister']);
        add_action('wp_ajax_instinctor_ajax_register',        [$this, 'ajaxRegister']);
        add_action('wp_ajax_instinctor_cancel_subscription',  [$this, 'ajaxCancelSubscription']);
        add_action('wp_ajax_instinctor_update_card',          [$this, 'ajaxUpdateCard']);
        if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_ajax_login',    [$this, 'ajaxLogin']);
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_ajax_register', [$this, 'ajaxRegister']);
            \Instinctor\Core\AjaxDispatcher::register('instinctor_cancel_subscription', [$this, 'ajaxCancelSubscription']);
            \Instinctor\Core\AjaxDispatcher::register('instinctor_update_card', [$this, 'ajaxUpdateCard']);
            // Public (nopriv) actions — guests can call these without an admin session
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_add_to_cart',       [$this, 'ajaxAddToCart']);
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_buy_now',           [$this, 'ajaxBuyNow']);
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_apply_coupon',      [$this, 'ajaxApplyCoupon']);
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_submit_form',       [$this, 'ajaxSubmitForm']);
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_public_book',       [$this, 'ajaxPublicBook']);
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_booking_deposit_intent', [$this, 'ajaxBookingDepositIntent']);
            
            
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_get_quickview',     [$this, 'ajaxGetQuickView']);
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_track_order',       [$this, 'ajaxTrackOrder']);
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_add_bundle_to_cart',[$this, 'ajaxAddBundleToCart']);
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_switch_currency',   [$this, 'ajaxSwitchCurrency']);
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_search_products',   [$this, 'ajaxSearchProducts']);
            \Instinctor\Core\AjaxDispatcher::registerPublic('instinctor_get_stock_status',  [$this, 'ajaxGetStockStatus']);
        }

        // AJAX Password Reset — WP mode only; standalone uses Router routes above
        add_action('wp_ajax_nopriv_instinctor_forgot_password', ['\\Instinctor\\Frontend\\PasswordResetController', 'ajaxForgot']);
        add_action('wp_ajax_nopriv_instinctor_reset_password',  ['\\Instinctor\\Frontend\\PasswordResetController', 'ajaxReset']);

        // Template redirect: intercept ?instinctor_action=reset_password on WP
        add_action('template_redirect', ['\\Instinctor\\Frontend\\PasswordResetController', 'maybeHandleWPReset']);
    }
    
    /**
     * AJAX: cancel a customer's own subscription (membership). Authed only.
     */
    /**
     * AJAX: update the card on file for a customer's own subscription. Authed.
     */
    public function ajaxUpdateCard(): void
    {
        if (!\Instinctor\Core\Auth::check()) {
            wp_send_json_error(['message' => __('Please log in.', 'instinctor')]);
        }
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via Session::verify HMAC nonce in the handler (e.g. 116-117); inputs sanitize_*'d
        $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
        // phpcs:enable WordPress.Security.NonceVerification
        if (!wp_verify_nonce($nonce, 'instinctor_frontend')) {
            wp_send_json_error(['message' => __('Security check failed.', 'instinctor')]);
        }
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via Session::verify HMAC nonce in the handler (e.g. 116-117); inputs sanitize_*'d
        $subId      = absint(wp_unslash($_POST['subscription_id'] ?? 0));
        // phpcs:enable WordPress.Security.NonceVerification
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via Session::verify HMAC nonce in the handler (e.g. 116-117); inputs sanitize_*'d
        $descriptor = sanitize_text_field(wp_unslash($_POST['descriptor'] ?? ''));
        // phpcs:enable WordPress.Security.NonceVerification
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via Session::verify HMAC nonce in the handler (e.g. 116-117); inputs sanitize_*'d
        $anNonce    = sanitize_text_field(wp_unslash($_POST['opaque_value'] ?? ''));
        // phpcs:enable WordPress.Security.NonceVerification
        if (!$subId || !$descriptor || !$anNonce) {
            wp_send_json_error(['message' => __('Missing card details.', 'instinctor')]);
        }
        $userId = \Instinctor\Core\Auth::userId();
        $p = \Instinctor\Core\DB::prefix();
        $cust = \Instinctor\Core\DB::get_row(\Instinctor\Core\DB::prepare(
            "SELECT c.first_name, c.last_name FROM `{$p}instinctor_subscriptions` s
             INNER JOIN `{$p}instinctor_customers` c ON c.id = s.customer_id
             WHERE s.id = %d AND c.user_id = %d LIMIT 1",
            $subId, $userId
        ));
        if (!$cust) {
            wp_send_json_error(['message' => __('Subscription not found.', 'instinctor')]);
        }
        $billing = [
            'first_name' => $cust->first_name ?? '',
            'last_name'  => $cust->last_name ?? '',
            // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via Session::verify HMAC nonce in the handler (e.g. 116-117); inputs sanitize_*'d
            'postcode'   => sanitize_text_field(wp_unslash($_POST['zip'] ?? '')),
            // phpcs:enable WordPress.Security.NonceVerification
        ];
        $result = \Instinctor\Services\SubscriptionService::instance()->updateCardOnFile($subId, $descriptor, $anNonce, $billing);
        if (!empty($result['success'])) {
            wp_send_json_success(['message' => __('Card updated.', 'instinctor')]);
        }
        wp_send_json_error(['message' => $result['error'] ?? __('Could not update card.', 'instinctor')]);
    }

    public function ajaxCancelSubscription(): void
    {
        if (!\Instinctor\Core\Auth::check()) {
            wp_send_json_error(['message' => __('Please log in.', 'instinctor')]);
        }
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via Session::verify HMAC nonce in the handler (e.g. 116-117); inputs sanitize_*'d
        $nonce = sanitize_text_field(wp_unslash($_POST['nonce'] ?? ''));
        // phpcs:enable WordPress.Security.NonceVerification
        if (!wp_verify_nonce($nonce, 'instinctor_frontend')) {
            wp_send_json_error(['message' => __('Security check failed.', 'instinctor')]);
        }
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via Session::verify HMAC nonce in the handler (e.g. 116-117); inputs sanitize_*'d
        $subId = absint(wp_unslash($_POST['subscription_id'] ?? 0));
        // phpcs:enable WordPress.Security.NonceVerification
        if (!$subId) {
            wp_send_json_error(['message' => __('Invalid subscription.', 'instinctor')]);
        }
        $userId = \Instinctor\Core\Auth::userId();
        $p = \Instinctor\Core\DB::prefix();
        $row = \Instinctor\Core\DB::get_row(\Instinctor\Core\DB::prepare(
            "SELECT s.id FROM `{$p}instinctor_subscriptions` s
             INNER JOIN `{$p}instinctor_customers` c ON c.id = s.customer_id
             WHERE s.id = %d AND c.user_id = %d LIMIT 1",
            $subId, $userId
        ));
        if (!$row) {
            wp_send_json_error(['message' => __('Subscription not found.', 'instinctor')]);
        }
        $result = \Instinctor\Services\SubscriptionService::instance()->cancelSubscription($subId, false);
        if (!empty($result['success'])) {
            wp_send_json_success(['message' => __('Membership cancelled.', 'instinctor')]);
        }
        wp_send_json_error(['message' => $result['error'] ?? __('Could not cancel.', 'instinctor')]);
    }

    /**
     * Check if a page is an Impulse page.
     * Now queries instinctor_pages table — works on both WP and standalone.
     */
    public static function isImpulsePage(int $post_id): bool
    {
        if (!$post_id) {
            return false;
        }
        return \Instinctor\Services\PageService::exists($post_id);
    }

    /**
     * Handle subscription checkout callback from Stripe
     */
    public function handleSubscriptionCallback(): void
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Payment gateway callback URL
        if (!isset($_GET['instinctor_checkout'])) {
            return;
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Payment gateway callback URL
        $checkoutType = isset($_GET['instinctor_checkout']) ? sanitize_text_field(wp_unslash($_GET['instinctor_checkout'])) : '';
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Payment gateway callback URL
        $orderId = isset($_GET['order_id']) ? intval($_GET['order_id']) : 0;

        if (!$orderId) {
            return;
        }

        if ($checkoutType === 'subscription_success') {
            $this->handleStripeSubscriptionSuccess($orderId);
        }
    }

    /**
     * Build the order-received URL with the order_key from order meta.
     * The key prevents anonymous enumeration of order details.
     */
    private function orderReceivedUrl(int $orderId, string $extra = ''): string
    {
        $key = '';
        if (class_exists('\\Instinctor\\Core\\DB')) {
            $p    = DB::prefix();
            $meta = DB::get_var(DB::prepare("SELECT meta FROM {$p}instinctor_orders WHERE id = %d", $orderId));
            $arr  = $meta ? (json_decode($meta, true) ?: []) : [];
            $key  = $arr['order_key'] ?? '';
        }
        $base = home_url('/order-received/?order_id=' . $orderId . ($key !== '' ? '&order_key=' . rawurlencode($key) : ''));
        return $extra !== '' ? $base . '&' . $extra : $base;
    }

    /**
     * Handle Stripe subscription success callback
     */
    private function handleStripeSubscriptionSuccess(int $orderId): void
    {
        $p = DB::prefix();
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Payment gateway callback URL
        $sessionId = isset($_GET['session_id']) ? sanitize_text_field(wp_unslash($_GET['session_id'])) : '';

        if (!$sessionId) {
            wp_safe_redirect($this->orderReceivedUrl($orderId));
            exit;
        }

        // Verify the session with Stripe
        $stripeKey = Config::get('instinctor_stripe_secret_key', '');
        if (!$stripeKey) {
            wp_safe_redirect($this->orderReceivedUrl($orderId));
            exit;
        }

        $response = wp_remote_get('https://api.stripe.com/v1/checkout/sessions/' . $sessionId, [
            'headers' => ['Authorization' => 'Bearer ' . $stripeKey]
        ]);

        if (is_wp_error($response)) {
            wp_safe_redirect($this->orderReceivedUrl($orderId));
            exit;
        }

        $session = json_decode(wp_remote_retrieve_body($response), true);

        if (empty($session['id']) || $session['payment_status'] !== 'paid') {
            wp_safe_redirect($this->orderReceivedUrl($orderId, 'status=pending'));
            exit;
        }

        // Verify session belongs to this order via metadata
        $sessionOrderId = (int) ($session['metadata']['instinctor_order_id'] ?? 0);
        if ($sessionOrderId && $sessionOrderId !== $orderId) {
            wp_safe_redirect($this->orderReceivedUrl($orderId, 'status=error'));
            exit;
        }


        // Update order status and payment info
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Order update after payment
        DB::update(
            DB::prefix() . 'instinctor_orders',
            [
                'status' => 'processing',
                'payment_status' => 'paid',
                'transaction_id' => $session['subscription'] ?? $session['id']
            ],
            ['id' => $orderId]
        );

        // Create subscription record if not exists
        if (!empty($session['subscription'])) {
            $subResponse = wp_remote_get('https://api.stripe.com/v1/subscriptions/' . $session['subscription'], [
                'headers' => ['Authorization' => 'Bearer ' . $stripeKey]
            ]);

            if (!is_wp_error($subResponse)) {
                $subscription = json_decode(wp_remote_retrieve_body($subResponse), true);
                
                if (!empty($subscription['id'])) {
                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Order lookup
                    $order = DB::get_row(DB::prepare(
                        "SELECT customer_id FROM {$p}instinctor_orders WHERE id = %d",
                        $orderId
                    ));

                    // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Subscription check
                    $existing = DB::get_var(DB::prepare(
                        "SELECT id FROM {$p}instinctor_subscriptions WHERE external_id = %s",
                        $subscription['id']
                    ));

                    if (!$existing && $order) {
                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Order item lookup
                        $orderItem = DB::get_row(DB::prepare(
                            "SELECT product_id, price FROM {$p}instinctor_order_items WHERE order_id = %d LIMIT 1",
                            $orderId
                        ));

                        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Subscription creation
                        DB::insert(DB::prefix() . 'instinctor_subscriptions', [
                            'customer_id' => $order->customer_id,
                            'product_id' => $orderItem->product_id ?? 0,
                            'external_id' => $subscription['id'],
                            'price' => ($subscription['items']['data'][0]['price']['unit_amount'] ?? 0) / 100,
                            'period' => $subscription['items']['data'][0]['price']['recurring']['interval'] ?? 'month',
                            'status' => 'active',
                            'payment_method' => 'stripe',
                            // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
                            'current_period_start' => date('Y-m-d H:i:s', $subscription['current_period_start']),
                            // phpcs:enable WordPress.DateTime.RestrictedFunctions
                            // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
                            'current_period_end' => date('Y-m-d H:i:s', $subscription['current_period_end']),
                            // phpcs:enable WordPress.DateTime.RestrictedFunctions
                            'created_at' => current_time('mysql')
                        ]);
                    }
                }
            }
        }

        // Run full post-payment finalization (stock, email, gift cards, loyalty, coupons, bookings, customer stats)
        \Instinctor\Services\CheckoutService::finalize($orderId);

        // Clear cart
        $cartService = new \Instinctor\Services\CartService();
        $cartService->clear();

        wp_safe_redirect($this->orderReceivedUrl($orderId));
        exit;
    }

    /**
     * Handle PayPal subscription success callback
     */

    /**
     * Send order confirmation email
     */
    private function sendOrderConfirmationEmail(int $orderId): void
    {
        $p = DB::prefix();
        
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Order lookup for email
        $order = DB::get_row(DB::prepare(
            "SELECT o.*, c.email, c.first_name FROM {$p}instinctor_orders o 
             LEFT JOIN {$p}instinctor_customers c ON c.id = o.customer_id 
             WHERE o.id = %d",
            $orderId
        ));

        if (!$order || !$order->email) {
            return;
        }

        $storeName = Config::get('instinctor_store_name', get_bloginfo('name'));
        $subject = sprintf('Order #%s confirmed', $order->order_number);

        $message = sprintf("Hi %s,\n\n", $order->first_name ?: 'there');
        $message .= sprintf("Thank you for your subscription order #%s.\n\n", $order->order_number);
        $message .= sprintf("Total: $%.2f\n\n", $order->total);
        $message .= sprintf("View your account: %s\n\n", home_url('/my-account/'));
        $message .= $storeName;

        Mailer::send($order->email, $subject, $message);
    }

    public function enqueueAssets(): void
    {
        if (!$this->isInstinctorPage()) {
            return;
        }

        wp_enqueue_style(
            'instinctor-frontend',
            INSTINCTOR_PLUGIN_URL . 'assets/css/frontend.css',
            [],
            INSTINCTOR_VERSION
        );

        wp_enqueue_script(
            'instinctor-frontend',
            INSTINCTOR_PLUGIN_URL . 'assets/js/frontend.js',
            ['jquery'],
            INSTINCTOR_VERSION,
            true
        );

        // Get current currency info
        $currentCurrency = \Instinctor\Services\CurrencyService::getCurrentCurrency();
        
        wp_localize_script('instinctor-frontend', 'instinctorFrontend', [
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'apiUrl' => rest_url('instinctor/v1/'),
            'nonce' => Session::token('instinctor_frontend'),
            'currency' => $currentCurrency->code ?? Config::get('instinctor_currency', 'USD'),
            'currencySymbol' => $currentCurrency->symbol ?? '$',
            'currencyPosition' => $currentCurrency->symbol_position ?? Config::get('instinctor_currency_position', 'left'),
            'exchangeRate' => floatval($currentCurrency->exchange_rate ?? 1),
            'currencyDecimals' => intval($currentCurrency->decimals ?? 2),
            'thousandSeparator' => $currentCurrency->thousand_separator ?? ',',
            'decimalSeparator' => $currentCurrency->decimal_separator ?? '.',
            'cartUrl' => $this->getPageUrl('cart'),
            'checkoutUrl' => $this->getPageUrl('checkout'),
            'accountUrl' => $this->getPageUrl('account'),
            'pluginUrl' => INSTINCTOR_PLUGIN_URL,
        ]);
        
        // Frontend Addons (badges, one-click, etc.)
        $this->enqueueAddonAssets();
    }

    /**
     * Enqueue addon-specific frontend assets
     */
    private function enqueueAddonAssets(): void
    {
        $hasOneClick = \Instinctor\Core\StoreFeatures::isFeatureEnabled('one_click_checkout');
        $hasBadges = true;
        $hasVolumeDiscounts = \Instinctor\Core\StoreFeatures::isFeatureEnabled('volume_discounts');
        $hasPreorders = \Instinctor\Core\StoreFeatures::isFeatureEnabled('preorders');
        $hasBundles = \Instinctor\Core\StoreFeatures::isFeatureEnabled('product_bundles');
        $hasQuantity = \Instinctor\Core\StoreFeatures::isFeatureEnabled('quantity_buttons');

        // Block-level addons are always active — script always enqueues on product pages
        wp_enqueue_style(
            'instinctor-frontend-addons',
            INSTINCTOR_PLUGIN_URL . 'assets/css/frontend-addons.css',
            ['instinctor-frontend'],
            INSTINCTOR_VERSION
        );

        wp_enqueue_script(
            'instinctor-frontend-addons',
            INSTINCTOR_PLUGIN_URL . 'assets/js/frontend-addons.js',
            ['jquery', 'instinctor-frontend'],
            INSTINCTOR_VERSION,
            true
        );
        
        // Localize addon config
        $addonConfig = [];
        // Block-level addons are always active (no feature flag needed)
        $addonConfig['productAddons'] = ['enabled' => true];
        
        if ($hasOneClick) {
            $settings = \Instinctor\Services\OneClickCheckoutService::getSettings();
            $addonConfig['oneClick'] = [
                'enabled' => $settings['enabled'] ?? true,
                'buttonText' => $settings['button_text'] ?? 'Buy Now',
            ];
        }
        
        if ($hasBadges) {
            // Product badges always enabled (core feature)
            $addonConfig['badges'] = ['enabled' => true];
        }
        
        if ($hasVolumeDiscounts) {
            $addonConfig['volumeDiscounts'] = ['enabled' => true];
        }

        wp_localize_script('instinctor-frontend-addons', 'instinctorAddons', $addonConfig);
    }

    public function registerShortcodes(): void
    {
        add_shortcode('instinctor_products', [$this, 'shortcodeProducts']);
        add_shortcode('instinctor_product', [$this, 'shortcodeProduct']);
        add_shortcode('instinctor_cart', [$this, 'shortcodeCart']);
        add_shortcode('instinctor_checkout', [$this, 'shortcodeCheckout']);
        add_shortcode('instinctor_account', [$this, 'shortcodeAccount']);
        add_shortcode('instinctor_orders', [$this, 'shortcodeOrders']);
        add_shortcode('instinctor_order_received', [$this, 'shortcodeOrderReceived']);
        add_shortcode('instinctor_currency_switcher', [$this, 'shortcodeCurrencySwitcher']);
        
        // v1.3.0 shortcodes
        add_shortcode('instinctor_search', [$this, 'shortcodeSearch']);
        add_shortcode('instinctor_gift_card_balance', [$this, 'shortcodeGiftCardBalance']);
        add_shortcode('instinctor_loyalty_points', [$this, 'shortcodeLoyaltyPoints']);
        add_shortcode('instinctor_order_tracking', [$this, 'shortcodeOrderTracking']);
        
        // v1.4.0 shortcodes - Multi-vendor & Wholesale
        
        // v1.4.0 shortcodes - AI & Social Proof
        
        // AJAX handler for currency switching
        $this->registerAjax('instinctor_switch_currency', [$this, 'ajaxSwitchCurrency']);
        add_action('wp_ajax_nopriv_instinctor_switch_currency', [$this, 'ajaxSwitchCurrency']);
        
        // v1.3.0 AJAX handlers        
        $this->registerAjax('instinctor_autocomplete', [$this, 'ajaxAutocomplete']);
        add_action('wp_ajax_nopriv_instinctor_autocomplete', [$this, 'ajaxAutocomplete']);
        $this->registerAjax('instinctor_search_products', [$this, 'ajaxSearchProducts']);
        add_action('wp_ajax_nopriv_instinctor_search_products', [$this, 'ajaxSearchProducts']);

        $this->registerAjax('instinctor_get_bundle', [$this, 'ajaxGetBundle']);
        add_action('wp_ajax_nopriv_instinctor_get_bundle', [$this, 'ajaxGetBundle']);
        $this->registerAjax('instinctor_add_bundle_to_cart', [$this, 'ajaxAddBundleToCart']);
        add_action('wp_ajax_nopriv_instinctor_add_bundle_to_cart', [$this, 'ajaxAddBundleToCart']);

        // Add-on label lookup (for cart/checkout display)
        $this->registerAjax('instinctor_get_addon_labels', [$this, 'ajaxGetAddonLabels']);
        add_action('wp_ajax_nopriv_instinctor_get_addon_labels', [$this, 'ajaxGetAddonLabels']);
        
        $this->registerAjax('instinctor_check_gift_card', [$this, 'ajaxCheckGiftCard']);
        add_action('wp_ajax_nopriv_instinctor_check_gift_card', [$this, 'ajaxCheckGiftCard']);

        $this->registerAjax('instinctor_register_visitor', [$this, 'ajaxRegisterVisitor']);
        add_action('wp_ajax_nopriv_instinctor_register_visitor', [$this, 'ajaxRegisterVisitor']);
        $this->registerAjax('instinctor_get_stock_status', [$this, 'ajaxGetStockStatus']);
        add_action('wp_ajax_nopriv_instinctor_get_stock_status', [$this, 'ajaxGetStockStatus']);
    }

    public function handleSEOURLs(): void
    {
        $uri = wp_parse_url(esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'] ?? '')), PHP_URL_PATH);
        // wp_parse_url() returns false for a malformed request URI (bots hit "//"
        // routinely); a false parse resolves to root rather than fatalling in ltrim().
        $uri = '/' . ltrim((string) $uri, '/');

        // sitemap.xml index
        if ($uri === '/sitemap.xml') {
            header('Content-Type: application/xml; charset=utf-8');
            // phpcs:disable WordPress.Security.EscapeOutput -- plugin-generated sitemap XML document; every dynamic URL is esc_url()-escaped inside generateSitemapIndex(). No single WP escaping fn can wrap a full XML doc without corrupting its tags.
            echo \Instinctor\Services\SEOService::generateSitemapIndex();
            // phpcs:enable WordPress.Security.EscapeOutput
            exit;
        }
        // sitemap-pages.xml
        if ($uri === '/sitemap-pages.xml') {
            header('Content-Type: application/xml; charset=utf-8');
            // phpcs:disable WordPress.Security.EscapeOutput -- plugin-generated sitemap XML document; every dynamic URL is esc_url()-escaped inside generatePagesSitemap(). No single WP escaping fn can wrap a full XML doc without corrupting its tags.
            echo \Instinctor\Services\SEOService::generatePagesSitemap();
            // phpcs:enable WordPress.Security.EscapeOutput
            exit;
        }
        // sitemap-products.xml
        if ($uri === '/sitemap-products.xml') {
            header('Content-Type: application/xml; charset=utf-8');
            // phpcs:disable WordPress.Security.EscapeOutput -- plugin-generated sitemap XML document; every dynamic URL is esc_url()-escaped inside generateProductsSitemap(). No single WP escaping fn can wrap a full XML doc without corrupting its tags.
            echo \Instinctor\Services\SEOService::generateProductsSitemap();
            // phpcs:enable WordPress.Security.EscapeOutput
            exit;
        }
        // robots.txt — only handle if WP hasn't already
        if ($uri === '/robots.txt' && !file_exists(ABSPATH . 'robots.txt')) {
            header('Content-Type: text/plain; charset=utf-8');
            // phpcs:disable WordPress.Security.EscapeOutput -- plugin-generated robots.txt served as text/plain (not an HTML/JS context, so HTML-escaping does not apply); dynamic paths come from wp_parse_url() and site config.
            echo \Instinctor\Services\SEOService::generateRobotsTxt();
            // phpcs:enable WordPress.Security.EscapeOutput
            exit;
        }

        // 301/302 redirects from database
        \Instinctor\Services\SEOService::handleRedirect();
    }

    public function registerEndpoints(): void
    {
        add_rewrite_endpoint('view-order', EP_PAGES);
        add_rewrite_endpoint('downloads', EP_PAGES);
        add_rewrite_endpoint('subscriptions', EP_PAGES);
        add_rewrite_endpoint('bookings', EP_PAGES);
    }

    /**
     * Handle Impulse preview mode - set up query for preview page
     * This runs early to prevent 404 on preview URLs
     */
    public function handleImpulsePreview(\WP_Query $query): void
    {
        // Only handle main query on frontend
        if (is_admin() || !$query->is_main_query()) {
            return;
        }
        
        // Allow logged-in editors to view draft Impulse pages via their permalink
        // Without this, WordPress 404s on draft page slugs even for admins
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via Session::verify HMAC nonce in the handler (e.g. 116-117); inputs sanitize_*'d
        if (!isset($_GET['impulse_preview']) && $query->is_page && Auth::can('edit_pages')) {
        // phpcs:enable WordPress.Security.NonceVerification
            $query->set('post_status', ['publish', 'draft', 'private']);
            return;
        }
        
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        if (!isset($_GET['impulse_preview'])) {
            return;
        }

        // Preview-by-id can surface unpublished/draft page content, so it is
        // an editor-only capability — never expose it to anonymous visitors.
        if (!Auth::can('edit_pages')) {
            return;
        }

        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $preview_page_id = intval($_GET['impulse_preview']);
        if ($preview_page_id <= 0) {
            return;
        }
        
        // Verify the page exists
        if (!\Instinctor\Services\PageService::exists($preview_page_id)) {
            return;
        }
        
        // Set up query to load this specific page
        $query->set('page_id', $preview_page_id);
        $query->set('post_type', 'page');
        $query->is_page = true;
        $query->is_singular = true;
        $query->is_home = false;
        $query->is_front_page = false;
        $query->is_404 = false;
    }
    
    public function loadTemplates(string $template): string
    {
        // Use blank template for preview mode (checkout, cart, etc.)
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        if (isset($_GET['instinctor_preview']) && $_GET['instinctor_preview'] === '1') {
            $blankTemplate = INSTINCTOR_PLUGIN_DIR . 'templates/blank-template.php';
            if (file_exists($blankTemplate)) {
                return $blankTemplate;
            }
        }
        
        // Impulse editor preview mode - load page by ID.
        // Preview-by-id can surface unpublished/draft content, so it is an
        // editor-only capability (mirrors handleImpulsePreview()'s gate at the
        // query stage). Without this, a crafted ?impulse_preview=<id> URL served
        // the frontend template for any page id regardless of status; non-editors
        // now fall through to normal template resolution below. WP.org round 4.
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        if (isset($_GET['impulse_preview']) && Auth::can('edit_pages')) {
            // phpcs:ignore WordPress.Security.NonceVerification.Recommended
            $preview_page_id = intval($_GET['impulse_preview']);
            if ($preview_page_id > 0 && \Instinctor\Services\PageService::exists($preview_page_id)) {
                // Editor requests (edit=1): impulse-preview.php handles these via
                // parse_request hook. If that callback already called exit, this code
                // never runs. If for any reason it didn't, serve the editor template here.
                // phpcs:ignore WordPress.Security.NonceVerification.Recommended
                if (!empty($_GET['edit']) && current_user_can('edit_pages')) {
                    $editorTpl = INSTINCTOR_PLUGIN_DIR . 'admin/views/impulse-preview.php';
                    if (file_exists($editorTpl)) {
                        if (!defined('INSTINCTOR_EDITOR_MODE'))     define('INSTINCTOR_EDITOR_MODE', true);
                        if (!defined('INSTINCTOR_PREVIEW_CONTEXT')) define('INSTINCTOR_PREVIEW_CONTEXT', true);
                        if (!defined('INSTINCTOR_ADMIN_CONTEXT')) define('INSTINCTOR_ADMIN_CONTEXT', true);
                        global $post;
                        $post = get_post($preview_page_id); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional post-context setup paired with setup_postdata(); request exits after render
                        if ($post) setup_postdata($post);
                        include $editorTpl;
                        exit;
                    }
                }
                // Public preview (no edit=1): serve frontend template
                global $post;
                $post = get_post($preview_page_id); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional post-context setup paired with setup_postdata(); request exits after render
                if ($post) setup_postdata($post);
                $impulseTemplate = INSTINCTOR_PLUGIN_DIR . 'templates/impulse-page.php';
                if (file_exists($impulseTemplate)) {
                    return $impulseTemplate;
                }
            }
        }
        
        // Impulse-enabled pages - use custom template (no theme header/footer)
        global $post;
        if (is_singular('page') && $post instanceof \WP_Post) {
            $post_id = $post->ID;
            if ($post_id && self::isImpulsePage($post_id)) {
                $impulseTemplate = INSTINCTOR_PLUGIN_DIR . 'templates/impulse-page.php';
                if (file_exists($impulseTemplate)) {
                    return $impulseTemplate;
                }
            }
        }
        
        if (is_singular('instinctor_product')) {
            $custom_template = $this->locateTemplate('single-product.php');
            if ($custom_template) {
                return $custom_template;
            }
        }

        if (is_post_type_archive('instinctor_product')) {
            $custom_template = $this->locateTemplate('archive-product.php');
            if ($custom_template) {
                return $custom_template;
            }
        }

        // ── 404 fallback: slug exists in instinctor_pages but not in wp_posts ──
        // This catches pages where the WP post shell has a stale slug or was
        // never created. The template's own slug resolver handles page loading.
        if (is_404()) {
            $slug = trim((string) wp_parse_url(esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'] ?? '/')), PHP_URL_PATH), '/');
            if ($slug && !\str_contains($slug, '/')) {
                // Use getByPublicSlug so designer-owned pages don't leak into the
                // global URL namespace (e.g. /profile shouldn't serve a designer's
                // profile page; that path is only valid under /d/<slug>).
                $page = \Instinctor\Services\PageService::getRoutableBySlug($slug);
                if ($page && ($page['status'] === 'published' || current_user_can('edit_posts'))) {
                    // Reset 404 status — this is a real page
                    global $wp_query;
                    $wp_query->is_404 = false;
                    status_header(200);

                    // If a WP post exists with a different slug, fix it now
                    $wpPost = get_post((int) $page['id']);
                    if ($wpPost && $wpPost->post_name !== $slug) {
                        wp_update_post(['ID' => $wpPost->ID, 'post_name' => $slug]);
                    }

                    $impulseTemplate = INSTINCTOR_PLUGIN_DIR . 'templates/impulse-page.php';
                    if (file_exists($impulseTemplate)) {
                        return $impulseTemplate;
                    }
                }
            }
        }

        return $template;
    }

    public function shortcodeProducts($atts = []): string
    {
        // Skip during REST API requests (block editor save/preview)
        if (defined('REST_REQUEST') && REST_REQUEST) {
            return '';
        }
        
        $atts = shortcode_atts([
            'columns' => 4,
            'limit' => 12,
            'category' => '',
            'orderby' => 'date',
            'order' => 'DESC',
        ], is_array($atts) ? $atts : []);

        ob_start();
        $template = $this->locateTemplate('shortcodes/products.php');
        if ($template) {
            include $template;
        }
        return ob_get_clean();
    }

    public function shortcodeProduct($atts = []): string
    {
        // Skip during REST API requests (block editor save/preview)
        if (defined('REST_REQUEST') && REST_REQUEST) {
            return '';
        }
        
        $atts = shortcode_atts([
            'id' => 0,
            'sku' => '',
        ], is_array($atts) ? $atts : []);

        ob_start();
        $template = $this->locateTemplate('shortcodes/product.php');
        if ($template) {
            include $template;
        }
        return ob_get_clean();
    }

    public function shortcodeCart($atts = []): string
    {
        // Skip during REST API requests (block editor save/preview)
        if (defined('REST_REQUEST') && REST_REQUEST) {
            return '';
        }
        
        ob_start();
        $template = $this->locateTemplate('shortcodes/cart.php');
        if ($template) {
            include $template;
        }
        return ob_get_clean();
    }

    public function shortcodeCheckout($atts = []): string
    {
        // Skip during REST API requests (block editor save/preview)
        if (defined('REST_REQUEST') && REST_REQUEST) {
            return '';
        }

        // AI-chat resume: Instagent created a pending order; load its items into CartSession
        // so the visitor arrives at a pre-filled checkout and only needs to pay.
        // phpcs:disable WordPress.Security.NonceVerification -- request authenticated via Session::verify HMAC nonce in the handler (e.g. 116-117); inputs sanitize_*'d
        $resumeToken = isset($_GET['resume']) ? sanitize_text_field(wp_unslash($_GET['resume'])) : '';
        // phpcs:enable WordPress.Security.NonceVerification
        if ($resumeToken !== '') {
            $this->applyResumeOrder($resumeToken);
        }

        ob_start();
        $template = $this->locateTemplate('shortcodes/checkout.php');
        if ($template) {
            include $template;
        }
        return ob_get_clean();
    }

    /**
     * Load a pending order created by Instagent into CartSession (one-time use).
     * Clears the resume_token from order meta after loading to prevent replay.
     */
    private function applyResumeOrder(string $token): void
    {
        if (strlen($token) < 16) return;

        $p           = \Instinctor\Core\DB::prefix();
        $ordersTable = $p . 'instinctor_orders';
        $itemsTable  = $p . 'instinctor_order_items';

        $orders = \Instinctor\Core\DB::get_results(\Instinctor\Core\DB::prepare(
            "SELECT id, meta FROM {$ordersTable}
              WHERE status = 'pending' AND payment_status = 'pending' AND meta LIKE %s LIMIT 1",
            '%' . \Instinctor\Core\DB::esc_like($token) . '%'
        )) ?: [];

        if (empty($orders)) return;

        $order = $orders[0];
        $meta  = json_decode($order->meta ?? '{}', true) ?? [];
        if (($meta['resume_token'] ?? '') !== $token) return;

        // One-time: remove the token so this URL can't be replayed
        unset($meta['resume_token']);
        // phpcs:disable WordPress.WP.AlternativeFunctions -- native filesystem/string op required for standalone, no WP_Filesystem in this path
        \Instinctor\Core\DB::update($ordersTable, ['meta' => json_encode($meta)], ['id' => $order->id]);
        // phpcs:enable WordPress.WP.AlternativeFunctions

        $items = \Instinctor\Core\DB::get_results(\Instinctor\Core\DB::prepare(
            "SELECT product_id, quantity FROM {$itemsTable} WHERE order_id = %d",
            $order->id
        )) ?: [];

        if (empty($items)) return;

        $cartService = new \Instinctor\Services\CartService();
        foreach ($items as $item) {
            $cartService->addItem((int) $item->product_id, (int) $item->quantity);
        }
    }

    public function shortcodeAccount($atts = []): string
    {
        // Skip during REST API requests (block editor save/preview)
        if (defined('REST_REQUEST') && REST_REQUEST) {
            return '';
        }
        
        ob_start();
        $template = $this->locateTemplate('shortcodes/account.php');
        if ($template) {
            include $template;
        }
        return ob_get_clean();
    }

    public function shortcodeOrders($atts = []): string
    {
        // Skip during REST API requests (block editor save/preview)
        if (defined('REST_REQUEST') && REST_REQUEST) {
            return '';
        }
        
        ob_start();
        $template = $this->locateTemplate('shortcodes/orders.php');
        if ($template) {
            include $template;
        }
        return ob_get_clean();
    }

    public function shortcodeOrderReceived($atts = []): string
    {
        // Skip during REST API requests (block editor save/preview)
        if (defined('REST_REQUEST') && REST_REQUEST) {
            return '';
        }
        
        ob_start();
        $template = $this->locateTemplate('shortcodes/order-received.php');
        if ($template) {
            include $template;
        }
        return ob_get_clean();
    }

    private function locateTemplate(string $template_name): ?string
    {
        // Check theme first
        $theme_template = locate_template(['instinctor/' . $template_name]);
        if ($theme_template) {
            return $theme_template;
        }

        // Fall back to plugin
        $plugin_template = INSTINCTOR_PLUGIN_DIR . 'templates/' . $template_name;
        if (file_exists($plugin_template)) {
            return $plugin_template;
        }

        return null;
    }

    private function isInstinctorPage(): bool
    {
        global $post;

        if (!$post) {
            return false;
        }

        // Check instinctor_pages table (works on both WP and standalone)
        if (\Instinctor\Services\PageService::exists((int) $post->ID)) {
            return true;
        }

        $instinctor_shortcodes = [
            'instinctor_products',
            'instinctor_product',
            'instinctor_cart',
            'instinctor_checkout',
            'instinctor_account',
            'instinctor_orders',
        ];

        foreach ($instinctor_shortcodes as $shortcode) {
            if (has_shortcode($post->post_content, $shortcode)) {
                return true;
            }
        }

        return false;
    }

    private function getPageUrl(string $page): string
    {
        return \Instinctor\Services\PageService::resolveUrl($page);
    }

    public function shortcodeCurrencySwitcher($atts = []): string
    {
        return \Instinctor\Services\CurrencyService::getCurrencySelectorHtml();
    }

    public function ajaxSwitchCurrency(): void
    {
        // WP.org B1 — public state-changing handler; verify the frontend nonce
        // minted by impulse-page.php as ImpulseConfig.nonce. No cap check since
        // currency choice is per-visitor (stored in session).
        if (!check_ajax_referer('instinctor_frontend', 'nonce', false)) {
            wp_send_json_error(['message' => 'bad_nonce'], 403);
            return;
        }
        $code = isset($_POST['currency']) ? sanitize_text_field(wp_unslash($_POST['currency'])) : '';
        
        if (empty($code)) {
            wp_send_json_error(['message' => 'No currency specified']);
        }

        $success = \Instinctor\Services\CurrencyService::setCurrentCurrency($code);
        
        if ($success) {
            wp_send_json_success(['currency' => $code]);
        } else {
            wp_send_json_error(['message' => 'Invalid currency']);
        }
    }

    // v1.3.0 Shortcodes

    public function shortcodeSearch($atts = []): string
    {
        if (defined('REST_REQUEST') && REST_REQUEST) {
            return '';
        }
        ob_start();
        $template = $this->locateTemplate('shortcodes/search.php');
        if ($template) {
            include $template;
        }
        return ob_get_clean();
    }

    public function shortcodeGiftCardBalance($atts = []): string
    {
        ob_start();
        ?>
        <div class="instinctor-gift-card-balance">
            <h3>Check Gift Card Balance</h3>
            <form id="instinctor-gc-balance-form">
                <input type="text" id="instinctor-gc-code" placeholder="Enter gift card code" required>
                <button type="submit" class="instinctor-btn instinctor-btn-primary">Check Balance</button>
            </form>
            <div id="instinctor-gc-result" style="display: none; margin-top: 15px; padding: 15px; background: #f9f9f9; border-radius: 8px;"></div>
        </div>
        <script>
        document.getElementById('instinctor-gc-balance-form').addEventListener('submit', function(e) {
            e.preventDefault();
            const code = document.getElementById('instinctor-gc-code').value;
            const result = document.getElementById('instinctor-gc-result');
            
            fetch(instinctorFrontend.ajaxUrl, {
                method: 'POST',
                headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
                body: 'action=instinctor_check_gift_card&code=' + encodeURIComponent(code)
            })
            .then(r => r.json())
            .then(data => {
                result.style.display = 'block';
                if (data.success && data.data.found) {
                    result.innerHTML = '<strong>Balance:</strong> ' + data.data.balance_formatted + 
                        (data.data.expires_at ? '<br><small>Expires: ' + data.data.expires_at + '</small>' : '');
                } else {
                    result.innerHTML = '<span style="color: #dc3545;">Gift card not found</span>';
                }
            });
        });
        </script>
        <?php
        return ob_get_clean();
    }

    public function shortcodeLoyaltyPoints($atts = []): string
    {
        if (!Auth::check()) {
            return '<p>Please <a href="' . esc_url(\Instinctor\Services\PageService::resolveUrl('account')) . '">log in</a> to view your loyalty points.</p>';
        }

        $customerId = $this->getCustomerId();
        if (!$customerId) {
            return '';
        }

        $points = \Instinctor\Services\LoyaltyService::getCustomerPoints($customerId);
        $settings = \Instinctor\Services\LoyaltyService::getSettings();
        $transactions = \Instinctor\Services\LoyaltyService::getTransactions($customerId, 10);

        ob_start();
        ?>
        <div class="instinctor-loyalty-widget">
            <div class="instinctor-loyalty-header">
                <div class="instinctor-loyalty-points">
                    <span class="points-value"><?php echo esc_html( number_format_i18n( (int) $points->points ) ); ?></span>
                    <span class="points-label">Points</span>
                </div>
                <div class="instinctor-loyalty-tier">
                    <span class="tier-badge tier-<?php echo esc_attr($points->tier); ?>"><?php echo esc_html(ucfirst($points->tier)); ?></span>
                    <?php if ($points->next_tier): ?>
                    <small><?php echo esc_html( number_format($points->next_tier['points_needed']) ); ?> points to <?php echo esc_html($points->next_tier['tier']['name']); ?></small>
                    <?php endif; ?>
                </div>
            </div>
            
            <div class="instinctor-loyalty-value">
                Worth: <strong><?php echo esc_html(\Instinctor\Services\CurrencyService::formatPrice(\Instinctor\Services\LoyaltyService::getPointsValue($points->points))); ?></strong>
                <small>(<?php echo esc_html(number_format($settings['points_value'], 3)); ?>/point)</small>
            </div>

            <?php if (!empty($transactions)): ?>
            <div class="instinctor-loyalty-history">
                <h4>Recent Activity</h4>
                <ul>
                    <?php foreach ($transactions as $t): ?>
                    <li>
                        <span class="<?php echo esc_attr($t->points > 0 ? 'earned' : 'redeemed'); ?>">
                            <?php echo $t->points > 0 ? '+' : ''; ?><?php echo esc_html(number_format($t->points)); ?>
                        </span>
                        <span class="desc"><?php echo esc_html($t->description ?: ucfirst($t->type)); ?></span>
                        // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
                        // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
                        <span class="date"><?php echo esc_html(date('M j', strtotime($t->created_at))); // phpcs:ignore WordPress.DateTime.RestrictedFunctions.date_date -- local-time display ?></span>
                        // phpcs:enable WordPress.DateTime.RestrictedFunctions
                        // phpcs:enable WordPress.DateTime.RestrictedFunctions
                    </li>
                    <?php endforeach; ?>
                </ul>
            </div>
            <?php endif; ?>
        </div>
        <style>
        .instinctor-loyalty-widget { background: #fff; border: 1px solid #eee; border-radius: 12px; padding: 20px; max-width: 400px; }
        .instinctor-loyalty-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; }
        .instinctor-loyalty-points { text-align: center; }
        .instinctor-loyalty-points .points-value { display: block; font-size: 36px; font-weight: bold; }
        .instinctor-loyalty-points .points-label { font-size: 12px; color: #666; text-transform: uppercase; }
        .tier-badge { display: inline-block; padding: 4px 12px; border-radius: 20px; font-size: 12px; font-weight: 500; }
        .tier-bronze { background: #cd7f32; color: #fff; }
        .tier-silver { background: #c0c0c0; color: #333; }
        .tier-gold { background: #ffd700; color: #333; }
        .tier-platinum { background: #e5e4e2; color: #333; }
        .instinctor-loyalty-value { text-align: center; padding: 10px; background: #f9f9f9; border-radius: 8px; margin-bottom: 15px; }
        .instinctor-loyalty-history h4 { margin: 0 0 10px; font-size: 14px; }
        .instinctor-loyalty-history ul { list-style: none; margin: 0; padding: 0; }
        .instinctor-loyalty-history li { display: flex; gap: 10px; padding: 8px 0; border-bottom: 1px solid #f0f0f0; font-size: 13px; }
        .instinctor-loyalty-history .earned { color: #28a745; font-weight: 500; }
        .instinctor-loyalty-history .redeemed { color: #dc3545; }
        .instinctor-loyalty-history .desc { flex: 1; }
        .instinctor-loyalty-history .date { color: #999; }
        </style>
        <?php
        return ob_get_clean();
    }

    public function shortcodeOrderTracking($atts = []): string
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public read-only tracking lookup; ownership is enforced below by requiring the email that matches the order.
        $orderNumber = isset($_GET['order_id']) ? sanitize_text_field(wp_unslash($_GET['order_id'])) : '';
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public read-only tracking lookup; ownership is enforced below by requiring the email that matches the order.
        $trackEmail = isset($_GET['track_email']) ? sanitize_email(wp_unslash($_GET['track_email'])) : '';

        if ($orderNumber === '' || $trackEmail === '') {
            ob_start();
            ?>
            <div class="instinctor-order-tracking-form">
                <h3>Track Your Order</h3>
                <form method="get">
                    <input type="text" name="order_id" placeholder="Order Number" required>
                    <input type="email" name="track_email" placeholder="Email on the order" required>
                    <button type="submit" class="instinctor-btn instinctor-btn-primary">Track</button>
                </form>
            </div>
            <?php
            return ob_get_clean();
        }

        // Require the order number AND the matching customer email, so orders
        // cannot be enumerated by number alone.
        $match = \Instinctor\Services\OrderTrackingService::lookupOrder($orderNumber, $trackEmail);
        if (!$match) {
            return '<p>We could not find an order matching that order number and email. Please check both and try again.</p>';
        }

        return \Instinctor\Services\OrderTrackingService::getTrackingPageHtml((int) $match['order']->id);
    }


    public function ajaxAutocomplete(): void
    {
        // WP.org B1 — public read-only handler. Verify the frontend nonce so
        // automated scrapers can't burn the autocomplete cache from off-site.
        if (!check_ajax_referer('instinctor_frontend', 'nonce', false)) {
            wp_send_json_error(['message' => 'bad_nonce'], 403);
            return;
        }
        $query = isset($_POST['query']) ? sanitize_text_field(wp_unslash($_POST['query'])) : '';
        
        if (strlen($query) < 2) {
            wp_send_json_success([]);
            return;
        }

        $results = \Instinctor\Services\ProductSearchService::autocomplete($query);
        wp_send_json_success($results);
    }

    public function ajaxSearchProducts(): void
    {
        // WP.org B1 — public read handler with state-side-effects (logSearch);
        // verify frontend nonce so unrelated origins can't poison search stats.
        if (!check_ajax_referer('instinctor_frontend', 'nonce', false)) {
            wp_send_json_error(['message' => 'bad_nonce'], 403);
            return;
        }
        $query = isset($_POST['query']) ? sanitize_text_field(wp_unslash($_POST['query'])) : '';
        $page  = isset($_POST['page']) ? intval($_POST['page']) : 1;
        $limit = 12;
        $offset = ($page - 1) * $limit;


        $filters = [
            'category' => isset($_POST['category']) ? sanitize_text_field(wp_unslash($_POST['category'])) : '',
            'min_price' => isset($_POST['min_price']) ? floatval($_POST['min_price']) : 0,
            'max_price' => isset($_POST['max_price']) ? floatval($_POST['max_price']) : 0,
            'in_stock' => !empty($_POST['in_stock']),
            'on_sale' => !empty($_POST['on_sale']),
            'sort' => isset($_POST['sort']) ? sanitize_text_field(wp_unslash($_POST['sort'])) : 'newest',
        ];

        $results = \Instinctor\Services\ProductSearchService::search($query, $filters, $limit, $offset);
        
        // Log search
        if ($query) {
            \Instinctor\Services\ProductSearchService::logSearch($query, $results['total']);
        }

        wp_send_json_success($results);
    }

    // =========================================================================
    // BUNDLE HANDLERS
    // =========================================================================

    /**
     * AJAX: Get bundle data for frontend display
     */
    /**
     * AJAX: Return human-readable labels for addon ID+value pairs (public)
     * POST: addon_ids[] = comma-separated addon IDs
     */
    public function ajaxGetAddonLabels(): void
    {
        // Addon labels are now stored directly as names in cart meta.
        wp_send_json_success(['labels' => []]);
    }

    public function ajaxGetBundle(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        $bundleId = intval($_GET['bundle_id'] ?? $_POST['bundle_id'] ?? 0);
        if (!$bundleId) {
            wp_send_json_error(['message' => 'Invalid bundle ID']);
        }

        $bundle = \Instinctor\Services\ProductBundleService::getBundle($bundleId);
        if (!$bundle || !intval($bundle['is_active'])) {
            wp_send_json_error(['message' => 'Bundle not found']);
        }

        // Enrich with product details
        $products = [];
        if (!empty($bundle['products'])) {
            $table = DB::prefix() . 'instinctor_products';
            foreach (array_map('intval', $bundle['products']) as $pid) {
                $product = DB::get_row(
                    DB::prepare("SELECT id, name, price, sale_price, images, gallery_images, featured_image FROM {$table} WHERE id = %d", $pid), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    ARRAY_A
                );
                if ($product) {
                    $images = impulse_get_product_images($product);
                    $product['image_url'] = $images[0] ?? '';
                    $regularPrice = floatval($product['price']);
                    $salePrice = floatval($product['sale_price']);
                    $product['effective_price'] = $regularPrice > 0 ? $regularPrice : ($salePrice > 0 ? $salePrice : 0);
                    $product['price_formatted'] = \Instinctor\Services\CurrencyService::formatPrice($product['effective_price']);
                    $products[] = $product;
                }
            }
        }

        $pricing = \Instinctor\Services\ProductBundleService::calculateBundlePrice($bundleId);

        wp_send_json_success([
            'bundle'   => $bundle,
            'products' => $products,
            'pricing'  => $pricing,
        ]);
    }

    /**
     * AJAX: Add all bundle products to cart at once
     */
    public function ajaxAddBundleToCart(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        check_ajax_referer('instinctor_frontend', 'nonce');

        $bundleId = intval($_POST['bundle_id'] ?? 0);
        if (!$bundleId) {
            wp_send_json_error(['message' => 'Invalid bundle ID']);
        }

        $bundle = \Instinctor\Services\ProductBundleService::getBundle($bundleId);
        if (!$bundle || !intval($bundle['is_active'])) {
            wp_send_json_error(['message' => 'Bundle not found or inactive']);
        }

        $productIds = array_map('intval', $bundle['products'] ?? []);
        if (empty($productIds)) {
            wp_send_json_error(['message' => 'This bundle has no products']);
        }

        // For build-your-own, accept selected products from POST
        if ($bundle['bundle_type'] === 'build_your_own') {
            $selected = array_map('intval', (array)($_POST['selected_products'] ?? []));
            if (!empty($selected)) {
                $productIds = array_intersect($productIds, $selected) ?: $selected;
            }
            $min = intval($bundle['min_products']);
            $max = intval($bundle['max_products']);
            if ($min > 0 && count($productIds) < $min) {
                wp_send_json_error(['message' => "Please select at least {$min} products"]);
            }
            if ($max > 0 && count($productIds) > $max) {
                wp_send_json_error(['message' => "Please select no more than {$max} products"]);
            }
        }

        $cartService = new \Instinctor\Services\CartService();
        $added = 0;
        foreach ($productIds as $pid) {
            $result = $cartService->addItem($pid, 1, ['_bundle_id' => $bundleId]);
            if ($result['success']) {
                $added++;
            }
        }

        if ($added === 0) {
            wp_send_json_error(['message' => 'Failed to add bundle items to cart']);
        }

        wp_send_json_success([
            'message'    => __('Bundle added to cart!', 'instinctor'),
            'cart_count' => $cartService->getItemCount(),
            'added'      => $added,
        ]);
    }

    public function ajaxCheckGiftCard(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        $code = isset($_POST['code']) ? sanitize_text_field(wp_unslash($_POST['code'])) : '';
        $result = \Instinctor\Services\GiftCardService::checkBalance($code);
        
        if ($result['found']) {
            $result['balance_formatted'] = \Instinctor\Services\CurrencyService::formatPrice($result['balance']);
        }

        wp_send_json_success($result);
    }


    private function getCustomerId(): ?int
    {
        if (!Auth::check()) {
            return null;
        }

        $p = DB::prefix();        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Customer ID lookup
        return (int) DB::get_var(DB::prepare(
            "SELECT id FROM {$p}instinctor_customers WHERE user_id = %d",
            Auth::userId()
        )) ?: null;
    }

    /**
     * AJAX: Register visitor for live visitor count
     */

    public function ajaxRegisterVisitor(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        
        $productId = isset($_POST['product_id']) ? absint($_POST['product_id']) : 0;
        $pageUrl = isset($_POST['page_url']) ? esc_url_raw(wp_unslash($_POST['page_url'])) : '';
        $sessionId = $this->getVisitorSessionId();
        
        // Insert or update visitor record
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Visitor session tracking
        DB::replace(
            DB::prefix() . 'instinctor_visitor_sessions',
            [
                'session_id' => $sessionId,
                'product_id' => $productId,
                'page_url' => $pageUrl,
                'ip_address' => $this->getClientIp(),
                'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '',
                'last_activity' => current_time('mysql'),
            ],
            ['%s', '%d', '%s', '%s', '%s', '%s']
        );
        
        wp_send_json_success();
    }

    /**
     * AJAX: Get stock status for a product
     */
    public function ajaxGetStockStatus(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        $p = DB::prefix();
        $productId = isset($_POST['product_id']) ? absint($_POST['product_id']) : 0;
        
        if (!$productId) {
            wp_send_json_error(['message' => 'Product ID required']);
        }
        
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Stock lookup
        $stock = (int)DB::get_var(DB::prepare("SELECT stock_quantity FROM {$p}instinctor_products WHERE id = %d", $productId));
        
        wp_send_json_success(['stock' => $stock]);
    }

    /**
     * Get human-readable time ago string
     */
    private function getTimeAgo(int $timestamp): string
    {
        $diff = time() - $timestamp;
        
        if ($diff < 60) {
            return 'Just now';
        } elseif ($diff < 3600) {
            $mins = floor($diff / 60);
            return $mins . ' minute' . ($mins > 1 ? 's' : '') . ' ago';
        } elseif ($diff < 86400) {
            $hours = floor($diff / 3600);
            return $hours . ' hour' . ($hours > 1 ? 's' : '') . ' ago';
        } else {
            $days = floor($diff / 86400);
            return $days . ' day' . ($days > 1 ? 's' : '') . ' ago';
        }
    }

    /**
     * Get or create visitor session ID
     */
    private function getVisitorSessionId(): string
    {
        if (!isset($_COOKIE['instinctor_visitor_session'])) {
            $sessionId = wp_generate_uuid4();
            setcookie('instinctor_visitor_session', $sessionId, time() + 86400, COOKIEPATH, COOKIE_DOMAIN);
            return $sessionId;
        }
        
        return sanitize_text_field(wp_unslash((string)$_COOKIE['instinctor_visitor_session']));
    }

    /**
     * Get client IP address
     */
    private function getClientIp(): string
    {
        $ip = '';
        
        if (!empty($_SERVER['HTTP_CLIENT_IP'])) {
            $ip = sanitize_text_field(wp_unslash((string)($_SERVER['HTTP_CLIENT_IP'] ?? '')));
        } elseif (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
            $forwarded = sanitize_text_field(wp_unslash((string)($_SERVER['HTTP_X_FORWARDED_FOR'] ?? '')));
            $ip = explode(',', $forwarded)[0];
        } elseif (!empty($_SERVER['REMOTE_ADDR'])) {
            $ip = sanitize_text_field(wp_unslash((string)($_SERVER['REMOTE_ADDR'] ?? '')));
        }
        
        return trim($ip);
    }

    // ===================
    // ADDON AJAX HANDLERS (2.1.0)
    // ===================

    /**
     * One-click buy now - add to cart and redirect to checkout
     */
    public function ajaxBuyNow(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'] ?? '')), 'instinctor_frontend')) {
            wp_send_json_error(['message' => 'Invalid nonce']);
        }

        $productId = intval($_POST['product_id'] ?? 0);
        $quantity = intval($_POST['quantity'] ?? 1);
        $variationId = intval($_POST['variation_id'] ?? 0);

        if (!$productId) {
            wp_send_json_error(['message' => __('Invalid product', 'instinctor')]);
        }

        // Clear cart and add this product
        $cartSvc = new \Instinctor\Services\CartService();
        $cartSvc->clear();
        $result  = $cartSvc->addItem($productId, $quantity);
        
        if ($result) {
            $checkoutUrl = $this->getPageUrl('checkout');
            wp_send_json_success(['redirect' => $checkoutUrl]);
        } else {
            wp_send_json_error(['message' => __('Unable to add product to cart', 'instinctor')]);
        }
    }
    
    /**
     * Apply coupon code via AJAX
     */
    public function ajaxApplyCoupon(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        check_ajax_referer('instinctor_frontend', 'nonce');
        
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
        $couponCode = isset($_POST['coupon_code']) ? sanitize_text_field(wp_unslash($_POST['coupon_code'])) : '';
        
        if (empty($couponCode)) {
            wp_send_json_error(['message' => __('Please enter a coupon code', 'instinctor')]);
        }
        
        // Get coupon from database
        $couponService = new \Instinctor\Services\CouponService();
        $coupon = $couponService->getCoupon($couponCode);
        
        if (!$coupon) {
            wp_send_json_error(['message' => __('Invalid coupon code', 'instinctor')]);
        }
        
        // Get cart for validation
        $cart = \Instinctor\Services\CartService::getCart();
        $email = Auth::check() ? Auth::user()->user_email : null;
        
        // Validate coupon
        $validation = $couponService->validateCoupon($couponCode, $cart, $email);
        if (!$validation['valid']) {
            wp_send_json_error(['message' => $validation['error']]);
        }
        
        // Calculate discount
        $subtotal = array_reduce($cart['items'] ?? [], function($sum, $item) {
            return $sum + ($item['price'] * $item['quantity']);
        }, 0);
        
        $discount = 0;
        if ($coupon->type === 'percentage') {
            $discount = $subtotal * ($coupon->amount / 100);
        } else {
            $discount = min($coupon->amount, $subtotal);
        }
        
        // Apply to cart
        \Instinctor\Services\CartService::applyCoupon([
            'code' => $coupon->code,
            'type' => $coupon->type,
            'amount' => $coupon->amount
        ], $discount);
        
        wp_send_json_success([
            'coupon' => $coupon->code,
            'discount' => $discount,
            /* translators: %s: coupon code */
            'message' => sprintf(__('Coupon "%s" applied successfully!', 'instinctor'), $coupon->code)
        ]);
    }
    
    /**
     * Handle Impulse form submissions
     */
    public function ajaxSubmitForm(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        $ip = Security::clientIp();

        // Rate limit: 3 contact form submissions per minute per IP
        if (!Security::rateLimit('contact_form', $ip)) {
            wp_send_json_error(['message' => __('Too many submissions. Please wait a moment.', 'instinctor')]);
        }

        // Honeypot check (timing + hidden field).
        // A confirmed bot gets a fake success so detection stays hidden. Anything
        // else that fails gets a REAL error: this handler is the only record of a
        // contact message, so answering "submitted successfully" and dropping it
        // loses the message with no copy anywhere and no way for the sender to
        // know. That is what /talk-to-us was doing to every message sent more
        // than an hour after page load. Sergei 2026-08-01.
        $hp = Security::checkHoneypot();
        if ($hp === \Instinctor\Core\Security\Spam::RESULT_BOT_HONEYPOT
            || $hp === \Instinctor\Core\Security\Spam::RESULT_BOT_TOO_FAST) {
            wp_send_json_success(['message' => __('Form submitted successfully', 'instinctor')]);
            return;
        }
        if ($hp !== \Instinctor\Core\Security\Spam::RESULT_OK) {
            wp_send_json_error(['message' => __('This page was open too long. Please reload and send again.', 'instinctor')]);
            return;
        }

        check_ajax_referer('instinctor_frontend', 'nonce');
        
        // Get form data
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
        $formName = isset($_POST['form_name']) ? sanitize_text_field(wp_unslash($_POST['form_name'])) : 'Contact Form';
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput
        $formId = isset($_POST['form_id']) ? absint($_POST['form_id']) : 0;
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- absint id, used only to read this form block's own stored notify_sms setting.
        $pageId = isset($_POST['page_id']) ? absint($_POST['page_id']) : 0;
        
        // Collect all form fields (exclude system fields)
        $systemFields = ['action', 'nonce', 'form_name', 'form_id', '_instinctor_hp', '_instinctor_ts', 'instinctor_hp', 'attribution'];
        $formData = [];
        
        // phpcs:ignore WordPress.Security.NonceVerification.Missing
        foreach ($_POST as $key => $value) {
            if (in_array($key, $systemFields, true)) {
                continue;
            }
            // Sanitize based on content
            if (is_array($value)) {
                $formData[$key] = array_map('sanitize_text_field', wp_unslash($value));
            } else {
                // Allow basic HTML in textarea-like fields
                $cleanKey = sanitize_key($key);
                $formData[$cleanKey] = wp_kses_post(wp_unslash($value));
            }
        }
        
        if (empty($formData)) {
            wp_send_json_error(['message' => __('No form data received', 'instinctor')]);
        }

        // Server-side required-field enforcement. `required` was only ever the
        // browser attribute, so a direct POST with every field blank was accepted,
        // stored, emailed and (once ads were running) counted as a conversion.
        // Checked against the BLOCK'S OWN stored field definitions, never a
        // client-sent list, so it cannot be forged. (Sergei 2026-08-11)
        $missing = $this->missingRequiredFields($formData, $formId, $pageId);
        if ($missing !== []) {
            wp_send_json_error([
                'message' => __('Please fill in every required field.', 'instinctor'),
                'fields'  => $missing,
            ]);
        }
        
        // Store submission in database
        $table = DB::prefix() . 'instinctor_form_submissions';
        
        // Create table if not exists
        $this->maybeCreateFormSubmissionsTable();
        \Instinctor\Services\AnalyticsService::ensureAttributionColumn($table);

        // The page's owner scopes the row AND routes the notifications: without it a
        // tenant's form lead emailed the vendor and could never reach their dashboard.
        $formOwnerId = null;
        if ($pageId > 0) {
            $formOwnerId = DB::get_var(DB::prepare(
                "SELECT owner_id FROM " . DB::prefix() . "instinctor_pages WHERE id = %d", $pageId
            ));
            $formOwnerId = ($formOwnerId !== null && (int) $formOwnerId > 0) ? (int) $formOwnerId : null;
        }
        $submissionData = [
            'attribution' => \Instinctor\Services\AnalyticsService::attributionFromRequest(),
            'form_name' => $formName,
            'form_id' => $formId,
            'owner_id' => $formOwnerId,
            'data' => wp_json_encode($formData),
            'ip_address' => $this->getClientIp(),
            'user_agent' => isset($_SERVER['HTTP_USER_AGENT']) ? sanitize_text_field(wp_unslash($_SERVER['HTTP_USER_AGENT'])) : '',
            'user_id' => Auth::userId(),
            'created_at' => current_time('mysql'),
        ];
        
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        $inserted = DB::insert($table, $submissionData);
        
        if (!$inserted) {
            wp_send_json_error(['message' => __('Failed to save submission', 'instinctor')]);
        }
        
        $submissionId = DB::insert_id();
        
        // Email the PAGE'S OWNER when the form sits on a tenant page; the vendor
        // only for vendor pages (instinctor.com's own forms).
        $this->sendFormNotificationEmail($formName, $formData, $submissionId, $formOwnerId);

        // Owner SMS notification (reuses the platform SMS relay). No-op unless
        // instinctor_form_notify_sms holds a number AND this form opted in.
        $this->sendFormNotificationSms($formName, $formData, $formId, $pageId);

        // Mirror migration-request submissions into Platform > Tickets.
        $this->maybeCreatePlatformTicket($formName, $formData);

        // Trigger action for integrations (no-op on standalone — hooks are shimmed empty)
        do_action('instinctor_form_submitted', $formName, $formData, $submissionId);
        
        wp_send_json_success([
            'message' => __('Form submitted successfully', 'instinctor'),
            'submission_id' => $submissionId
        ]);
    }

    /**
     * Mirror a "Site migration request" submission into the Platform ticket
     * queue (Platform > Tickets) via its public /api/report endpoint, so the
     * team works migration leads alongside support tickets. No-op for every
     * other form. The submission is already stored + emailed; a relay failure
     * here is logged, never surfaced to the visitor.
     */
    private function maybeCreatePlatformTicket(string $formName, array $formData): void
    {
        if ($formName !== 'Site migration request') {
            return;
        }

        $name    = trim((string) ($formData['name']     ?? ''));
        $email   = trim((string) ($formData['email']    ?? ''));
        $siteUrl = trim((string) ($formData['site_url'] ?? ''));
        $note    = trim((string) ($formData['note']     ?? ''));

        // The Platform endpoint requires a valid email; without one there's
        // nothing to file (and the form marks email required anyway).
        if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
            return;
        }

        $body = "Migration request from the instinctor.com homepage.\n\n"
              . 'Name: '         . ($name    !== '' ? $name    : '(not given)') . "\n"
              . 'Current site: ' . ($siteUrl !== '' ? $siteUrl : '(not given)') . "\n\n"
              . "Notes:\n" . ($note !== '' ? $note : '(none)');

        $subject = 'Site migration request' . ($siteUrl !== '' ? ': ' . $siteUrl : '');

        // WP.org guideline 7/9 — migration ticket only lives on standalone
        // (the marketing site), where consent is always true. Belt-and-braces
        // gate so the same controller is safe if wired up in WP mode later.
        if (!\Instinctor\Core\PlatformConsent::granted()) {
            wp_send_json_success(['queued' => false, 'reason' => 'platform_consent_required']);
            return;
        }

        $resp = wp_remote_post('https://platform.instinctor.com/api/report', [
            'timeout' => 15,
            'body'    => [
                'email'    => $email,
                'site_url' => $siteUrl,
                'subject'  => $subject,
                'body'     => $body,
                'type'     => 'support',
                'env'      => 'marketing',
            ],
        ]);

        if (is_wp_error($resp)) {
            // phpcs:disable WordPress.PHP.DevelopmentFunctions -- server-side error capture in catch block, not debug output
            error_log('[Instinctor] migration ticket relay failed: ' . $resp->get_error_message());
            // phpcs:enable WordPress.PHP.DevelopmentFunctions
        }
    }

    /**
     * Create form submissions table if not exists
     */
    private function maybeCreateFormSubmissionsTable(): void
    {
        $table = DB::prefix() . 'instinctor_form_submissions';
        
        // Check if table exists
        // phpcs:ignore WordPress.DB.DirectDatabaseQuery
        if (DB::get_var(DB::prepare("SHOW TABLES LIKE %s", $table)) === $table) {
            // Rows never carried the page's owner, so a tenant's form leads were
            // unscopable: invisible to the tenant, mixed into the vendor's list
            // (Sergei 2026-08-01). Heal existing tables in place.
            if (!DB::get_var("SHOW COLUMNS FROM {$table} LIKE 'owner_id'")) {
                DB::query("ALTER TABLE {$table} ADD COLUMN owner_id bigint(20) unsigned DEFAULT NULL AFTER form_id, ADD KEY owner_id (owner_id)");
            }
            return;
        }
        
        // Direct CREATE via the DB driver — dbDelta + wp-admin/upgrade.php is
        // unreliable on standalone (the table silently never got created).
        DB::query("CREATE TABLE IF NOT EXISTS {$table} (
            id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
            form_name varchar(255) NOT NULL,
            form_id bigint(20) unsigned DEFAULT 0,
            owner_id bigint(20) unsigned DEFAULT NULL,
            data longtext NOT NULL,
            ip_address varchar(45) DEFAULT '',
            user_agent text,
            user_id bigint(20) unsigned DEFAULT 0,
            is_read tinyint(1) DEFAULT 0,
            created_at datetime DEFAULT CURRENT_TIMESTAMP,
            PRIMARY KEY (id),
            KEY form_name (form_name),
            KEY created_at (created_at),
            KEY is_read (is_read)
        ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci");
    }
    
    /**
     * Explicit lead recipients for form notifications, as a validated list.
     *
     * Reads instinctor_lead_notify_email, which accepts a comma-separated list so a
     * lead can reach the business owner AND whoever actually works the inbox. This
     * exists because the fallback chain below starts at admin_email, and admin_email
     * is the WordPress system address: on a managed install that is US, not the
     * client, so every lead landed with the operator and never with the business.
     * Bath Done's owner tested his own form on 2026-08-13 and the notice came to
     * Sergei. Changing admin_email would fix leads by dragging WP's password
     * resets and update notices along with it, so leads get their own setting.
     *
     * Blank (every install that has not set it) returns [] and the historical
     * chain runs untouched, so this can never change an existing site's routing.
     * Owner-scoped when a tenant owns the page: ownerOnly, never getForOwner, so
     * "this tenant set no recipient" means fall back, not inherit the vendor's.
     */
    private function leadNotifyRecipients(?int $ownerId = null): array
    {
        $raw = $ownerId !== null
            ? (string) Config::ownerOnly($ownerId, 'instinctor_lead_notify_email', '')
            : (string) Config::get('instinctor_lead_notify_email', '');

        $out = [];
        foreach (preg_split('/[,;]+/', $raw) ?: [] as $addr) {
            $addr = trim($addr);
            if ($addr !== '' && is_email($addr)) {
                $out[strtolower($addr)] = $addr;
            }
        }
        return array_values($out);
    }

    /**
     * Send email notification for form submission
     */
    private function sendFormNotificationEmail(string $formName, array $formData, int $submissionId, ?int $ownerId = null): void
    {
        // A tenant page's lead belongs to the tenant. ownerOnly, then their account
        // email: the global fallthrough here is exactly how tenant leads emailed us.
        if ($ownerId !== null) {
            $explicit = $this->leadNotifyRecipients($ownerId);
            if ($explicit !== []) {
                $subject = sprintf('[%s] New %s Submission', get_bloginfo('name'), $formName);
                $html    = \Instinctor\Services\EmailService::buildFormNotificationHtml(
                    'New ' . $formName . ' Submission',
                    $formData,
                    ['Submission' => '#' . $submissionId, 'Received' => current_time('F j, Y g:i a')]
                );
                foreach ($explicit as $addr) {
                    \Instinctor\Services\EmailService::sendDirectEmail($addr, $subject, $html);
                }
                return;
            }
            $to = trim((string) Config::ownerOnly($ownerId, 'instinctor_store_email', ''));
            if ($to === '') {
                $to = trim((string) DB::get_var(DB::prepare(
                    "SELECT email FROM " . DB::prefix() . "instinctor_users WHERE id = %d LIMIT 1", $ownerId
                )));
            }
            if ($to === '') return;
            $subject = sprintf('[%s] New %s Submission', get_bloginfo('name'), $formName);
            \Instinctor\Services\EmailService::sendDirectEmail($to, $subject,
                \Instinctor\Services\EmailService::buildFormNotificationHtml(
                    'New ' . $formName . ' Submission',
                    $formData,
                    ['Submission' => '#' . $submissionId, 'Received' => current_time('F j, Y g:i a')]
                )
            );
            return;
        }
        // Explicit lead recipients win over the whole fallback chain below. See
        // leadNotifyRecipients(): admin_email is the operator's address on every
        // managed install, so without this a client's leads never reach the client.
        $explicit = $this->leadNotifyRecipients(null);
        if ($explicit !== []) {
            $subject = sprintf('[%s] New %s Submission', get_bloginfo('name'), $formName);
            $html    = \Instinctor\Services\EmailService::buildFormNotificationHtml(
                'New ' . $formName . ' Submission',
                $formData,
                [
                    'Submission' => '#' . $submissionId,
                    'Received'   => current_time('F j, Y g:i a'),
                    'IP'         => $this->getClientIp(),
                ]
            );
            foreach ($explicit as $addr) {
                \Instinctor\Services\EmailService::sendDirectEmail($addr, $subject, $html);
            }
            return;
        }

        // Robust recipient: admin_email -> store_email -> first active owner/admin
        // user. Standalone installs create the primary user as role 'owner', not
        // 'admin', so a query for role='admin' alone silently found nobody and no
        // lead email ever went out (MOVE Studio, 2026-07-04). Match both, owner first.
        $adminEmail = (string) Config::get('admin_email', '');
        if ($adminEmail === '') $adminEmail = (string) Config::get('instinctor_store_email', '');
        if ($adminEmail === '') {
            $adminEmail = (string) DB::get_var("SELECT email FROM " . DB::prefix() . "instinctor_users WHERE role IN ('owner','admin') AND active=1 ORDER BY FIELD(role,'owner','admin'), id ASC LIMIT 1");
        }
        if ($adminEmail === '') return; // nowhere to send
        $siteName = get_bloginfo('name');
        
        $subject = sprintf('[%s] New %s Submission', $siteName, $formName);
        
        \Instinctor\Services\EmailService::sendDirectEmail($adminEmail, $subject,
            \Instinctor\Services\EmailService::buildFormNotificationHtml(
                'New ' . $formName . ' Submission',
                $formData,
                [
                    'Submission' => '#' . $submissionId,
                    'Received'   => current_time('F j, Y g:i a'),
                    'IP'         => $this->getClientIp(),
                ]
            )
        );
    }

    /**
     * Text the site owner on a new form submission, AS THE PAGE'S OWNER.
     *
     * An AJAX POST renders no page, so Tenant::currentOwnerId() was null here and every
     * tenant's form read the VENDOR's global notify number and texted the vendor on that
     * tenant's behalf, on our Telnyx bill. The page carries its owner_id, so enter it and
     * the whole body resolves correctly: the owner's own number, gated by the owner's own
     * plan. A vendor page has owner_id NULL, which withOwner(null) declares explicitly, so
     * instinctor.com's own forms keep working under the now-fail-shut gate.
     */
    private function sendFormNotificationSms(string $formName, array $formData, int $formId = 0, int $pageId = 0): void
    {
        $ownerId = null;
        if ($pageId > 0) {
            $ownerId = DB::get_var(DB::prepare(
                "SELECT owner_id FROM " . DB::prefix() . "instinctor_pages WHERE id = %d",
                $pageId
            ));
            $ownerId = ($ownerId !== null && (int) $ownerId > 0) ? (int) $ownerId : null;
        }
        \Instinctor\Core\Tenant::withOwner($ownerId, function () use ($formName, $formData, $formId, $pageId, $ownerId) {
            $this->sendFormNotificationSmsFor($formName, $formData, $formId, $pageId, $ownerId);
        });
    }

    /** The body of sendFormNotificationSms, run inside the page owner's context. */
    private function sendFormNotificationSmsFor(string $formName, array $formData, int $formId = 0, int $pageId = 0, ?int $ownerId = null): void
    {
        $to = trim((string) Config::get('instinctor_form_notify_sms', ''));
        if ($to === '') {
            return;
        }
        // Per-form opt-in (off by default): only text if THIS form block's own
        // notify_sms setting is on. Read server-side so it can't be forged.
        if (!$this->formWantsSms($formId, $pageId)) {
            return;
        }
        $store = (string) Config::get('instinctor_store_name', get_bloginfo('name'));
        $name  = trim((string) ($formData['name']  ?? ''));
        $email = trim((string) ($formData['email'] ?? ''));
        $phone = trim((string) ($formData['phone'] ?? ''));
        $type  = trim((string) ($formData['event_type'] ?? ($formData['type'] ?? '')));
        $date  = trim((string) ($formData['event_date'] ?? ($formData['date'] ?? '')));
        $detail = trim(($type !== '' ? $type : '') . ($date !== '' ? ($type !== '' ? ' on ' : 'on ') . $date : ''));
        $who     = $name !== '' ? $name : 'Someone';
        $contact = $email !== '' ? $email : $phone;
        $msg = "New {$formName}: " . ($detail !== '' ? $detail . '. ' : '') . "From {$who}" . ($contact !== '' ? " ({$contact})" : '') . ". - {$store}";
        // sendLeadAlert, not send: a form submission IS a lead, so it counts against the
        // owner's free trial alerts and stops after those. Strictly to their own notify
        // number, never to a customer.
        //
        // EXCEPT on a vendor page (no owner_id): those are instinctor.com's own lead
        // forms, so the free-plan trailer would staple our own Pro upsell onto our own
        // lead alerts, and the trial counter is meaningless with no owner to bill.
        // Sms::send already exempts the vendor from the Pro gate (Sergei 2026-08-03).
        try {
            if ($ownerId === null) {
                \Instinctor\Core\Sms::send($to, $msg);
            } else {
                \Instinctor\Core\Sms::sendLeadAlert($to, $msg);
            }
        } catch (\Throwable $e) {}
    }

    /**
     * Whether the given form block opted in to SMS alerts. Reads the block's
     * stored notify_sms setting (authoritative — not a client-sent field), so a
     * forged hidden field can't trigger a text. Off by default (opt-in).
     */
    /**
     * Required fields the submission left empty, by field name.
     *
     * Read from the form block's OWN stored definitions, so a forged hidden
     * field cannot declare a form "has no required fields". Returns [] when the
     * block declares no field list of its own: those fall back to the renderer's
     * default set, and enforcing a set the block never declared would start
     * rejecting live leads on installs we cannot inspect. This is deliberately
     * narrow, it closes the blank-POST hole without touching anything else.
     *
     * @return list<string>
     */
    private function missingRequiredFields(array $formData, int $formId, int $pageId): array
    {
        if ($formId <= 0 || $pageId <= 0) {
            return [];
        }
        $fields = null;
        foreach (\Instinctor\Services\PageService::getElements($pageId) as $el) {
            if ((int) ($el['id'] ?? 0) !== $formId) {
                continue;
            }
            $f = $el['settings']['fields'] ?? null;
            if (is_string($f)) { $f = json_decode($f, true); }
            if (is_array($f)) { $fields = $f; }
            break;
        }
        if (!is_array($fields) || $fields === []) {
            return [];
        }

        $missing = [];
        foreach ($fields as $f) {
            if (!is_array($f) || empty($f['required'])) {
                continue;
            }
            $name = sanitize_key((string) ($f['name'] ?? ''));
            if ($name === '') {
                continue;
            }
            $v = $formData[$name] ?? '';
            if (is_array($v)) { $v = implode('', $v); }
            $v = trim((string) $v);
            if ($v === '') {
                $missing[] = $name;
                continue;
            }
            // A required email that is not an email is as useless as a blank one:
            // the lead cannot be answered either way.
            if (strtolower((string) ($f['type'] ?? '')) === 'email'
                && !filter_var($v, FILTER_VALIDATE_EMAIL)) {
                $missing[] = $name;
            }
        }
        return $missing;
    }

    private function formWantsSms(int $formId, int $pageId): bool
    {
        if ($formId <= 0 || $pageId <= 0) {
            return false;
        }
        $elements = \Instinctor\Services\PageService::getElements($pageId);
        foreach ($elements as $el) {
            if ((int) ($el['id'] ?? 0) === $formId) {
                $ns = $el['settings']['notify_sms'] ?? '';
                return $ns === '1' || $ns === 1 || $ns === true;
            }
        }
        return false;
    }
    
    /**
     * AJAX: Get Quick View content
     */
    public function ajaxGetQuickView(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        check_ajax_referer('instinctor_frontend', 'nonce');
        
        $productId = intval($_POST['product_id'] ?? 0);
        
        if (!$productId) {
            wp_send_json_error(['message' => 'Invalid product ID']);
        }
        
        
        // First try database table (Instinctor native products)
        $productsTable = DB::prefix() . 'instinctor_products';
        $dbProduct = DB::get_row(DB::prepare("SELECT * FROM $productsTable WHERE id = %d", $productId));
        
        if ($dbProduct) {
            // Use database product
            $this->renderQuickViewFromDb($dbProduct);
            return;
        }
        
        // Fallback to WordPress post type
        $product = get_post($productId);
        
        if (!$product || $product->post_type !== 'instinctor_product') {
            wp_send_json_error(['message' => 'Product not found']);
        }
        
        $productData = get_post_meta($productId, '_instinctor_product_data', true) ?: [];
        
        // Get images
        $images = [];
        $featuredId = get_post_thumbnail_id($productId);
        if ($featuredId) {
            $images[] = wp_get_attachment_image_url($featuredId, 'large');
        }
        $galleryIds = $productData['gallery_ids'] ?? [];
        foreach ($galleryIds as $gid) {
            $url = wp_get_attachment_image_url($gid, 'large');
            if ($url) {
                $images[] = $url;
            }
        }
        
        // If no images, use placeholder
        if (empty($images)) {
            $images[] = INSTINCTOR_PLUGIN_URL . 'assets/images/placeholder.svg';
        }
        
        // Get thumbnails for gallery
        $thumbs = [];
        if ($featuredId) {
            $thumbs[] = wp_get_attachment_image_url($featuredId, 'thumbnail');
        }
        foreach ($galleryIds as $gid) {
            $url = wp_get_attachment_image_url($gid, 'thumbnail');
            if ($url) {
                $thumbs[] = $url;
            }
        }
        
        // Product details - fix price handling
        $price = floatval($productData['price'] ?? 0);
        $salePrice = floatval($productData['sale_price'] ?? 0);
        // Only show sale if BOTH prices exist and sale < regular
        $hasSale = $salePrice > 0 && $price > 0 && $salePrice < $price;
        $effectivePrice = $hasSale ? $salePrice : ($price > 0 ? $price : $salePrice);
        $description = $product->post_excerpt ?: wp_trim_words($product->post_content, 30);
        $permalink = get_permalink($productId);
        
        // Get variations
        $variations = $productData['variations'] ?? [];
        $attributes = $productData['attributes'] ?? [];
        
        // Visibility flags from element modal_design settings
        $showDesc  = filter_var(wp_unslash($_POST['show_desc'] ?? true),  FILTER_VALIDATE_BOOLEAN);
        $descType  = sanitize_text_field(wp_unslash($_POST['show_desc_type'] ?? 'short'));
        $showPrice = filter_var(wp_unslash($_POST['show_price'] ?? true),  FILTER_VALIDATE_BOOLEAN);
        $showQty   = filter_var(wp_unslash($_POST['show_qty'] ?? true),  FILTER_VALIDATE_BOOLEAN);
        // Button styling from element modal_design settings
        $btnBg     = sanitize_hex_color(wp_unslash($_POST['btn_bg']   ?? '')) ?: '#1d2327';
        $btnText   = sanitize_hex_color(wp_unslash($_POST['btn_text'] ?? '')) ?: '#ffffff';
        $btnRadius = intval($_POST['btn_radius'] ?? 6);

        // Choose description
        if ($descType === 'long') {
            $displayDesc = $product->post_content ? wp_trim_words($product->post_content, 80) : '';
        } else {
            $displayDesc = $product->post_excerpt ?: wp_trim_words($product->post_content, 30);
        }
        $sku = $productData['sku'] ?? '';

        // Build HTML
        $html = '<div class="impulse-quickview-content">';
        
        // Gallery
        $html .= '<div class="impulse-quickview-gallery">';
        $html .= '<div class="impulse-quickview-gallery-main">';
        foreach ($images as $i => $img) {
            $activeClass = $i === 0 ? ' active' : '';
            $html .= '<img src="' . esc_url($img) . '" class="' . $activeClass . '" alt="">';
        }
        $html .= '<button class="impulse-quickview-zoom-btn" aria-label="Zoom"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="11" x2="11" y1="8" y2="14"/><line x1="8" x2="14" y1="11" y2="11"/></svg></button>';
        $html .= '</div>';
        // Thumbs strip
        if (count($images) > 1) {
            $html .= '<div class="impulse-quickview-thumbs">';
            foreach ($images as $i => $img) {
                $activeClass = $i === 0 ? ' active' : '';
                $html .= '<div class="impulse-quickview-thumb' . $activeClass . '" data-index="' . $i . '"><img src="' . esc_url($img) . '" alt=""></div>';
            }
            $html .= '</div>';
        }
        $html .= '</div>'; // gallery

        // Details
        $html .= '<div class="impulse-quickview-details">';
        $html .= '<h2 class="impulse-quickview-title">' . esc_html($product->post_title) . '</h2>';

        // Description
        if ($showDesc && $displayDesc) {
            $html .= '<div class="impulse-quickview-desc">' . esc_html($displayDesc) . '</div>';
        }

        // SKU

        // Variants
        if (!empty($attributes)) {
            $html .= '<div class="impulse-quickview-variants">';
            foreach ($attributes as $attr) {
                $attrName = $attr['name'] ?? '';
                $attrValues = $attr['values'] ?? [];
                if ($attrName && !empty($attrValues)) {
                    $attrSlug = sanitize_title($attrName);
                    $html .= '<div class="impulse-quickview-variant-group" data-attribute="' . esc_attr($attrSlug) . '">';
                    $html .= '<div class="impulse-quickview-variant-label">' . esc_html($attrName) . '</div>';
                    $html .= '<div class="impulse-quickview-variant-options">';
                    foreach ($attrValues as $j => $val) {
                        $activeClass = $j === 0 ? ' active' : '';
                        if (strtolower($attrName) === 'color' && preg_match('/^#[a-f0-9]{6}$/i', $val)) {
                            $html .= '<div class="impulse-quickview-color-swatch' . $activeClass . '" style="background:' . esc_attr($val) . '" data-value="' . esc_attr($val) . '"></div>';
                        } else {
                            $html .= '<button class="impulse-quickview-variant-btn' . $activeClass . '" data-value="' . esc_attr($val) . '">' . esc_html($val) . '</button>';
                        }
                    }
                    $html .= '</div></div>';
                }
            }
            $html .= '</div>';
        }

        // Add-ons from block settings
        $addonDefs = json_decode(sanitize_textarea_field(wp_unslash($_POST['addon_defs'] ?? '[]')), true) ?: [];
        $addonHtmlWp = $this->renderAddonsFromDefs($addonDefs, $productId);
        if ($addonHtmlWp) {
            $html .= '<div class="impulse-quickview-addons" style="margin:12px 0;padding:12px 0;border-top:1px solid #f0f0f0">';
            $html .= $addonHtmlWp;
            $html .= '</div>';
        }

        // Price + Qty row + ATC
        $html .= '<div class="impulse-quickview-actions">';
        $html .= '<div class="impulse-quickview-price-qty-row">';
        if ($showQty) {
            $html .= '<div class="impulse-quickview-qty-control">';
            $html .= '<button class="impulse-quickview-qty-btn" data-action="minus">−</button>';
            $html .= '<input type="number" class="impulse-quickview-qty-input" value="1" min="1" max="999">';
            $html .= '<button class="impulse-quickview-qty-btn" data-action="plus">+</button>';
            $html .= '</div>';
        }
        if ($showPrice) {
            $base = $hasSale ? $salePrice : $effectivePrice;
            $html .= '<div class="impulse-quickview-price" data-base-price="' . number_format($base, 2) . '">';
            if ($hasSale) {
                $html .= '<span class="sale">$' . number_format($salePrice, 2) . '</span>';
                $html .= '<del>$' . number_format($price, 2) . '</del>';
            } else {
                $html .= '$' . number_format($effectivePrice, 2);
            }
            $html .= '</div>';
        }
        $html .= '</div>';
        $btnStyle = 'background:' . esc_attr($btnBg) . ';color:' . esc_attr($btnText) . ';border-radius:' . $btnRadius . 'px;';
        $html .= '<button class="impulse-quickview-add-btn" data-product-id="' . $productId . '" style="' . $btnStyle . '">Add to Cart</button>';
        $html .= '</div>';

        $html .= '</div>'; // details
        $html .= '</div>'; // content
        
        wp_send_json_success([
            'html' => $html,
            'product' => [
                'id' => $productId,
                'name' => $product->post_title,
                'price' => $effectivePrice,
                'images' => $images
            ]
        ]);
    }
    
    /**
     * Render add-ons form HTML from block settings definitions.
     * Replaces DB-driven ProductAddonService::renderAddonsHtml().
     */
    private function renderAddonsFromDefs(array $defs, int $productId): string
    {
        if (empty($defs)) return '';

        static $counter = 0;
        $counter++;
        $wrapId = 'ia-addons-' . $productId . '-' . $counter;

        $html = '<div id="' . esc_attr($wrapId) . '" class="instinctor-product-addons" data-product-id="' . esc_attr((string)$productId) . '" style="margin:12px 0">';

        foreach ($defs as $idx => $addon) {
            // Skip addons scoped to a different product
            if (!empty($addon['product_id']) && intval($addon['product_id']) !== $productId) continue;

            $name     = sanitize_text_field($addon['name'] ?? '');
            if (!$name) continue;
            $type      = sanitize_text_field($addon['type'] ?? 'checkbox');
            $multiline = !empty($addon['multiline']);
            $price     = floatval($addon['price'] ?? 0);
            $priceType = sanitize_text_field($addon['price_type'] ?? 'flat');
            $required  = !empty($addon['required']);
            $options   = array_map('sanitize_text_field', (array)($addon['options'] ?? []));

            $priceLbl = '';
            if ($price > 0) {
                $priceLbl = $priceType === 'percent'
                    ? ' (+' . $price . '%)'
                    : ' (+' . \Instinctor\Services\CurrencyService::formatPrice($price) . ')';
            }
            $req = $required ? ' required' : '';

            $html .= '<div class="instinctor-addon-item"'
                . ' data-addon-idx="' . intval($idx) . '"'
                . ' data-addon-name="' . esc_attr($name) . '"'
                . ' data-price="' . esc_attr((string)$price) . '"'
                . ' data-price-type="' . esc_attr($priceType) . '"'
                . ' data-required="' . ($required ? '1' : '0') . '"'
                . ' data-type="' . esc_attr($type) . '"'
                . ' style="margin-bottom:10px">';

            switch ($type) {
                case 'checkbox':
                    $html .= '<label style="display:flex;align-items:flex-start;gap:8px;cursor:pointer;font-size:13px">';
                    $html .= '<input type="checkbox" class="ia-addon-check ia-addon-input" name="addons[' . intval($idx) . ']" value="1"' . $req . ' style="margin-top:2px;flex-shrink:0">';
                    $html .= '<span><strong>' . esc_html($name) . '</strong>' . esc_html($priceLbl) . '</span>';
                    $html .= '</label>';
                    break;
                case 'text':
                    $html .= '<div style="font-size:13px;font-weight:600;margin-bottom:4px">' . esc_html($name) . esc_html($priceLbl) . '</div>';
                    if ($multiline) {
                        $html .= '<textarea class="ia-addon-text ia-addon-input" name="addons[' . intval($idx) . ']"' . $req
                            . ' rows="3" style="width:100%;padding:8px 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;box-sizing:border-box;resize:vertical"></textarea>';
                    } else {
                        $html .= '<input type="text" class="ia-addon-text ia-addon-input" name="addons[' . intval($idx) . ']"' . $req
                            . ' style="width:100%;height:34px;padding:0 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;box-sizing:border-box">';
                    }
                    break;
                case 'select':
                    $html .= '<div style="font-size:13px;font-weight:600;margin-bottom:4px">' . esc_html($name) . esc_html($priceLbl) . '</div>';
                    $html .= '<select class="ia-addon-select ia-addon-input" name="addons[' . intval($idx) . ']"' . $req
                        . ' style="width:100%;height:34px;padding:0 10px;border:1px solid #ddd;border-radius:4px;font-size:13px;box-sizing:border-box">';
                    $html .= '<option value="">Select…</option>';
                    foreach ($options as $opt) {
                        $html .= '<option value="' . esc_attr($opt) . '">' . esc_html($opt) . '</option>';
                    }
                    $html .= '</select>';
                    break;
            }

            $html .= '</div>';
        }

        $html .= '</div>';
        return $html;
    }

    /**
     * Render Quick View from database product
     */
    private function renderQuickViewFromDb($product): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        // Parse images from JSON
        $images = [];
        $thumbs = [];
        if (!empty($product->images)) {
            $imagesData = json_decode($product->images, true);
            if (is_array($imagesData)) {
                foreach ($imagesData as $img) {
                    if (!empty($img['url'])) {
                        $images[] = $img['url'];
                        $thumbs[] = $img['url']; // Same URL for thumbs
                    }
                }
            }
        }
        
        // If no images, use placeholder
        if (empty($images)) {
            $images[] = INSTINCTOR_PLUGIN_URL . 'assets/images/placeholder.svg';
        }
        
        // Product details - fix price handling
        $regPrice = floatval($product->price);
        $saleP = floatval($product->sale_price);
        $hasSale = $saleP > 0 && $regPrice > 0 && $saleP < $regPrice;
        $effectivePrice = $hasSale ? $saleP : ($regPrice > 0 ? $regPrice : $saleP);
        
        $description = $product->short_description ?: wp_trim_words($product->description ?? '', 30);
        $permalink = home_url('/product/' . $product->slug);
        
        // Visibility flags from element modal_design settings
        $showDesc  = filter_var(wp_unslash($_POST['show_desc'] ?? true),  FILTER_VALIDATE_BOOLEAN);
        $descType  = sanitize_text_field(wp_unslash($_POST['show_desc_type'] ?? 'short'));
        $showPrice = filter_var(wp_unslash($_POST['show_price'] ?? true),  FILTER_VALIDATE_BOOLEAN);
        $showQty   = filter_var(wp_unslash($_POST['show_qty'] ?? true),  FILTER_VALIDATE_BOOLEAN);
        // Button styling from element modal_design settings
        $btnBg     = sanitize_hex_color(wp_unslash($_POST['btn_bg']   ?? '')) ?: '#1d2327';
        $btnText   = sanitize_hex_color(wp_unslash($_POST['btn_text'] ?? '')) ?: '#ffffff';
        $btnRadius = intval($_POST['btn_radius'] ?? 6);

        // Choose description
        if ($descType === 'long') {
            $displayDesc = !empty($product->description) ? wp_trim_words($product->description, 80) : '';
        } else {
            $displayDesc = $product->short_description ?: wp_trim_words($product->description ?? '', 30);
        }

        // Build HTML
        $html = '<div class="impulse-quickview-content">';

        // Gallery
        $html .= '<div class="impulse-quickview-gallery">';
        $html .= '<div class="impulse-quickview-gallery-main">';
        foreach ($images as $i => $img) {
            $activeClass = $i === 0 ? ' active' : '';
            $html .= '<img src="' . esc_url($img) . '" class="' . $activeClass . '" alt="">';
        }
        $html .= '<button class="impulse-quickview-zoom-btn" aria-label="Zoom"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" x2="16.65" y1="21" y2="16.65"/><line x1="11" x2="11" y1="8" y2="14"/><line x1="8" x2="14" y1="11" y2="11"/></svg></button>';
        $html .= '</div>';
        // Thumbs strip
        if (count($images) > 1) {
            $html .= '<div class="impulse-quickview-thumbs">';
            foreach ($images as $i => $img) {
                $activeClass = $i === 0 ? ' active' : '';
                $html .= '<div class="impulse-quickview-thumb' . $activeClass . '" data-index="' . $i . '"><img src="' . esc_url($img) . '" alt=""></div>';
            }
            $html .= '</div>';
        }
        $html .= '</div>'; // gallery

        // Details
        $html .= '<div class="impulse-quickview-details">';
        $html .= '<h2 class="impulse-quickview-title">' . esc_html($product->name) . '</h2>';

        // Description
        if ($showDesc && $displayDesc) {
            $html .= '<div class="impulse-quickview-desc">' . esc_html($displayDesc) . '</div>';
        }

        // SKU
        if (false) { // SKU moved to card back face
            $html .= '<div class="impulse-quickview-sku">SKU: ' . esc_html($product->sku) . '</div>';
        }

        // Add-ons from block settings
        $addonDefsDb = json_decode(sanitize_textarea_field(wp_unslash($_POST['addon_defs'] ?? '[]')), true) ?: [];
        $addonHtmlDb = $this->renderAddonsFromDefs($addonDefsDb, intval($product->id));
        if ($addonHtmlDb) {
            $html .= '<div class="impulse-quickview-addons" style="margin:12px 0;padding:12px 0;border-top:1px solid #f0f0f0">';
            $html .= $addonHtmlDb;
            $html .= '</div>';
        }

        // Price + Qty row + ATC
        $html .= '<div class="impulse-quickview-actions">';
        $html .= '<div class="impulse-quickview-price-qty-row">';
        if ($showQty) {
            $html .= '<div class="impulse-quickview-qty-control">';
            $html .= '<button class="impulse-quickview-qty-btn" data-action="minus">−</button>';
            $html .= '<input type="number" class="impulse-quickview-qty-input" value="1" min="1" max="999">';
            $html .= '<button class="impulse-quickview-qty-btn" data-action="plus">+</button>';
            $html .= '</div>';
        }
        if ($showPrice) {
            $baseP = $hasSale ? $saleP : $effectivePrice;
            $html .= '<div class="impulse-quickview-price" data-base-price="' . number_format($baseP, 2) . '">';
            if ($hasSale) {
                $html .= '<span class="sale">$' . number_format($saleP, 2) . '</span>';
                $html .= '<del>$' . number_format($regPrice, 2) . '</del>';
            } else {
                $html .= '$' . number_format($effectivePrice, 2);
            }
            $html .= '</div>';
        }
        $html .= '</div>';
        $btnStyle = 'background:' . esc_attr($btnBg) . ';color:' . esc_attr($btnText) . ';border-radius:' . $btnRadius . 'px;';
        $html .= '<button class="impulse-quickview-add-btn" data-product-id="' . intval($product->id) . '" style="' . $btnStyle . '">Add to Cart</button>';
        $html .= '</div>';

        $html .= '</div>'; // details
        $html .= '</div>'; // content

        wp_send_json_success([
            'html'    => $html,
            'product' => [
                'id'     => $product->id,
                'name'   => $product->name,
                'price'  => $effectivePrice,
                'images' => $images,
            ],
        ]);
    }
    
    /**
     * AJAX: Add to Cart from Quick View
     */
    public function ajaxAddToCart(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        check_ajax_referer('instinctor_frontend', 'nonce');

        $productId     = intval($_POST['product_id'] ?? 0);
        $quantity      = max(1, intval($_POST['quantity'] ?? 1));
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- array of variation rows; each field is sanitized in the loop below.
        $rawVariations = (array) wp_unslash($_POST['variations'] ?? []);
        $addonDefs     = json_decode(sanitize_textarea_field(wp_unslash($_POST['addon_defs']   ?? '[]')), true) ?: [];
        $addonValues   = json_decode(sanitize_textarea_field(wp_unslash($_POST['addon_values'] ?? '{}')), true) ?: [];

        if (!$productId) {
            wp_send_json_error(['message' => 'Invalid product ID']);
        }

        // Sanitize variation keys and values
        $meta = [];
        foreach ($rawVariations as $attr => $val) {
            $meta[sanitize_key($attr)] = sanitize_text_field(wp_unslash((string) $val));
        }

        // Validate required addons and build named meta
        $namedAddons     = [];
        $addonPriceDelta = 0.0;
        $basePrice       = 0.0;

        if (!empty($addonDefs)) {
            // Look up base price for percent calculations
            $productsTable = DB::prefix() . 'instinctor_products';
            $prod = DB::get_row(DB::prepare("SELECT price, sale_price FROM {$productsTable} WHERE id = %d", $productId));
            if ($prod) {
                $regPrice  = floatval($prod->price ?? 0);
                $salePrice = floatval($prod->sale_price ?? 0);
                $hasSale   = $salePrice > 0 && $salePrice < $regPrice;
                $basePrice = \Instinctor\Services\CurrencyService::convertProductPrice($prod, $hasSale ? 'sale_price' : ($regPrice > 0 ? 'price' : 'sale_price'));
            }

            foreach ($addonDefs as $idx => $def) {
                // Skip addons scoped to a different product
                if (!empty($def['product_id']) && intval($def['product_id']) !== $productId) continue;

                $required = !empty($def['required']);
                $val      = $addonValues[$idx] ?? $addonValues[(string)$idx] ?? '';

                if ($required && ($val === '' || $val === null)) {
                    wp_send_json_error(['message' => sprintf('"%s" is required.', sanitize_text_field($def['name'] ?? 'Field'))]);
                }

                if ($val === '' || $val === null) continue;

                $name = sanitize_text_field($def['name'] ?? '');
                if (!$name) continue;
                $namedAddons[$name] = sanitize_text_field((string)$val);

                $price = floatval($def['price'] ?? 0);
                if ($price > 0) {
                    $addonPriceDelta += ($def['price_type'] ?? 'flat') === 'percent'
                        ? $basePrice * ($price / 100)
                        : $price;
                }
            }
        }

        if (!empty($namedAddons)) {
            $meta['addons']      = $namedAddons;           // {name: value} for display
            $meta['addon_price'] = round($addonPriceDelta, 2); // pre-calculated delta
        }

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

        if ($result['success']) {
            wp_send_json_success([
                'message'    => 'Added to cart',
                'cart_count' => $cartService->getItemCount(),
            ]);
        } else {
            wp_send_json_error(['message' => $result['error'] ?? 'Failed to add to cart']);
        }
    }
    
    /**
     * AJAX: Track Order (public)
     */
    public function ajaxTrackOrder(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'] ?? '')), 'instinctor_frontend')) {
            wp_send_json_error(['message' => __('Security check failed. Please refresh and try again.', 'instinctor')]);
        }
        
        $orderNumber = sanitize_text_field(wp_unslash($_POST['order_number'] ?? ''));
        $email = sanitize_email(wp_unslash($_POST['email'] ?? ''));
        
        if (empty($orderNumber) || empty($email)) {
            wp_send_json_error(['message' => __('Please enter both order number and email address.', 'instinctor')]);
        }
        
        $result = \Instinctor\Services\OrderTrackingService::lookupOrder($orderNumber, $email);
        
        if (!$result) {
            wp_send_json_error(['message' => __('Order not found. Please check your order number and email address.', 'instinctor')]);
        }
        
        $order = $result['order'];
        $tracking = $result['tracking'];
        $items = $result['items'];
        
        // Format response
        $response = [
            'order_number' => $order->order_number,
            'status' => $order->status,
            'status_label' => ucfirst(str_replace('_', ' ', $order->status)),
            // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
            'date' => $order->created_at ? date('M j, Y', strtotime($order->created_at)) : '',
            // phpcs:enable WordPress.DateTime.RestrictedFunctions
            'total' => number_format((float)$order->total, 2),
            'currency' => $order->currency ?? 'USD',
            'currency_symbol' => \Instinctor\Services\CurrencyService::getCurrentCurrency()->symbol ?? '$',
            'items' => [],
            'tracking' => [],
        ];
        
        // Format items
        foreach ($items as $item) {
            $response['items'][] = [
                'name' => $item->name ?? 'Product',
                'quantity' => (int)$item->quantity,
                'price' => number_format((float)$item->price, 2),
                'currency_symbol' => $response['currency_symbol'],
            ];
        }
        
        // Format tracking
        foreach ($tracking as $t) {
            $response['tracking'][] = [
                'carrier' => $t->carrier_name,
                'tracking_number' => $t->tracking_number,
                'tracking_url' => $t->tracking_url,
                'status' => $t->status,
                'status_label' => $t->status_label,
                // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
                'estimated_delivery' => $t->estimated_delivery ? date('M j, Y', strtotime($t->estimated_delivery)) : null,
                // phpcs:enable WordPress.DateTime.RestrictedFunctions
                // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
                'shipped_at' => $t->shipped_at ? date('M j, Y', strtotime($t->shipped_at)) : null,
                // phpcs:enable WordPress.DateTime.RestrictedFunctions
            ];
        }
        
        wp_send_json_success($response);
    }
    
    /**
     * AJAX: Login
     */
    public function ajaxLogin(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'] ?? '')), 'instinctor_frontend')) {
            wp_send_json_error(['message' => __('Security check failed. Please refresh and try again.', 'instinctor')]);
        }

        // Honeypot check
        if (!Security::validateHoneypot()) {
            wp_send_json_error(['message' => __('Security check failed.', 'instinctor')]);
        }

        $ip       = Security::clientIp();
        $username = sanitize_text_field(wp_unslash($_POST['username'] ?? ''));
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- password must not be altered by sanitization; it is hashed/verified.
        $password = wp_unslash($_POST['password'] ?? '');
        $remember = !empty($_POST['remember']);

        // Rate limit: 5 attempts per minute per IP
        if (!Security::rateLimit('login', $ip)) {
            wp_send_json_error(['message' => __('Too many login attempts. Please wait a moment.', 'instinctor')]);
        }

        // Brute-force lockout
        if (Security::isLockedOut($username, $ip)) {
            wp_send_json_error(['message' => __('Account temporarily locked. Please try again in 15 minutes.', 'instinctor')]);
        }

        if (empty($username) || empty($password)) {
            wp_send_json_error(['message' => __('Please enter your username and password.', 'instinctor')]);
        }

        $result = \Instinctor\Core\UserService::loginCustomer($username, $password, $remember);

        if (!$result['ok']) {
            Security::recordLogin($username, $ip, false);
            wp_send_json_error(['message' => $result['error']]);
        }

        Security::recordLogin($username, $ip, true);
        Security::clearRateLimit('login:' . $ip);

        wp_send_json_success([
            'message'  => __('Login successful!', 'instinctor'),
            'redirect' => sanitize_text_field(wp_unslash($_POST['redirect'] ?? '')),
        ]);
    }
    
    /**
     * AJAX: Register storefront customer.
     * Routes through UserService::registerCustomer() — works on both WP and standalone.
     */
    public function ajaxRegister(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'] ?? '')), 'instinctor_frontend')) {
            wp_send_json_error(['message' => __('Security check failed. Please refresh and try again.', 'instinctor')]);
        }

        $ip = Security::clientIp();

        // Rate limit: 5 registrations per minute per IP
        if (!Security::rateLimit('register', $ip)) {
            wp_send_json_error(['message' => __('Too many requests. Please wait a moment.', 'instinctor')]);
        }

        // Honeypot check
        if (!Security::validateHoneypot()) {
            wp_send_json_error(['message' => __('Security check failed.', 'instinctor')]);
        }

        $email     = sanitize_email(wp_unslash($_POST['email'] ?? ''));
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- password must not be altered by sanitization; it is hashed/verified.
        $password  = wp_unslash($_POST['password'] ?? '');
        $firstName = sanitize_text_field(wp_unslash($_POST['first_name'] ?? ''));
        $lastName  = sanitize_text_field(wp_unslash($_POST['last_name'] ?? ''));

        $result = \Instinctor\Core\UserService::registerCustomer($email, $password, $firstName, $lastName);

        if (!$result['ok']) {
            wp_send_json_error(['message' => $result['error']]);
        }

        wp_send_json_success([
            'message'  => __('Account created successfully!', 'instinctor'),
            'redirect' => sanitize_text_field(wp_unslash($_POST['redirect'] ?? '')),
        ]);
    }
    /**
     * Public booking submission from Impulse booking block
     */
    

    public function ajaxPublicBook(): void
    {
        check_ajax_referer( 'instinctor_frontend', 'nonce' ); // enforced WP nonce check; frontend sends instinctorFrontend.nonce = Session::token('instinctor_frontend')
        $p  = DB::prefix();
        $ip = Security::clientIp();

        // Rate limit: 10 bookings per minute per IP
        if (!Security::rateLimit('checkout', $ip)) {
            wp_send_json_error(['message' => __('Too many requests. Please wait a moment.', 'instinctor')]);
        }

        if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'] ?? '')), 'instinctor_frontend')) {
            wp_send_json_error(['message' => __('Security check failed', 'instinctor')]);
        }


        $serviceId = intval($_POST['service_id'] ?? 0);
        $date      = sanitize_text_field(wp_unslash($_POST['booking_date'] ?? ''));
        $time      = sanitize_text_field(wp_unslash($_POST['booking_time'] ?? ''));
        $name      = sanitize_text_field(wp_unslash($_POST['customer_name'] ?? ''));
        $email     = sanitize_email(wp_unslash($_POST['customer_email'] ?? ''));
        $phone     = sanitize_text_field(wp_unslash($_POST['customer_phone'] ?? ''));
        $note      = sanitize_textarea_field(wp_unslash($_POST['note'] ?? ''));
        $guests    = max(1, intval($_POST['guest_count'] ?? 1));
        $staffId   = intval($_POST['staff_id'] ?? 0);

        // PHONE, not email (Sergei 2026-07-26): a salon client books by phone and gets
        // a text back. The form no longer collects an address at all, so requiring one
        // here would reject every real booking.
        if (!$serviceId || !$date || !$time || !$name
            || strlen(preg_replace('/\D/', '', $phone)) < 10) {
            wp_send_json_error(['message' => __('Please fill in your name and phone number', 'instinctor')]);
        }

        // Validate service exists and is active
        $service = DB::get_row(DB::prepare(
            "SELECT id, name, duration, buffer_before, buffer_after, capacity, deposit, owner_id FROM {$p}instinctor_services WHERE id = %d AND status = 'active'",
            $serviceId
        ));
        if (!$service) {
            wp_send_json_error(['message' => __('Service not found', 'instinctor')]);
        }
        // The tenant this booking belongs to (from the service). Without this the
        // booking + customer land with owner_id NULL and never show in the owner's
        // /dashboard/bookings, so the whole booking reads as a dead contact form.
        $bkOwnerId = ($service->owner_id === null || $service->owner_id === '') ? null : (int) $service->owner_id;
        // Act as the service's owner for the rest of this request.
        //
        // An AJAX POST renders no page, so nothing declared a context here: every config
        // read below resolved to the VENDOR's globals, which meant a tenant's booking
        // texted the vendor's number rather than the owner's. And now that the SMS gate
        // fails shut on the managed app without a context, a PRO tenant's booking
        // confirmation would have stopped sending. One enter() fixes both.
        //
        // No matching leave(): every exit from here is a wp_send_json_* that ends the
        // request. Static context dies with it.
        \Instinctor\Core\Tenant::enter($bkOwnerId);

        // Online booking is a Pro feature. On a free-publish tenant we do NOT reject:
        // the submission becomes an appointment REQUEST (a captured lead) so the owner
        // never loses a customer. Nothing auto-confirms, no SMS goes out (SMS is Pro);
        // the owner is emailed and confirms by hand. Standalone / vendor (no tenant
        // owner) book normally.
        $bkIsRequest = ($bkOwnerId !== null
            && class_exists('\\Instinctor\\Core\\TenantPlan')
            && !\Instinctor\Core\TenantPlan::bookingAllowed($bkOwnerId));

        // Build start/end times
        $startTime = $date . ' ' . $time . ':00';
        $endDt = new \DateTime($startTime);
        $endDt->modify('+' . intval($service->duration) . ' minutes');
        $endTime = $endDt->format('Y-m-d H:i:s');

        // Availability re-check on WRITE (the capacity check below is not enough): the
        // display endpoint validates business hours / closed days / min-advance, but the
        // submit path did not — so past, out-of-hours, and closed-day bookings slipped
        // through. Re-run the same slot generator and require the requested time to be a
        // real open slot for that weekday, and reject anything in the past.
        $bkSvc     = \Instinctor\Services\BookingService::instance();
        $openSlots = $bkSvc->getSlotsForDate($serviceId, $date, $staffId);
        $timeValid = false;
        foreach ($openSlots as $sl) {
            if ((string) ($sl['time'] ?? '') === $time) { $timeValid = true; break; }
        }
        // A request just needs to be in the future; the owner confirms the exact time,
        // so don't hold it to a live open slot (free sites may show illustrative slots).
        // A CLOSED DAY IS REFUSED EVEN AS A REQUEST. Slot validation is deliberately
        // skipped for requests, because a free-tier enquiry is just "can you do roughly
        // then" and the owner decides. A blocked day is a different statement: the owner
        // has already said they are not working. Letting a request through would invite
        // a client to ask for a day that is certainly a no, and the only answer left is
        // a decline, which reads as being turned away (2026-07-27).
        $bkSettings = (array) $bkSvc->getSettings();
        if (in_array($date, (array) ($bkSettings['blocked_dates'] ?? []), true)) {
            wp_send_json_error(['message' => __('We are closed that day. Please choose another date.', 'instinctor')]);
        }
        // A CLOSED RANGE IS REFUSED FOR REQUESTS TOO, for the same reason a closed day
        // is: the owner has already said they are busy then, usually because they wrote
        // a regular into that slot by hand. The slot generator hides these, so reaching
        // here means a stale form or a direct post.
        if (\Instinctor\Services\BookingService::isTimeBlocked($date, $startTime, $endTime, $bkSettings)) {
            wp_send_json_error(['message' => __('That time is taken. Please choose another slot.', 'instinctor')]);
        }
        if (!$bkIsRequest && !$timeValid) {
            wp_send_json_error(['message' => __('That time is not available. Please choose an open slot.', 'instinctor')]);
        }
        if (strtotime($startTime) < time()) {
            wp_send_json_error(['message' => __('That time has already passed. Please choose a future slot.', 'instinctor')]);
        }

        // Capacity check: count confirmed/pending bookings for this slot
        $bufBefore   = intval($service->buffer_before) * 60;
        $bufAfter    = intval($service->buffer_after) * 60;
        // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
        $startBuffer = date('Y-m-d H:i:s', strtotime($startTime) - $bufBefore);
        // phpcs:enable WordPress.DateTime.RestrictedFunctions
        // phpcs:disable WordPress.DateTime.RestrictedFunctions -- date() used for local-timezone formatting, gmdate would force UTC
        $endBuffer   = date('Y-m-d H:i:s', strtotime($endTime) + $bufAfter);
        // phpcs:enable WordPress.DateTime.RestrictedFunctions

        $existingCovers = (int) DB::get_var(DB::prepare(
            "SELECT COALESCE(SUM(guest_count),0) FROM {$p}instinctor_bookings
             WHERE product_id = %d AND status NOT IN ('cancelled','request')
             AND ((start_time < %s AND end_time > %s) OR (start_time >= %s AND start_time < %s))",
            $serviceId, $endBuffer, $startBuffer, $startBuffer, $endBuffer
        ));

        if (($existingCovers + $guests) > intval($service->capacity)) {
            wp_send_json_error(['message' => __('This time slot is fully booked. Please choose another.', 'instinctor')]);
        }

        // Get or create the customer, IDENTIFIED BY WHATEVER THEY ACTUALLY GAVE US.
        //
        // The free booking form asks for a name and a phone and nothing else, so keying
        // this on email meant every booking looked up email = '' and then tried to
        // INSERT email = ''. That column is globally unique: the first phone-only
        // booking on the whole install succeeded and every one after it failed with a
        // duplicate-key error, insert_id() returned 0, and the booking was saved with
        // customer_id = 0. The owner's dashboard showed "Customer #0" and the phone
        // number went down with the failed row (Sergei 2026-07-27).
        //
        // Phone is the identity for a phone-only client, digits-only so "(512) 555 0148"
        // and "512-555-0148" are the same person. Email still wins when there is one,
        // because that is the stronger key and keeps existing behaviour intact.
        $digits = preg_replace('/\D/', '', (string) $phone);
        if ($email !== '') {
            $customer = DB::get_row(DB::prepare(
                "SELECT id FROM {$p}instinctor_customers WHERE email = %s AND " . \Instinctor\Core\Tenant::scopeSqlFor($bkOwnerId, 'owner_id') . " LIMIT 1",
                $email
            ));
        } elseif ($digits !== '') {
            $customer = DB::get_row(DB::prepare(
                "SELECT id FROM {$p}instinctor_customers
                   WHERE REPLACE(REPLACE(REPLACE(REPLACE(phone,' ',''),'-',''),'(',''),')','') = %s
                     AND " . \Instinctor\Core\Tenant::scopeSqlFor($bkOwnerId, 'owner_id') . " LIMIT 1",
                $digits
            ));
        } else {
            $customer = null;
        }

        if ($customer) {
            $customerId = (int) $customer->id;
            // A returning client may have given a fuller name this time; never blank
            // over what is already there.
            $upd = [];
            if ($phone !== '') $upd['phone'] = $phone;
            if ($name !== '') {
                $np = explode(' ', $name, 2);
                $upd['first_name'] = $np[0] ?? '';
                $upd['last_name']  = $np[1] ?? '';
            }
            if ($upd) DB::update(DB::prefix() . 'instinctor_customers', $upd, ['id' => $customerId]);
        } else {
            $parts = explode(' ', $name, 2);
            DB::insert(DB::prefix() . 'instinctor_customers', [
                // NULL, not '': many NULLs coexist in a UNIQUE index, empty strings do
                // not. Migration 0055 makes the column nullable.
                'email'      => $email !== '' ? $email : null,
                'first_name' => $parts[0] ?? '',
                'last_name'  => $parts[1] ?? '',
                'phone'      => $phone,
                'owner_id'   => $bkOwnerId,
                'created_at' => current_time('mysql'),
            ]);
            $customerId = (int) DB::insert_id();
        }

        // Booking deposit (optional, upfront). Only engages when the frontend
        // supplies a Stripe PaymentIntent id AND the service carries a deposit
        // AND payment is required; otherwise the booking is a free request, as
        // before. A paid deposit is recorded as a small order linked via order_id
        // and confirms the appointment.
        $bookingService = \Instinctor\Services\BookingService::instance();
        $bkSettings     = $bookingService->getSettings();
        $depositAmount  = (float) ($service->deposit ?? 0);
        $piId           = sanitize_text_field(wp_unslash($_POST['payment_intent_id'] ?? ''));
        $depositOrderId = 0;
        if (!$bkIsRequest && !empty($bkSettings['require_payment']) && $depositAmount > 0 && $piId !== '') {
            if (!$bookingService->verifyDepositPayment($piId, $depositAmount)) {
                wp_send_json_error(['message' => __('We could not verify your deposit payment. Please try again.', 'instinctor')]);
            }
            $depositOrderId = $bookingService->createDepositOrder($customerId, $service, $depositAmount, $piId);
        }
        // Bookings stay pending for admin confirmation unless a deposit was paid.
        // Free-tier submissions are 'request' (a lead the owner confirms manually).
        $bookingStatus = $bkIsRequest ? 'request' : (($depositOrderId > 0) ? 'confirmed' : 'pending');
        // Restaurant reservations auto-confirm (capacity is already enforced, so
        // there's nothing to manually approve). Stripped from the free build.
        

        // Insert booking (deposit paid -> confirmed; otherwise pending for admin confirm)
        $insertData = [
            'product_id'    => $serviceId,
            'customer_id'   => $customerId,
            'owner_id'      => $bkOwnerId,
            'order_id'      => $depositOrderId,
            'status'        => $bookingStatus,
            'start_time'    => $startTime,
            'end_time'      => $endTime,
            'guest_count'   => $guests,
            'timezone'      => wp_timezone_string(),
            'customer_note' => $note,
            'meta'          => wp_json_encode(['duration' => intval($service->duration), 'created_via' => $bkIsRequest ? 'request' : 'booking', 'deposit_paid' => $depositOrderId > 0 ? $depositAmount : 0, 'guest_count' => $guests]),
            'created_at'    => current_time('mysql'),
        ];
        if ($staffId > 0) $insertData['staff_id'] = $staffId;
        \Instinctor\Services\AnalyticsService::ensureAttributionColumn(DB::prefix() . 'instinctor_bookings');
        $insertData['attribution'] = \Instinctor\Services\AnalyticsService::attributionFromRequest();
        $inserted = DB::insert(DB::prefix() . 'instinctor_bookings', $insertData);

        if (!$inserted) {
            wp_send_json_error(['message' => __('Could not save booking. Please try again.', 'instinctor')]);
        }

        $newBookingId = (int) DB::insert_id();

        // Repeat: the customer asked for a standing appointment, so the visit above
        // becomes occurrence #1 of a series and the rest are materialised from it.
        //
        // Capped at 12 from a public form. The generator otherwise runs to a 90-day
        // horizon, which on a weekly rule is thirteen of the owner's slots taken by
        // one anonymous POST. Twelve is a season of visits, enough to be a real
        // standing booking and small enough that a bad one is a nuisance rather than
        // a wiped calendar. The owner extends it from the dashboard.
        $repeatPeriod = sanitize_text_field(wp_unslash($_POST['repeat_period'] ?? ''));
        // PRO ONLY. A free tenant's block renders the request form, which has no
        // Repeat control, so this can only be reached by a crafted POST: a standing
        // appointment on an install where nothing auto-confirms would be a schedule
        // the owner never agreed to and cannot manage.
        if (in_array($repeatPeriod, ['week', 'month'], true) && $newBookingId > 0 && !$bkIsRequest) {
            try {
                \Instinctor\Services\BookingService::instance()->createSeries($bkOwnerId, [
                    'service_id'       => $serviceId,
                    'staff_id'         => $staffId,
                    'customer_id'      => $customerId,
                    'period'           => $repeatPeriod,
                    'interval_count'   => max(1, min(4, intval($_POST['repeat_interval'] ?? 1))),
                    'weekday'          => (int) gmdate('w', strtotime($date)),
                    'start_time_local' => substr($time, 0, 5),
                    'duration_minutes' => intval($service->duration),
                    'timezone'         => wp_timezone_string(),
                    'starts_on'        => $date,
                    'occurrences'      => 12,
                    'customer_note'    => $note,
                    'first_booking_id' => $newBookingId,
                ]);
            } catch (\Throwable $e) {
                // The single appointment is already saved and is what the customer
                // was promised on screen. A failed series must not turn that into an
                // error they have to redo.
            }
        }

        // Customer gets the same "request received, we'll confirm" email either way.
        try { \Instinctor\Services\BookingService::instance()->sendBookingConfirmation($newBookingId); } catch (\Throwable $e) {}
        // Owner: SMS on a real (Pro) booking; EMAIL on a free-tier request (SMS is Pro).
        if ($bkIsRequest) {
            try { \Instinctor\Services\BookingService::instance()->notifyBookingRequest($newBookingId); } catch (\Throwable $e) {}
        } else {
            try { \Instinctor\Services\BookingService::instance()->notifyNewBooking($newBookingId); } catch (\Throwable $e) {}
        }

        wp_send_json_success([
            'booking_id'   => $newBookingId,
            'deposit_paid' => $depositOrderId > 0,
            'request'      => $bkIsRequest,
            'message'      => $bkIsRequest ? __('Request sent. The salon will contact you to confirm your appointment.', 'instinctor') : null,
        ]);
    }

    /**
     * Create a Stripe PaymentIntent for a service's booking deposit. Public:
     * the booking widget calls this before submitting when a service may carry a
     * deposit. The amount is resolved server-side from the service row, never
     * trusted from the client. Returns {deposit:0} when no upfront payment
     * applies (no deposit, payment not required, or Stripe not connected) so the
     * widget skips the payment step and books as a free request.
     */
    public function ajaxBookingDepositIntent(): void
    {
        check_ajax_referer('instinctor_frontend', 'nonce');
        if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'] ?? '')), 'instinctor_frontend')) {
            wp_send_json_error(['message' => __('Security check failed', 'instinctor')]);
        }
        $p = DB::prefix();
        $serviceId = intval($_POST['service_id'] ?? 0);
        $service = DB::get_row(DB::prepare(
            "SELECT id, name, deposit FROM {$p}instinctor_services WHERE id = %d AND status = 'active'",
            $serviceId
        ));
        if (!$service) {
            wp_send_json_error(['message' => __('Service not found', 'instinctor')]);
        }

        $bookingService = \Instinctor\Services\BookingService::instance();
        $settings = $bookingService->getSettings();
        $deposit  = (float) ($service->deposit ?? 0);
        $pk       = (string) \Instinctor\Core\Config::get('instinctor_stripe_publishable_key', '');

        // No deposit, payment not required, or Stripe not connected -> free request.
        if (empty($settings['require_payment']) || $deposit <= 0 || $pk === '') {
            wp_send_json_success(['deposit' => 0]);
        }

        $intent = $bookingService->createDepositIntent($deposit);
        if (empty($intent['success'])) {
            wp_send_json_error(['message' => $intent['error'] ?? __('Could not start the deposit payment.', 'instinctor')]);
        }
        wp_send_json_success([
            'deposit'           => $deposit,
            'client_secret'     => $intent['client_secret'],
            'payment_intent_id' => $intent['payment_intent_id'],
            'pk'                => $pk,
        ]);
    }

    


    /**
     * Register an AJAX handler for both WordPress and standalone modes.
     * On WordPress: add_action('wp_ajax_' . $action)
     * On standalone: AjaxDispatcher::register($action)
     */
    private function registerAjax(string $action, callable $handler): void
    {
        if (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress') {
            add_action('wp_ajax_' . $action, $handler);
        } else {
            \Instinctor\Core\AjaxDispatcher::register($action, $handler);
        }
    }

}
