<?php
// phpcs:disable WordPress.DateTime.RestrictedFunctions.date_date,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash,WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- date() used for internal non-display formatting; timezone managed at application level; direct filesystem calls required for Instinctor standalone storage driver; input sanitization handled by Instinctor sanitization layer on save// phpcs:disable WordPress.Security.NonceVerification.Missing,WordPress.Security.NonceVerification.Recommended -- Nonce verification is handled by check_ajax_referer() which calls check_ajax_referer() in WP context and a HMAC token verifier in standalone context. Direct wp_verify_nonce() calls are absent by design.

declare(strict_types=1);

namespace Instinctor\Admin;

use Instinctor\Core\Config;
use Instinctor\Core\DB;
use Instinctor\Core\Storage;
use Instinctor\Core\Auth;
use Instinctor\Core\Session;
use Instinctor\Core\Mailer;
use Instinctor\Services\PageService;

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

/**
 * Admin Controller - orchestrates the WordPress admin interface
 */
class AdminController
{
    public function __construct()
    {
        add_action('admin_menu', [$this, 'registerMenus']);
        add_action('admin_enqueue_scripts', [$this, 'enqueueAssets']);
        add_action('admin_init', [$this, 'handleTemplateFormActions']);
        // Upgrade notice removed — all features unlocked
        
        // Auto-install roles/caps on upgrade (no reactivation needed)
        add_action('admin_init', [$this, 'maybeInstallRoles'], 1);
        
        // Impulse WordPress Pages integration
        add_action('admin_init', [$this, 'redirectImpulseEdit'], 1);
        add_filter('page_row_actions', [$this, 'addImpulseRowAction'], 10, 2);
        add_filter('get_edit_post_link', [$this, 'filterImpulseEditLink'], 10, 3);
        add_filter('use_block_editor_for_post', [$this, 'disableGutenbergForImpulse'], 10, 2);
        add_action('add_meta_boxes', [$this, 'addImpulseMetaBox']);
        
        // AJAX handlers for variations
        $this->registerAjax('instinctor_generate_variations', [$this, 'ajaxGenerateVariations']);
        $this->registerAjax('instinctor_save_variations', [$this, 'ajaxSaveVariations']);
        $this->registerAjax('instinctor_delete_variation', [$this, 'ajaxDeleteVariation']);
        
        // AJAX handlers for order management
        $this->registerAjax('instinctor_add_order_note', [$this, 'ajaxAddOrderNote']);
        $this->registerAjax('instinctor_delete_order_note', [$this, 'ajaxDeleteOrderNote']);
        $this->registerAjax('instinctor_delete_order', [$this, 'ajaxDeleteOrder']);
        $this->registerAjax('instinctor_delete_client', [$this, 'ajaxDeleteClient']);
        $this->registerAjax('instinctor_save_tracking', [$this, 'ajaxSaveTracking']);
        $this->registerAjax('instinctor_send_tracking_email', [$this, 'ajaxSendTrackingEmail']);
        $this->registerAjax('instinctor_process_refund', [$this, 'ajaxProcessRefund']);
        $this->registerAjax('instinctor_update_order_status', [$this, 'ajaxUpdateOrderStatus']);
        
        // Dashboard workspace AJAX
        $this->registerAjax('instinctor_get_order_detail',    [$this, 'ajaxGetOrderDetail']);
        $this->registerAjax('instinctor_get_customer_detail', [$this, 'ajaxGetCustomerDetail']);
        $this->registerAjax('instinctor_update_member',       [$this, 'ajaxUpdateMember']);
        $this->registerAjax('instinctor_suspend_member',      [$this, 'ajaxSuspendMember']);
        $this->registerAjax('instinctor_reactivate_member',   [$this, 'ajaxReactivateMember']);
        $this->registerAjax('instinctor_get_customers',       [$this, 'ajaxGetCustomers']);
        $this->registerAjax('instinctor_get_client_note',     [$this, 'ajaxGetClientNote']);
        $this->registerAjax('instinctor_save_client_note',    [$this, 'ajaxSaveClientNote']);

        // Salon CRM (client profile: before/after photos + color formulas + tags)
        $this->registerAjax('instinctor_client_photo_add',     [$this, 'ajaxClientPhotoAdd']);
        $this->registerAjax('instinctor_client_photo_delete',  [$this, 'ajaxClientPhotoDelete']);
        $this->registerAjax('instinctor_client_formula_save',  [$this, 'ajaxClientFormulaSave']);
        $this->registerAjax('instinctor_client_formula_delete',[$this, 'ajaxClientFormulaDelete']);
        $this->registerAjax('instinctor_client_meta_save',     [$this, 'ajaxClientMetaSave']);

        // Blog — simple post editor (title + cover + body → a flow-mode post)
        $this->registerAjax('instinctor_save_post',           [$this, 'ajaxSavePost']);
        $this->registerAjax('instinctor_get_post',            [$this, 'ajaxGetPost']);

        // Marketing workspace AJAX
        $this->registerAjax('instinctor_get_coupon_detail', [$this, 'ajaxGetCouponDetail']);
        $this->registerAjax('instinctor_save_coupon', [$this, 'ajaxSaveCoupon']);
        $this->registerAjax('instinctor_delete_coupon', [$this, 'ajaxDeleteCoupon']);
        $this->registerAjax('instinctor_get_all_coupons', [$this, 'ajaxGetAllCoupons']);
        $this->registerAjax('instinctor_get_gift_cards', [$this, 'ajaxGetGiftCards']);
        $this->registerAjax('instinctor_get_gift_card_detail', [$this, 'ajaxGetGiftCardDetail']);
        $this->registerAjax('instinctor_create_gift_card', [$this, 'ajaxCreateGiftCard']);
        $this->registerAjax('instinctor_send_gift_card', [$this, 'ajaxSendGiftCard']);
        $this->registerAjax('instinctor_deactivate_gift_card', [$this, 'ajaxDeactivateGiftCard']);
        $this->registerAjax('instinctor_get_loyalty_settings', [$this, 'ajaxGetLoyaltySettings']);
        $this->registerAjax('instinctor_save_loyalty_settings', [$this, 'ajaxSaveLoyaltySettings']);
        $this->registerAjax('instinctor_get_loyalty_stats', [$this, 'ajaxGetLoyaltyStats']);
        $this->registerAjax('instinctor_adjust_loyalty_points', [$this, 'ajaxAdjustLoyaltyPoints']);
        
        // AJAX handlers for currency management
        $this->registerAjax('instinctor_save_currency', [$this, 'ajaxSaveCurrency']);
        $this->registerAjax('instinctor_delete_currency', [$this, 'ajaxDeleteCurrency']);
        $this->registerAjax('instinctor_update_exchange_rates', [$this, 'ajaxUpdateExchangeRates']);
        $this->registerAjax('instinctor_save_option', [$this, 'ajaxSaveOption']);
        
        // Email template toggle
        $this->registerAjax('instinctor_toggle_email_template', [$this, 'ajaxToggleEmailTemplate']);
        $this->registerAjax('instinctor_save_footer_settings', [$this, 'ajaxSaveFooterSettings']);
        
        // Intercept WordPress password reset email when our template is enabled
        add_filter('retrieve_password_message', [$this, 'handlePasswordResetEmail'], 10, 4);
        
        // Add-ons toggle
        
        // AfterShip settings
        $this->registerAjax('instinctor_save_aftership', [$this, 'ajaxSaveAfterShip']);
        $this->registerAjax('instinctor_save_aftership_settings', [$this, 'ajaxSaveAfterShipSettings']);
        $this->registerAjax('instinctor_get_tracking_settings', [$this, 'ajaxGetTrackingSettings']);
        $this->registerAjax('instinctor_test_aftership', [$this, 'ajaxTestAfterShip']);
        
        // Newsletter subscription (public)
        $this->registerAjax('instinctor_newsletter_subscribe', [$this, 'ajaxNewsletterSubscribe']);
        $this->registerAjax('nopriv_instinctor_newsletter_subscribe', [$this, 'ajaxNewsletterSubscribe']);
        
        // v1.3.0 AJAX handlers
        $this->registerAjax('instinctor_add_tracking', [$this, 'ajaxAddTracking']);
        $this->registerAjax('instinctor_update_tracking_status', [$this, 'ajaxUpdateTrackingStatus']);
        $this->registerAjax('instinctor_delete_tracking', [$this, 'ajaxDeleteTracking']);
        $this->registerAjax('instinctor_update_stock', [$this, 'ajaxUpdateStock']);
        $this->registerAjax('instinctor_export_inventory', [$this, 'ajaxExportInventory']);
        $this->registerAjax('instinctor_update_rma_status', [$this, 'ajaxUpdateRMAStatus']);
        $this->registerAjax('instinctor_get_rmas', [$this, 'ajaxGetRMAs']);
        $this->registerAjax('instinctor_get_rma', [$this, 'ajaxGetRMA']);
        $this->registerAjax('instinctor_approve_rma', [$this, 'ajaxApproveRMA']);
        $this->registerAjax('instinctor_reject_rma', [$this, 'ajaxRejectRMA']);
        $this->registerAjax('instinctor_rma_refund_now', [$this, 'ajaxRMARefundNow']);
        $this->registerAjax('instinctor_get_rma_settings', [$this, 'ajaxGetRMASettings']);
        $this->registerAjax('instinctor_save_rma_settings', [$this, 'ajaxSaveRMASettings']);

        $this->registerAjax('instinctor_print_invoice', [$this, 'ajaxPrintInvoice']);
        $this->registerAjax('instinctor_print_packing_slip', [$this, 'ajaxPrintPackingSlip']);
        
        // Pricing Table - Products and Subscriptions
        $this->registerAjax('instinctor_get_products', [$this, 'ajaxGetProducts']);
        $this->registerAjax('instinctor_get_subscription_plans', [$this, 'ajaxGetSubscriptionPlans']);


        // AI Recommendations AJAX        
        // v1.7.0 - UX improvements
        $this->registerAjax('instinctor_bulk_products', [$this, 'ajaxBulkProducts']);
        $this->registerAjax('instinctor_inline_edit_product', [$this, 'ajaxInlineEditProduct']);
        $this->registerAjax('instinctor_update_product_status', [$this, 'ajaxUpdateProductStatus']);
        $this->registerAjax('instinctor_global_search', [$this, 'ajaxGlobalSearch']);
        $this->registerAjax('instinctor_bulk_orders', [$this, 'ajaxBulkOrders']);
        $this->registerAjax('instinctor_get_orders_list', [$this, 'ajaxGetOrdersList']);
        $this->registerAjax('instinctor_reorder_categories', [$this, 'ajaxReorderCategories']);
        $this->registerAjax('instinctor_inline_update', [$this, 'ajaxInlineUpdate']);
        $this->registerAjax('instinctor_empty_trash', [$this, 'ajaxEmptyTrash']);
        
        // Impulse Products Workspace
        $this->registerAjax('instinctor_get_products', [$this, 'ajaxImpulseGetProducts']);
        $this->registerAjax('instinctor_get_categories', [$this, 'ajaxImpulseGetCategories']);
        $this->registerAjax('instinctor_create_category', [$this, 'ajaxImpulseCreateCategory']);
        $this->registerAjax('instinctor_update_category', [$this, 'ajaxImpulseUpdateCategory']);
        $this->registerAjax('instinctor_delete_category', [$this, 'ajaxImpulseDeleteCategory']);
        $this->registerAjax('instinctor_reorder_categories', [$this, 'ajaxImpulseReorderCategories']);
        $this->registerAjax('instinctor_move_category', [$this, 'ajaxImpulseMoveCategory']);
        $this->registerAjax('instinctor_get_inventory_stats', [$this, 'ajaxImpulseGetInventoryStats']);
        $this->registerAjax('instinctor_save_product', [$this, 'ajaxImpulseSaveProduct']);
        $this->registerAjax('instinctor_delete_product', [$this, 'ajaxImpulseDeleteProduct']);
        $this->registerAjax('instinctor_bulk_products', [$this, 'ajaxImpulseBulkProducts']);
        $this->registerAjax('instinctor_reorder_products', [$this, 'ajaxImpulseReorderProducts']);
        $this->registerAjax('instinctor_update_product_status', [$this, 'ajaxImpulseUpdateProductStatus']);
        $this->registerAjax('instinctor_update_product_featured', [$this, 'ajaxImpulseUpdateProductFeatured']);
        $this->registerAjax('instinctor_trash_product', [$this, 'ajaxImpulseTrashProduct']);
        $this->registerAjax('instinctor_restore_product', [$this, 'ajaxImpulseRestoreProduct']);
        $this->registerAjax('instinctor_duplicate_product', [$this, 'ajaxImpulseDuplicateProduct']);
        $this->registerAjax('instinctor_save_settings', [$this, 'ajaxImpulseSaveSettings']);
        $this->registerAjax('instinctor_save_store_options', [$this, 'ajaxImpulseSaveStoreOptions']);
        
        // `instinctor_apply_update` was retired in v2.1.1354. The standalone
        // self-update path lives at /admin/updates (Services\AutoUpdater);
        // WP Pro updates go through WP's native transient + Plugin_Upgrader.

        $this->registerAjax('instinctor_set_frontpage', [$this, 'ajaxImpulseSetFrontpage']);
        
        // Email template handlers
        $this->registerAjax('instinctor_toggle_email_template', [$this, 'ajaxImpulseToggleEmailTemplate']);
        $this->registerAjax('instinctor_get_email_template', [$this, 'ajaxImpulseGetEmailTemplate']);
        $this->registerAjax('instinctor_save_email_template', [$this, 'ajaxImpulseSaveEmailTemplate']);
        $this->registerAjax('instinctor_send_test_email', [$this, 'ajaxImpulseSendTestEmail']);
        $this->registerAjax('instinctor_get_email_footer_settings', [$this, 'ajaxImpulseGetEmailFooterSettings']);
        $this->registerAjax('instinctor_save_email_footer_settings', [$this, 'ajaxImpulseSaveEmailFooterSettings']);
        $this->registerAjax('instinctor_activate_license', [$this, 'ajaxImpulseActivateLicense']);
        $this->registerAjax('instinctor_proxy_checkout', [$this, 'ajaxImpulseProxyCheckout']);
        $this->registerAjax('instinctor_bulk_categories', [$this, 'ajaxBulkCategories']);
        
        // Card Designer AJAX
        $this->registerAjax('instinctor_save_card_design', [$this, 'ajaxSaveCardDesign']);
        $this->registerAjax('instinctor_save_card_template', [$this, 'ajaxSaveCardTemplate']);
        $this->registerAjax('instinctor_delete_card_template', [$this, 'ajaxDeleteCardTemplate']);
        $this->registerAjax('instinctor_load_card_template', [$this, 'ajaxLoadCardTemplate']);
        $this->registerAjax('instinctor_get_card_templates', [$this, 'ajaxGetCardTemplates']);
        
        // Header/Footer Global AJAX
        $this->registerAjax('instinctor_save_global_header', [$this, 'ajaxSaveGlobalHeader']);
        $this->registerAjax('instinctor_get_global_header', [$this, 'ajaxGetGlobalHeader']);
        $this->registerAjax('instinctor_save_global_footer', [$this, 'ajaxSaveGlobalFooter']);
        $this->registerAjax('instinctor_get_global_footer', [$this, 'ajaxGetGlobalFooter']);
        
        // Multi-Language AJAX
        
        
        // Subscription AJAX
        $this->registerAjax('instinctor_create_subscription', [$this, 'ajaxCreateSubscription']);
        $this->registerAjax('instinctor_update_subscription_status', [$this, 'ajaxUpdateSubscriptionStatus']);
        $this->registerAjax('instinctor_save_subscription_settings', [$this, 'ajaxSaveSubscriptionSettings']);
        
        // Booking AJAX
        $this->registerAjax('instinctor_get_bookings_calendar', [$this, 'ajaxGetBookingsCalendar']);
        $this->registerAjax('instinctor_get_bookings_list', [$this, 'ajaxGetBookingsList']);
        $this->registerAjax('instinctor_get_form_submissions', [$this, 'ajaxGetFormSubmissions']);
        $this->registerAjax('instinctor_get_bookings_stats', [$this, 'ajaxGetBookingsStats']);
        $this->registerAjax('instinctor_get_services', [$this, 'ajaxGetServices']);
        $this->registerAjax('instinctor_save_service', [$this, 'ajaxSaveService']);
        $this->registerAjax('instinctor_delete_service', [$this, 'ajaxDeleteService']);
        // Staff / Team management (Phase 1)
        $this->registerAjax('instinctor_get_staff', [$this, 'ajaxGetStaff']);
        $this->registerAjax('instinctor_save_staff', [$this, 'ajaxSaveStaff']);
        $this->registerAjax('instinctor_delete_staff', [$this, 'ajaxDeleteStaff']);
        $this->registerAjax('instinctor_get_booking', [$this, 'ajaxGetBooking']);
        $this->registerAjax('instinctor_stop_booking_series', [$this, 'ajaxStopBookingSeries']);
        $this->registerAjax('instinctor_create_booking', [$this, 'ajaxCreateBooking']);
        $this->registerAjax('instinctor_update_booking_status', [$this, 'ajaxUpdateBookingStatus']);
        $this->registerAjax('instinctor_update_booking_staff', [$this, 'ajaxUpdateBookingStaff']);
        $this->registerAjax('instinctor_save_booking_settings', [$this, 'ajaxSaveBookingSettings']);
        $this->registerAjax('instinctor_complete_onboarding', [$this, 'ajaxCompleteOnboarding']);
        // Instructor vertical — class schedule CRUD
        

        // Report Problem AJAX
        
        // Workspace preferences (per-user)
        $this->registerAjax('instinctor_save_workspace_prefs', [$this, 'ajaxSaveWorkspacePrefs']);
        $this->registerAjax('instinctor_dismiss_upsell', [$this, 'ajaxDismissUpsell']);
        
        // Team/Role management
        $this->registerAjax('instinctor_get_team', [$this, 'ajaxGetTeam']);
        $this->registerAjax('instinctor_set_user_role', [$this, 'ajaxSetUserRole']);

        // Digital Downloads
        $this->registerAjax('instinctor_get_product_downloads', [$this, 'ajaxGetProductDownloads']);
        $this->registerAjax('instinctor_save_product_download', [$this, 'ajaxSaveProductDownload']);
        $this->registerAjax('instinctor_delete_product_download', [$this, 'ajaxDeleteProductDownload']);
        $this->registerAjax('instinctor_get_customer_downloads', [$this, 'ajaxGetCustomerDownloads']);
        $this->registerAjax('instinctor_get_download_stats', [$this, 'ajaxGetDownloadStats']);
        $this->registerAjax('instinctor_toggle_product_downloadable', [$this, 'ajaxToggleProductDownloadable']);
        $this->registerAjax('instinctor_save_dd_settings', [$this, 'ajaxSaveDDSettings']);
        $this->registerAjax('instinctor_get_billing_data', [$this, 'ajaxGetBillingData']);
        $this->registerAjax('instinctor_save_stripe_plans', [$this, 'ajaxSaveStripePlans']);
        $this->registerAjax('instinctor_save_billing_urls', [$this, 'ajaxSaveBillingUrls']);

        // Product Bundles
        $this->registerAjax('instinctor_get_bundles', [$this, 'ajaxGetBundles']);
        $this->registerAjax('instinctor_save_bundle', [$this, 'ajaxSaveBundle']);
        $this->registerAjax('instinctor_delete_bundle', [$this, 'ajaxDeleteBundle']);

        // Product Add-Ons
        $this->registerAjax('instinctor_get_addons', [$this, 'ajaxGetProductAddons']);
        $this->registerAjax('instinctor_save_addon', [$this, 'ajaxSaveProductAddon']);
        $this->registerAjax('instinctor_delete_addon', [$this, 'ajaxDeleteProductAddon']);

        // Volume Discounts
        $this->registerAjax('instinctor_get_volume_tiers', [$this, 'ajaxGetVolumeTiers']);
        $this->registerAjax('instinctor_save_volume_tier', [$this, 'ajaxSaveVolumeTier']);
        $this->registerAjax('instinctor_delete_volume_tier', [$this, 'ajaxDeleteVolumeTier']);

        // Localization: language management

        // Localization: currency management (handlers already existed, just registering get_currencies)
        $this->registerAjax('instinctor_get_currencies', [$this, 'ajaxGetCurrencies']);

        // T10 — API credentials panel (ILM absorption: SendGrid, Twilio, OpenAI, Stripe)
        $this->registerAjax('instinctor_get_credentials', [$this, 'ajaxGetCredentials']);
        $this->registerAjax('instinctor_save_credentials', [$this, 'ajaxSaveCredentials']);
        $this->registerAjax('instinctor_panel_access', [$this, 'ajaxPanelAccess']);
    }

    public function maybeInstallRoles(): void
    {
        $installed_version = Config::get('instinctor_roles_version', '');
        if ($installed_version !== INSTINCTOR_VERSION) {
            \Instinctor\Core\RoleManager::install();
            Config::set('instinctor_roles_version', INSTINCTOR_VERSION);
        }
    }

    public function registerMenus(): void
    {
        // Ensure administrator always has required caps (not conditional on version)
        $admin = get_role('administrator');
        if ($admin) {
            foreach (['instinctor_access', 'instinctor_impulse', 'instinctor_chat', 'instinctor_store', 'instinctor_settings'] as $cap) {
                if (!$admin->has_cap($cap)) {
                    $admin->add_cap($cap, true);
                }
            }
        }
        
        // Single menu item - Instinctor workspace system
        // Icon is handled via CSS mask in instinctor-admin-menu-icon.css for proper hover/active states
        add_menu_page(
            __('Instinctor', 'instinctor'),
            __('Instinctor', 'instinctor'),
            'instinctor_access',
            'instinctor',
            [$this, 'renderImpulse'],
            'none',
            55
        );

        // Remove the duplicate submenu item that WordPress creates
        add_submenu_page(
            'instinctor',
            __('Instinctor', 'instinctor'),
            __('Instinctor', 'instinctor'),
            'instinctor_access',
            'instinctor',
            [$this, 'renderImpulse']
        );

        // Hidden: Impulse Preview (for live preview iframe)
        add_submenu_page(
            null,
            __('Preview', 'instinctor'),
            __('Preview', 'instinctor'),
            'instinctor_impulse',
            'instinctor-impulse-preview',
            [$this, 'renderImpulsePreview']
        );

        // Hidden: the simple blog-post editor. On standalone this lives at
        // /admin/post/edit/:id via AdminRouter, but AdminRouter::init() returns
        // immediately on WordPress, so posts had NO editor here at all: the page
        // grid sent /admin/post/edit/<id>, WP had no such route, and the visitor
        // landed on the public site instead (reported 2026-08-13). Same view,
        // reached the WordPress way.
        add_submenu_page(
            null,
            __('Edit Post', 'instinctor'),
            __('Edit Post', 'instinctor'),
            'instinctor_access',
            'instinctor-post',
            [$this, 'renderPostEditor']
        );
    }

    /** Simple blog-post editor (WordPress). Mirrors AdminRouter::renderPostEditor(). */
    public function renderPostEditor(): void
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only id used to load the editor
        $postId = isset($_GET['post_id']) ? absint(wp_unslash($_GET['post_id'])) : 0;
        $_GET['post_id'] = $postId;                       // the view reads it from $_GET
        $view = INSTINCTOR_PLUGIN_DIR . 'admin/views/post-editor.php';
        if (!file_exists($view)) {
            echo '<div class="wrap"><p>' . esc_html__('Post editor view not found.', 'instinctor') . '</p></div>';
            return;
        }
        include $view;
    }

    public function enqueueAssets(string $hook): void
    {
        // Load menu CSS on ALL admin pages for proper menu highlighting
        wp_enqueue_style(
            'instinctor-admin-menu',
            INSTINCTOR_PLUGIN_URL . 'assets/css/admin-menu.css',
            [],
            INSTINCTOR_VERSION
        );
        
        // Only load full assets on our pages
        if (strpos($hook, 'instinctor') === false) {
            return;
        }

        // Enqueue WordPress media uploader
        wp_enqueue_media();
        
        // Force jQuery to load in header — our view outputs inline <script> tags
        // that need jQuery before wp_footer() runs (WP 6.7+ footer-loads jQuery by default)
        wp_enqueue_script('jquery');
        $wp_scripts = wp_scripts();
        // set_group(handle, recursion, group) — group 0 = header, 1 = footer
        // This is the correct WP 6.7+ API; ->args=null no longer works
        foreach (['jquery', 'jquery-core', 'jquery-migrate'] as $handle) {
            if (isset($wp_scripts->registered[$handle])) {
                $wp_scripts->set_group($handle, false, 0);
            }
        }
        
        // Load Sortable.js for drag and drop
        wp_enqueue_script(
            'sortablejs',
            INSTINCTOR_PLUGIN_URL . 'assets/js/vendor/sortable.min.js',
            [],
            '1.15.0',
            true
        );

        // Load workspace CSS
        wp_enqueue_style(
            'instinctor-workspaces',
            INSTINCTOR_PLUGIN_URL . 'assets/css/instinctor.css',
            [],
            INSTINCTOR_VERSION
        );

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

        // Self-contained QR encoder — dependency of admin.js (editor "open on
        // phone" popover + Settings review-tool preview render client-side).
        wp_enqueue_script(
            'instinctor-qr',
            INSTINCTOR_PLUGIN_URL . 'assets/js/instinctor-qr.js',
            [],
            INSTINCTOR_VERSION,
            true
        );

        wp_enqueue_script(
            'instinctor-admin',
            INSTINCTOR_PLUGIN_URL . 'assets/js/admin.js',
            ['jquery', 'wp-api-fetch', 'jquery-ui-sortable', 'instinctor-qr'],
            INSTINCTOR_VERSION,
            true
        );

        wp_localize_script('instinctor-admin', 'instinctorAdmin', [
            'apiUrl'      => \Instinctor\Core\Config::restUrl('instinctor/v1/'),
            'adminUrl'    => admin_url('admin.php'),
            'nonce'       => Session::token('wp_rest'),
            'adminNonce'  => Session::token('instinctor_admin'),
            'currency'    => Config::get('instinctor_currency', 'USD'),
            'storeType'   => Config::get('instinctor_store_type', 'mixed'),
            'env'         => 'wordpress',
            'currentUser' => function_exists('wp_get_current_user') ? (function() {
                $u = wp_get_current_user();
                return $u && $u->ID ? [
                    'id'    => $u->ID,
                    'email' => $u->user_email,
                    'name'  => $u->display_name,
                    'role'  => in_array('administrator', (array) $u->roles) ? 'admin' : ($u->roles[0] ?? 'agent'),
                ] : null;
            })() : null,
        ]);
    }

    public function renderDashboard(): void
    {
        $this->renderPage('dashboard');
    }

    public function renderOrders(): void
    {
        $this->renderPage('orders');
    }

    public function renderProducts(): void
    {
        $this->renderPage('products');
    }

    public function renderDesign(): void
    {
        $this->renderPage('card-designer');
    }

    public function renderImpulse(): void
    {
        $this->renderPage('instinctor');
        $this->renderOnboarding();
    }

    /**
     * First-run welcome overlay for a new DIY (WordPress) install. A light
     * orientation shown once, until instinctor_onboarded is set: it points the
     * owner to Settings to set up their SITE, and does not force a store-type
     * choice at the door (that lives in Settings, where gated types read as an
     * upsell rather than a wall on the very first screen). Standalone installs
     * use SetupController instead.
     *
     * Deliberately not store-framed (2026-08-12): most WP.org installs arrive
     * for the page builder and never sell anything, so a store-only welcome
     * misdescribes the plugin on the one screen every new user sees.
     */
    public function renderOnboarding(): void
    {
        if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') return;
        if (Config::get('instinctor_onboarded', false)) return;
        if (!Auth::can('manage_options')) return;
        $nonce = Session::token('instinctor_admin');
        $ov   = 'position:fixed;inset:0;z-index:100000;background:rgba(15,17,21,0.72);display:flex;align-items:center;justify-content:center;padding:24px';
        $card = 'background:#fff;border-radius:12px;max-width:480px;width:100%;padding:32px;box-shadow:0 20px 60px rgba(0,0,0,0.35);font-family:Arial,Helvetica,sans-serif';
        echo '<div id="instinctor-onboarding" data-nonce="' . esc_attr($nonce) . '" style="' . esc_attr($ov) . '"><div style="' . esc_attr($card) . '">';
        echo '<div style="font-size:12px;font-weight:700;letter-spacing:0.08em;text-transform:uppercase;color:#999;margin-bottom:8px">Welcome to Instinctor</div>';
        echo '<h2 style="font-size:24px;font-weight:800;color:#1d2327;margin:0 0 10px;letter-spacing:-0.02em">Set up your site</h2>';
        echo '<p style="font-size:14px;line-height:1.55;color:#555;margin:0 0 24px">Start in Settings: add your business details, and pick a store type if you plan to sell. Then you are ready to build your first page.</p>';
        echo '<button type="button" id="instinctor-onboarding-go" style="width:100%;background:#1d2327;color:#fff;border:0;border-radius:6px;padding:13px;font-size:15px;font-weight:600;cursor:pointer;font-family:inherit">Get started</button>';
        echo '</div></div>';
    }

    public function ajaxCompleteOnboarding(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) wp_send_json_error(['message' => 'Permission denied']);
        Config::set('instinctor_onboarded', true);
        wp_send_json_success([]);
    }

    public function renderCustomers(): void
    {
        $this->renderPage('customers');
    }

    public function renderCheckout(): void
    {
        // Checkout builder has been moved to Impulse page builder
        wp_safe_redirect(admin_url('admin.php?page=instinctor'));
        exit;
    }

    public function renderCategories(): void
    {
        $this->renderPage('categories');
    }

    public function renderCoupons(): void
    {
        $this->renderPage('coupons');
    }

    public function renderShipping(): void
    {
        $this->renderPage('shipping');
    }

    public function renderTax(): void
    {
        $this->renderPage('tax');
    }

    public function renderCurrency(): void
    {
        $this->renderPage('currency');
    }

    public function renderEmails(): void
    {
        $this->renderPage('emails');
    }

    public function renderReports(): void
    {
        $this->renderPage('reports');
    }

    public function renderBookings(): void
    {
        $this->renderPage('bookings');
    }

    public function renderSubscriptions(): void
    {
        $this->renderPage('subscriptions');
    }

    public function renderMemberships(): void
    {
        $this->renderPage('memberships');
    }

    public function renderBundles(): void
    {
        $this->renderPage('bundles');
    }

    public function renderGifts(): void
    {
        $this->renderPage('gifts');
    }

    public function renderSettings(): void
    {
        $this->renderPage('settings');
    }

    public function renderGiftCards(): void
    {
        $this->renderPage('gift-cards');
    }

    public function renderLoyalty(): void
    {
        $this->renderPage('loyalty');
    }

    public function renderInventory(): void
    {
        $this->renderPage('inventory');
    }

    public function renderTracking(): void
    {
        $this->renderPage('tracking');
    }

    public function renderRMA(): void
    {
        $this->renderPage('rma');
    }

    public function renderQA(): void
    {
        $this->renderPage('qa');
    }

    public function renderSEO(): void
    {
        $this->renderPage('seo');
    }

    public function renderCommissions(): void
    {
        $this->renderPage('commissions');
    }

    public function renderWholesale(): void
    {
    }

    public function renderAnalytics(): void
    {
        $this->renderPage('analytics');
    }


    public function renderAIRecommendations(): void
    {
        $this->renderPage('ai-recommendations');
    }

    public function renderMarketing(): void
    {
        $this->renderPage('marketing');
    }

    public function renderFinance(): void
    {
        $this->renderPage('finance');
    }

    public function renderAIConversion(): void
    {
        $this->renderPage('ai-conversion');
    }

    public function renderCheckoutUpsells(): void
    {
        $this->renderPage('checkout-upsells');
    }

    public function renderAddons(): void
    {
        // Handle integrity reset request
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- capability-gated (manage_options) maintenance switch read from a GET flag.
        if (isset($_GET['reset_integrity']) && $_GET['reset_integrity'] === '1' && Auth::can('manage_options')) {
            // TierManager reset after plugin update
            \Instinctor\Core\TierManager::reset();
            wp_safe_redirect(admin_url('admin.php?page=instinctor&integrity_reset=1'));
            exit;
        }
        
        $this->renderPage('addons');
    }

    /**
     * Handle Impulse template form actions before any output
     */
    public function handleTemplateFormActions(): void
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Nonce verified below
        if (!isset($_POST['instinctor_template_action'])) {
            return;
        }
        
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        if (!isset($_POST['_wpnonce']) || !wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['_wpnonce'])), 'instinctor_template_editor')) {
            return;
        }
        
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
        $post_action = sanitize_text_field(wp_unslash((string)($_POST['instinctor_template_action'] ?? '')));
        $template_id = isset($_GET['template_id']) ? intval($_GET['template_id']) : 0;
        
        // Create tables if needed
        \Instinctor\Services\TemplateService::createTables();
        
        if ($post_action === 'create') {
            $id = \Instinctor\Services\TemplateService::saveTemplate([
                'name' => sanitize_text_field(wp_unslash($_POST['template_name'] ?? 'Untitled')),
                'type' => sanitize_text_field(wp_unslash($_POST['template_type'] ?? 'page')),
                'status' => 'draft',
            ]);
            wp_safe_redirect(admin_url('admin.php?page=instinctor&action=edit&template_id=' . $id));
            exit;
        }
        
        if ($post_action === 'delete' && $template_id) {
            \Instinctor\Services\TemplateService::deleteTemplate($template_id);
            wp_safe_redirect(admin_url('admin.php?page=instinctor&deleted=1'));
            exit;
        }
        
        if ($post_action === 'duplicate' && $template_id) {
            $newId = \Instinctor\Services\TemplateService::duplicateTemplate($template_id);
            wp_safe_redirect(admin_url('admin.php?page=instinctor&action=edit&template_id=' . $newId));
            exit;
        }
        
        if ($post_action === 'apply_starter' && $template_id) {
            $starter = sanitize_text_field(wp_unslash($_POST['starter_template'] ?? ''));
            if ($starter) {
                \Instinctor\Services\TemplateService::applyStarterTemplate($template_id, $starter);
            }
            wp_safe_redirect(admin_url('admin.php?page=instinctor&action=edit&template_id=' . $template_id . '&applied=1'));
            exit;
        }
    }

    private function renderPage(string $page): void
    {
        $template = INSTINCTOR_PLUGIN_DIR . "admin/views/{$page}.php";
        
        if (file_exists($template)) {
            ob_start();
            include $template;
            $html = ob_get_clean();
            // Strip HTML comments
            $html = preg_replace('/<!--(?!\[if)[\s\S]*?-->/', '', $html);
            // $html is the fully-rendered admin view template (plugin-generated
            // markup, escaped where each dynamic value is built). It legitimately
            // contains form controls (input/select/button/form) and data-*
            // attributes that wp_kses_post would strip and break the admin UI, so
            // escaping stays at the source and the assembled markup is emitted verbatim.
            echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        } else {
            // Fallback: React app mount point
            echo '<div class="wrap"><div id="instinctor-admin-app" data-page="' . esc_attr($page) . '"></div></div>';
        }
    }

    public function ajaxGenerateVariations(): void
    {
        check_ajax_referer('instinctor_variations', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $productId = intval($_POST['product_id'] ?? 0);
        $attributes = json_decode(sanitize_textarea_field(wp_unslash($_POST['attributes'] ?? '{}')), true);
        $basePrice = floatval($_POST['base_price'] ?? 0);

        if (!$productId || empty($attributes)) {
            wp_send_json_error('Invalid data');
        }

        $variationService = new \Instinctor\Services\VariationService();
        
        // Delete existing variations first
        $variationService->deleteProductVariations($productId);
        
        // Generate new variations
        $count = $variationService->generateVariations($productId, $attributes, $basePrice);

        wp_send_json_success(['count' => $count]);
    }

    public function ajaxSaveVariations(): void
    {
        check_ajax_referer('instinctor_variations', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $variations = json_decode(sanitize_textarea_field(wp_unslash($_POST['variations'] ?? '[]')), true);

        if (empty($variations)) {
            wp_send_json_error('No variations to save');
        }

        $variationService = new \Instinctor\Services\VariationService();

        foreach ($variations as $v) {
            $variationService->updateVariation((int) $v['id'], [
                'sku' => $v['sku'] ?? '',
                'price' => !empty($v['price']) ? (float) $v['price'] : null,
                'stock_quantity' => isset($v['stock_quantity']) && $v['stock_quantity'] !== '' ? (int) $v['stock_quantity'] : null,
            ]);
        }

        wp_send_json_success();
    }

    public function ajaxDeleteVariation(): void
    {
        check_ajax_referer('instinctor_variations', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $variationId = intval($_POST['variation_id'] ?? 0);

        if (!$variationId) {
            wp_send_json_error('Invalid variation');
        }

        $variationService = new \Instinctor\Services\VariationService();
        $variationService->deleteVariation($variationId);

        wp_send_json_success();
    }

    public function ajaxAddOrderNote(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $orderId = intval($_POST['order_id'] ?? 0);
        $note = sanitize_textarea_field($_POST['note'] ?? '');
        $isCustomerNote = !empty($_POST['is_customer_note']);

        if (!$orderId || !$note) {
            wp_send_json_error('Missing required fields');
        }

        $noteId = \Instinctor\Services\OrderNoteService::addNote($orderId, $note, $isCustomerNote, $isCustomerNote);

        wp_send_json_success(['note_id' => $noteId]);
    }

    public function ajaxDeleteOrderNote(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $noteId = intval($_POST['note_id'] ?? 0);

        if (!$noteId) {
            wp_send_json_error('Invalid note');
        }

        \Instinctor\Services\OrderNoteService::deleteNote($noteId);
        wp_send_json_success();
    }

    public function ajaxDeleteOrder(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $orderId = intval($_POST['order_id'] ?? 0);
        if (!$orderId) {
            wp_send_json_error('Invalid order');
        }

        $p = DB::prefix();
        // Delete related rows first
        DB::delete($p . 'instinctor_order_items',    ['order_id' => $orderId]);
        DB::delete($p . 'instinctor_order_notes',    ['order_id' => $orderId]);
        DB::delete($p . 'instinctor_order_tracking', ['order_id' => $orderId]);
        DB::delete($p . 'instinctor_orders',         ['id'       => $orderId]);

        wp_send_json_success();
    }

    public function ajaxDeleteClient(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $customerId = intval($_POST['customer_id'] ?? 0);
        if (!$customerId) {
            wp_send_json_error('Invalid customer');
        }

        $p = DB::prefix();
        DB::delete($p . 'instinctor_customers', ['id' => $customerId]);

        wp_send_json_success();
    }

    public function ajaxGetOrderDetail(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        

        $p = DB::prefix();
        $orderId = intval($_POST['order_id'] ?? 0);
        if (!$orderId) {
            wp_send_json_error('Invalid order');
        }

        $order = DB::get_row(DB::prepare(
            "SELECT * FROM {$p}instinctor_orders WHERE id = %d",
            $orderId
        ));

        if (!$order) {
            wp_send_json_error('Order not found');
        }

        $items = DB::get_results(DB::prepare(
            "SELECT * FROM {$p}instinctor_order_items WHERE order_id = %d",
            $orderId
        ));

        $billing = json_decode($order->billing_address ?? '{}', true);
        $customerName = trim(($billing['first_name'] ?? '') . ' ' . ($billing['last_name'] ?? ''));
        
        // Get customer email: customer table, fallback to billing_address JSON
        $customerEmail = '';
        if ($order->customer_id) {
            $customerEmail = DB::get_var(DB::prepare(
                "SELECT email FROM {$p}instinctor_customers WHERE id = %d",
                $order->customer_id
            )) ?: '';
        }
        if (empty($customerEmail)) {
            $customerEmail = $billing['email'] ?? '';
        }
        
        // Get tracking info from tracking table (primary) with meta fallback
        $trackingRow = DB::get_row(DB::prepare(
            "SELECT carrier, tracking_number, tracking_url FROM {$p}instinctor_order_tracking WHERE order_id = %d ORDER BY created_at DESC LIMIT 1",
            $orderId
        ));
        
        if ($trackingRow && $trackingRow->tracking_number) {
            $trackingNumber = $trackingRow->tracking_number;
            $trackingUrl = $trackingRow->tracking_url ?? '';
            $trackingCarrier = $trackingRow->carrier ?? 'other';
        } else {
            // Fallback to meta for legacy data
            $meta = json_decode($order->meta ?? '{}', true);
            $trackingNumber = $meta['tracking_number'] ?? '';
            $trackingUrl = $meta['tracking_url'] ?? '';
            $trackingCarrier = $meta['tracking_carrier'] ?? 'other';
        }

        wp_send_json_success([
            'id' => $order->id,
            'order_number' => $order->order_number,
            'status' => $order->status,
            'subtotal' => $order->subtotal,
            'discount_total' => $order->discount_total,
            'shipping_total' => $order->shipping_total,
            'tax_total' => $order->tax_total,
            'total' => $order->total,
            
            'currency' => Config::get('instinctor_currency_symbol', '$'),
            'customer_id' => $order->customer_id ? (int)$order->customer_id : null,
            'customer_name' => $customerName ?: null,
            'customer_email' => $customerEmail,
            'date' => date_i18n('F j, Y g:i a', strtotime($order->created_at)),
            
            'tracking_number' => $trackingNumber,
            'tracking_url' => $trackingUrl,
            'tracking_carrier' => $trackingCarrier,
            'items' => array_map(function($item) {
                $meta = json_decode($item->meta ?: '{}', true);
                return [
                    'name'     => $item->name,
                    'quantity' => $item->quantity,
                    'price'    => $item->price,
                    'total'    => $item->total,
                    'addons'   => $meta['addons'] ?? [],
                ];
            }, $items)
        ]);
    }

    public function ajaxGetCustomerDetail(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        

        $p = DB::prefix();
        $customerId = intval($_POST['customer_id'] ?? 0);
        if (!$customerId) {
            wp_send_json_error('Invalid customer');
        }

        $customer = DB::get_row(DB::prepare(
            "SELECT * FROM {$p}instinctor_customers WHERE id = %d",
            $customerId
        ));

        if (!$customer) {
            wp_send_json_error('Customer not found');
        }

        $currency = Config::get('instinctor_currency_symbol', '$');

        $orders = DB::get_results(DB::prepare(
            "SELECT id, order_number, status, total, created_at
             FROM {$p}instinctor_orders
             WHERE customer_id = %d
             ORDER BY created_at DESC
             LIMIT 50",
            $customerId
        ));

        $orderCount  = count($orders);
        $totalSpent  = 0;
        $firstDate   = null;
        $lastDate    = null;
        foreach ($orders as $o) {
            if (in_array($o->status, ['completed', 'processing'])) {
                $totalSpent += floatval($o->total);
            }
            if (!$firstDate || $o->created_at < $firstDate) $firstDate = $o->created_at;
            if (!$lastDate  || $o->created_at > $lastDate)  $lastDate  = $o->created_at;
        }

        $billing = json_decode($customer->billing_address ?? '{}', true) ?: [];
        $name    = trim(($customer->first_name ?: '') . ' ' . ($customer->last_name ?: ''));

        // Loyalty points
        $pt = DB::prefix() . 'instinctor_loyalty_points';
        $pt_exists = DB::get_var("SHOW TABLES LIKE '$pt'") === $pt;
        $loyalty = $pt_exists
            ? DB::get_row(DB::prepare("SELECT points, lifetime_points, tier FROM $pt WHERE customer_id = %d", $customerId))
            : null;

        // Gift card balance by recipient email
        $gc = DB::prefix() . 'instinctor_gift_cards';
        $gc_exists = DB::get_var("SHOW TABLES LIKE '$gc'") === $gc;
        $giftBalance = ($gc_exists && $customer->email)
            ? floatval(DB::get_var(DB::prepare("SELECT COALESCE(SUM(current_balance),0) FROM $gc WHERE recipient_email = %s AND status = 'active'", $customer->email)))
            : 0.0;

        // Memberships: surface the member's subscription(s) so the detail panel
        // shows plan/status/billing instead of the (empty) order history.
        $subscription  = null;
        $subscriptions = [];
        if (\Instinctor\Core\StoreFeatures::getStoreType() === 'memberships') {
            $subRows = DB::get_results(DB::prepare(
                "SELECT s.id, s.status, s.price, s.period, s.current_period_end,
                        s.canceled_at, s.created_at, s.payment_method, s.external_id, s.meta, p.name AS plan_name
                 FROM {$p}instinctor_subscriptions s
                 LEFT JOIN {$p}instinctor_products p ON p.id = s.product_id
                 WHERE s.customer_id = %d
                 ORDER BY (s.status = 'active') DESC, s.created_at DESC",
                $customerId
            ));
            $statusLabels = [
                'active' => 'Active', 'canceled' => 'Cancelled', 'cancelled' => 'Cancelled',
                'past_due' => 'Past due', 'paused' => 'Paused', 'trialing' => 'Trial',
            ];
            foreach ($subRows as $s) {
                $per   = ((string)$s->period === 'year') ? '/yr' : '/mo';
                $price = (float)$s->price;
                $sMeta = json_decode((string) ($s->meta ?? ''), true) ?: [];
                $subscriptions[] = [
                    'id'           => (int)$s->id,
                    'status'       => $s->status,
                    'status_label' => $statusLabels[$s->status] ?? ucfirst(str_replace('_', ' ', (string)$s->status)),
                    'plan'         => $s->plan_name,
                    'price'        => $price,
                    'payment_method' => (string) $s->payment_method,
                    'billable'     => ($s->payment_method === 'authorizenet' && !empty($s->external_id)),
                    // Can be reactivated from a stored card-on-file (CIM profile) without
                    // re-entering the card. Drives the Reactivate button on paused members.
                    'has_saved_card' => (!empty($sMeta['an_customer_profile_id']) && !empty($sMeta['an_payment_profile_id'])),
                    'price_label'  => $price > 0 ? $currency . number_format($price, ($price == floor($price) ? 0 : 2)) . $per : '',
                    'since'        => $s->created_at ? date_i18n('M j, Y', strtotime($s->created_at)) : null,
                    'renews'       => ($s->status === 'active' && $s->current_period_end) ? date_i18n('M j, Y', strtotime($s->current_period_end)) : null,
                    'canceled'     => $s->canceled_at ? date_i18n('M j, Y', strtotime($s->canceled_at)) : null,
                ];
            }
            $subscription = $subscriptions[0] ?? null;
        }

        // Salon CRM block: appointment timeline + before/after photos + color
        // formulas + soft fields. Salon vertical only, so other stores are unchanged.
        $salon = null;
        if (\Instinctor\Core\StoreFeatures::isSalon()) {
            if (!\Instinctor\Core\TierManager::hasActiveLicense()) {
                $salon = ['locked' => true];   // Pro feature — admin shows an upgrade panel
            } else {
                $prof = \Instinctor\Services\ClientService::getProfile($customerId);
                if ($prof) {
                    $salon = [
                        'visits'    => array_values(array_filter($prof['visits'], fn($v) => $v['kind'] === 'appointment')),
                        'photos'    => $prof['photos'],
                        'formulas'  => $prof['formulas'],
                        'tags'      => $prof['tags'],
                        'birthday'  => $prof['birthday'],
                        'allergies' => $prof['allergies'],
                    ];
                }
            }
        }

        wp_send_json_success([
            'id'             => (int)$customer->id,
            'salon'          => $salon,
            'name'           => $name ?: null,
            'first_name'     => (string) ($customer->first_name ?? ''),
            'last_name'      => (string) ($customer->last_name ?? ''),
            'email'          => $customer->email,
            'phone'          => $customer->phone ?: null,
            'address'        => trim(implode(', ', array_filter([
                $billing['address'] ?? $billing['address_1'] ?? '',
                $billing['city'] ?? '',
                $billing['state'] ?? '',
                $billing['country'] ?? '',
            ]))),
            'order_count'    => $orderCount,
            'total_spent'    => $totalSpent,
            'currency'       => $currency,
            'first_order'    => $firstDate ? date_i18n('M j, Y', strtotime($firstDate)) : null,
            'last_order'     => $lastDate  ? date_i18n('M j, Y', strtotime($lastDate))  : null,
            'member_since'   => date_i18n('M j, Y', strtotime($customer->created_at)),
            'subscription'   => $subscription,
            'subscriptions'  => $subscriptions,
            'loyalty_points' => $loyalty ? (int)$loyalty->points : null,
            'loyalty_tier'   => $loyalty ? ($loyalty->tier ?: 'bronze') : null,
            'gift_balance'   => $giftBalance > 0 ? $giftBalance : null,
            'orders'         => array_map(function($o) use ($currency) {
                return [
                    'id'           => (int)$o->id,
                    'order_number' => $o->order_number,
                    'status'       => $o->status,
                    'total'        => floatval($o->total),
                    'date'         => date_i18n('M j, Y', strtotime($o->created_at)),
                ];
            }, $orders),
        ]);
    }

    /**
     * Edit a member: name (first/last) and, optionally, the recurring amount.
     * The amount change is pushed to the payment gateway so future charges use
     * the new figure (Authorize.net ARB via ARBUpdateSubscription), then the
     * local subscription row is synced. The name always saves even if the
     * billing update is skipped/unsupported; any billing issue is returned as a
     * non-fatal warning so the name edit is never lost. Memberships store only.
     */
    public function ajaxUpdateMember(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error(['message' => 'Permission denied']); }

        $p          = DB::prefix();
        $customerId = (int) ($_POST['customer_id'] ?? 0);
        $first      = trim(sanitize_text_field(wp_unslash($_POST['first_name'] ?? '')));
        $last       = trim(sanitize_text_field(wp_unslash($_POST['last_name'] ?? '')));
        if (!$customerId) { wp_send_json_error(['message' => 'Invalid member']); }

        $cust = DB::get_row(DB::prepare("SELECT id FROM {$p}instinctor_customers WHERE id = %d", $customerId));
        if (!$cust) { wp_send_json_error(['message' => 'Member not found']); }

        // 1. Name (always).
        DB::update("{$p}instinctor_customers", ['first_name' => $first, 'last_name' => $last], ['id' => $customerId]);

        $out = ['name' => trim($first . ' ' . $last), 'warning' => null];

        // 2. Optional recurring amount change → gateway + local sync.
        $amountRaw = $_POST['amount'] ?? '';
        if ($amountRaw !== '' && is_numeric($amountRaw)) {
            $amount = round((float) $amountRaw, 2);
            $sub = DB::get_row(DB::prepare(
                "SELECT id, price, payment_method, external_id FROM {$p}instinctor_subscriptions
                 WHERE customer_id = %d ORDER BY (status='active') DESC, created_at DESC LIMIT 1",
                $customerId
            ));
            if ($amount <= 0) {
                $out['warning'] = 'Amount must be greater than zero; billing unchanged.';
            } elseif (!$sub) {
                $out['warning'] = 'No membership to bill; name saved.';
            } elseif ((float) $sub->price === $amount) {
                // no change to the amount — nothing to do
            } else {
                $pm = (string) $sub->payment_method;
                if ($pm === 'authorizenet' && !empty($sub->external_id)) {
                    $r = \Instinctor\Services\SubscriptionService::instance()
                        ->updateAuthorizeNetAmount((string) $sub->external_id, $amount);
                    if (empty($r['success'])) {
                        wp_send_json_error(['message' => 'Name saved, but the billing update failed: ' . ($r['error'] ?? 'unknown error') . '. The recurring amount was NOT changed.']);
                    }
                    DB::update("{$p}instinctor_subscriptions", ['price' => $amount], ['id' => (int) $sub->id]);
                    $out['amount'] = $amount;
                } elseif ($pm === 'stripe') {
                    $out['warning'] = 'Name saved. Changing a Stripe amount is not supported here yet; adjust it in Stripe.';
                } else {
                    // local/manual subscription — just sync the record
                    DB::update("{$p}instinctor_subscriptions", ['price' => $amount], ['id' => (int) $sub->id]);
                    $out['amount'] = $amount;
                }
            }
        }

        wp_send_json_success($out);
    }

    /**
     * Suspend a member: cancel their Authorize.net ARB so billing STOPS (a local
     * status flag alone does not — the ARB keeps charging), then mark the local
     * subscription 'paused'. The card profile is retained so the member can be
     * reactivated later without re-entering the card. Memberships store only.
     */
    public function ajaxSuspendMember(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error(['message' => 'Permission denied']); }

        $p          = DB::prefix();
        $customerId = (int) ($_POST['customer_id'] ?? 0);
        if (!$customerId) { wp_send_json_error(['message' => 'Invalid member']); }

        // Target the member's active (or most recent) subscription.
        $sub = DB::get_row(DB::prepare(
            "SELECT id, status, payment_method, external_id, meta FROM {$p}instinctor_subscriptions
             WHERE customer_id = %d ORDER BY (status='active') DESC, created_at DESC LIMIT 1",
            $customerId
        ));
        if (!$sub) { wp_send_json_error(['message' => 'No membership to suspend']); }

        // Stop the real recurring charge for Authorize.net members.
        if ((string) $sub->payment_method === 'authorizenet' && !empty($sub->external_id)) {
            $r = \Instinctor\Services\SubscriptionService::instance()
                ->cancelAuthorizeNetSubscription((string) $sub->external_id);
            if (empty($r['success'])) {
                wp_send_json_error(['message' => 'Could not stop the recurring billing: ' . ($r['error'] ?? 'unknown') . '. Nothing changed.']);
            }
        }

        $meta = json_decode((string) ($sub->meta ?? ''), true) ?: [];
        $meta['suspended_at']  = current_time('mysql');
        $meta['arb_cancelled'] = ((string) $sub->payment_method === 'authorizenet');
        DB::update("{$p}instinctor_subscriptions", ['status' => 'paused', 'meta' => wp_json_encode($meta)], ['id' => (int) $sub->id]);

        wp_send_json_success(['status' => 'paused']);
    }

    /**
     * Reactivate a suspended member: create a fresh Authorize.net ARB from the
     * card on file (ARB cancel is one-way, so resume = new subscription), then set
     * the local subscription 'active' with the new ARB id. Requires a stored CIM
     * profile (has_saved_card); otherwise the member must re-enroll to add a card.
     */
    public function ajaxReactivateMember(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error(['message' => 'Permission denied']); }

        $p          = DB::prefix();
        $customerId = (int) ($_POST['customer_id'] ?? 0);
        if (!$customerId) { wp_send_json_error(['message' => 'Invalid member']); }

        $sub = DB::get_row(DB::prepare(
            "SELECT s.id, s.status, s.price, s.period, s.interval_count, s.payment_method, s.meta, pr.name AS plan_name
             FROM {$p}instinctor_subscriptions s
             LEFT JOIN {$p}instinctor_products pr ON pr.id = s.product_id
             WHERE s.customer_id = %d ORDER BY (s.status='paused') DESC, s.created_at DESC LIMIT 1",
            $customerId
        ));
        if (!$sub) { wp_send_json_error(['message' => 'No membership to reactivate']); }

        if ((string) $sub->payment_method !== 'authorizenet') {
            wp_send_json_error(['message' => 'Reactivating this member is not supported here yet; use the site checkout.']);
        }
        $meta = json_decode((string) ($sub->meta ?? ''), true) ?: [];
        $cust = $meta['an_customer_profile_id'] ?? '';
        $pay  = $meta['an_payment_profile_id']  ?? '';
        if (!$cust || !$pay) {
            wp_send_json_error(['message' => 'No card on file for this member. They need to re-enroll through the site to add a card.']);
        }

        $r = \Instinctor\Services\SubscriptionService::instance()->createAuthorizeNetSubscriptionFromProfile(
            (string) $cust, (string) $pay, (float) $sub->price,
            (string) $sub->period, (int) ($sub->interval_count ?: 1),
            (string) ($sub->plan_name ?: 'Membership')
        );
        if (empty($r['success'])) {
            wp_send_json_error(['message' => 'Could not restart the recurring billing: ' . ($r['error'] ?? 'unknown') . '.']);
        }

        unset($meta['suspended_at'], $meta['arb_cancelled']);
        $meta['reactivated_at'] = current_time('mysql');
        DB::update("{$p}instinctor_subscriptions", [
            'status'      => 'active',
            'external_id' => (string) $r['arb_id'],
            'canceled_at' => null,
            'meta'        => wp_json_encode($meta),
        ], ['id' => (int) $sub->id]);

        wp_send_json_success(['status' => 'active']);
    }

    /**
     * AJAX: Get visitor note for a client record (keyed by email)
     */
    public function ajaxGetClientNote(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }

        $customerId = intval($_POST['customer_id'] ?? 0);
        if (!$customerId) { wp_send_json_error('Invalid customer'); return; }

        $p = DB::prefix();
        $email = DB::get_var(DB::prepare("SELECT email FROM {$p}instinctor_customers WHERE id = %d", $customerId));
        if (!$email) { wp_send_json_success(['note' => '']); return; }

        $nt = $p . 'instinctor_visitor_notes';
        $tableExists = DB::get_var("SHOW TABLES LIKE '$nt'");
        if (!$tableExists) { wp_send_json_success(['note' => '']); return; }

        $row = DB::get_row(DB::prepare("SELECT note FROM $nt WHERE email = %s", $email));
        wp_send_json_success(['note' => $row ? $row->note : '']);
    }

    /**
     * AJAX: Save (upsert) visitor note for a client record
     */
    public function ajaxSaveClientNote(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }

        $customerId = intval($_POST['customer_id'] ?? 0);
        $note       = isset($_POST['note']) ? sanitize_textarea_field(wp_unslash($_POST['note'])) : '';
        if (!$customerId) { wp_send_json_error('Invalid customer'); return; }

        $p = DB::prefix();
        $email = DB::get_var(DB::prepare("SELECT email FROM {$p}instinctor_customers WHERE id = %d", $customerId));
        if (!$email) { wp_send_json_error('Customer email not found'); return; }

        $nt  = $p . 'instinctor_visitor_notes';
        $now = current_time('mysql');
        $uid = \Instinctor\Core\Auth::userId();

        // Ensure table exists
        if (!DB::get_var("SHOW TABLES LIKE '$nt'")) {
            \Instinctor\Services\ChatService::instance()->createTables();
        }

        $existing = DB::get_row(DB::prepare("SELECT id FROM $nt WHERE email = %s", $email));
        if ($existing) {
            DB::update($nt, ['note' => $note, 'updated_at' => $now, 'updated_by' => $uid], ['id' => $existing->id]);
        } else {
            DB::insert($nt, ['email' => $email, 'phone' => '', 'note' => $note, 'updated_at' => $now, 'updated_by' => $uid]);
        }

        wp_send_json_success(['note' => $note]);
    }

    // ── Salon CRM: before/after photos + color formulas + soft fields ─────────
    public function ajaxClientPhotoAdd(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }
        if (!\Instinctor\Core\TierManager::hasActiveLicense()) { wp_send_json_error('Client profiles are a Pro feature'); return; }
        $cid = intval($_POST['customer_id'] ?? 0);
        if (!$cid) { wp_send_json_error('Invalid client'); return; }
        $before = esc_url_raw(wp_unslash($_POST['before_url'] ?? ''));
        $after  = esc_url_raw(wp_unslash($_POST['after_url'] ?? ''));
        if ($before === '' && $after === '') { wp_send_json_error('Add at least one photo'); return; }
        \Instinctor\Services\ClientService::addPhoto($cid, [
            'service'      => sanitize_text_field(wp_unslash($_POST['service'] ?? '')),
            'before_url'   => $before,
            'after_url'    => $after,
            'before_thumb' => esc_url_raw(wp_unslash($_POST['before_thumb'] ?? '')) ?: $before,
            'after_thumb'  => esc_url_raw(wp_unslash($_POST['after_thumb'] ?? '')) ?: $after,
            'taken_at'     => sanitize_text_field(wp_unslash($_POST['taken_at'] ?? '')),
            'note'         => sanitize_textarea_field(wp_unslash($_POST['note'] ?? '')),
            'booking_id'   => intval($_POST['booking_id'] ?? 0),
        ]);
        wp_send_json_success(['photos' => \Instinctor\Services\ClientService::listPhotos($cid)]);
    }

    public function ajaxClientPhotoDelete(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }
        if (!\Instinctor\Core\TierManager::hasActiveLicense()) { wp_send_json_error('Client profiles are a Pro feature'); return; }
        $pid = intval($_POST['photo_id'] ?? 0);
        $cid = intval($_POST['customer_id'] ?? 0);
        if (!$pid) { wp_send_json_error('Invalid photo'); return; }
        \Instinctor\Services\ClientService::deletePhoto($pid);
        wp_send_json_success(['photos' => $cid ? \Instinctor\Services\ClientService::listPhotos($cid) : []]);
    }

    public function ajaxClientFormulaSave(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }
        if (!\Instinctor\Core\TierManager::hasActiveLicense()) { wp_send_json_error('Client profiles are a Pro feature'); return; }
        $cid = intval($_POST['customer_id'] ?? 0);
        $fid = intval($_POST['formula_id'] ?? 0);
        if (!$cid) { wp_send_json_error('Invalid client'); return; }
        $data = [
            'title'   => sanitize_text_field(wp_unslash($_POST['title'] ?? '')),
            'formula' => sanitize_textarea_field(wp_unslash($_POST['formula'] ?? '')),
            'brand'   => sanitize_text_field(wp_unslash($_POST['brand'] ?? '')),
            'service' => sanitize_text_field(wp_unslash($_POST['service'] ?? '')),
            'used_at' => sanitize_text_field(wp_unslash($_POST['used_at'] ?? '')),
        ];
        if ($fid) \Instinctor\Services\ClientService::updateFormula($fid, $data);
        else      \Instinctor\Services\ClientService::addFormula($cid, $data);
        wp_send_json_success(['formulas' => \Instinctor\Services\ClientService::listFormulas($cid)]);
    }

    public function ajaxClientFormulaDelete(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }
        if (!\Instinctor\Core\TierManager::hasActiveLicense()) { wp_send_json_error('Client profiles are a Pro feature'); return; }
        $fid = intval($_POST['formula_id'] ?? 0);
        $cid = intval($_POST['customer_id'] ?? 0);
        if (!$fid) { wp_send_json_error('Invalid formula'); return; }
        \Instinctor\Services\ClientService::deleteFormula($fid);
        wp_send_json_success(['formulas' => $cid ? \Instinctor\Services\ClientService::listFormulas($cid) : []]);
    }

    public function ajaxClientMetaSave(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }
        if (!\Instinctor\Core\TierManager::hasActiveLicense()) { wp_send_json_error('Client profiles are a Pro feature'); return; }
        $cid = intval($_POST['customer_id'] ?? 0);
        if (!$cid) { wp_send_json_error('Invalid client'); return; }
        $fields = [];
        foreach (['tags', 'birthday', 'allergies'] as $k) {
            if (isset($_POST[$k])) $fields[$k] = sanitize_text_field(wp_unslash($_POST[$k]));
        }
        \Instinctor\Services\ClientService::saveMeta($cid, $fields);
        $prof = \Instinctor\Services\ClientService::getProfile($cid);
        wp_send_json_success(['tags' => $prof['tags'] ?? [], 'birthday' => $prof['birthday'] ?? '', 'allergies' => $prof['allergies'] ?? '']);
    }

    public function ajaxGetCustomers(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');

        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }


        $segment  = sanitize_key($_POST['segment'] ?? 'all');
        $search   = sanitize_text_field($_POST['search'] ?? '');
        $page     = max(1, intval($_POST['page'] ?? 1));
        $per_page = 50;
        $offset   = ($page - 1) * $per_page;
        $cst      = DB::prefix() . 'instinctor_customers';
        $ord      = DB::prefix() . 'instinctor_orders';
        $currency = Config::get('instinctor_currency_symbol', '$');

        // Memberships stores list MEMBERS (people with a subscription), not
        // order-based clients. The order_count/total_spent/last_order metrics
        // are meaningless when revenue comes from recurring subscriptions, so
        // members are sourced from instinctor_subscriptions with their plan,
        // status, and start date instead. Account-only customers (no
        // subscription) are excluded.
        if (\Instinctor\Core\StoreFeatures::getStoreType() === 'memberships') {
            $this->getMembersList($segment, $search, $page, $per_page, $offset, $cst, $currency);
            return;
        }

        $where_parts = [];
        $where_args  = [];

        if ($search !== '') {
            $like = '%' . DB::esc_like($search) . '%';
            $where_parts[] = '(c.email LIKE %s OR c.first_name LIKE %s OR c.last_name LIKE %s OR c.phone LIKE %s)';
            array_push($where_args, $like, $like, $like, $like);
        }

        $where_sql = $where_parts ? 'WHERE ' . implode(' AND ', $where_parts) : '';

        $having_sql = '';
        $threshold  = floatval(Config::get('instinctor_high_value_threshold', 200));
        switch ($segment) {
            case 'repeat':     $having_sql = 'HAVING order_count >= 2'; break;
            case 'high_value': $having_sql = "HAVING total_spent >= {$threshold}"; break;
            case 'inactive':   $having_sql = 'HAVING last_order_date IS NULL OR last_order_date < DATE_SUB(NOW(), INTERVAL 90 DAY)'; break;
        }

        $inner_sql = "SELECT c.id, c.first_name, c.last_name, c.email, c.phone,
                        COUNT(o.id) as order_count,
                        COALESCE(SUM(CASE WHEN o.status IN ('completed','processing') THEN o.total ELSE 0 END), 0) as total_spent,
                        MAX(o.created_at) as last_order_date
                     FROM {$cst} c
                     LEFT JOIN {$ord} o ON o.customer_id = c.id
                     {$where_sql}
                     GROUP BY c.id
                     {$having_sql}";

        $list_sql  = "{$inner_sql} ORDER BY total_spent DESC, order_count DESC LIMIT %d OFFSET %d";
        $list_args = array_merge($where_args, [$per_page, $offset]);
        $customers = DB::get_results(DB::prepare($list_sql, ...$list_args));

        // Count must use same GROUP BY + HAVING to get correct total for the active segment
        $count_sql  = "SELECT COUNT(*) FROM ({$inner_sql}) AS _filtered";
        $total      = $where_args
            ? (int)DB::get_var(DB::prepare($count_sql, ...$where_args))
            : (int)DB::get_var($count_sql);

        $responseData = [
            'customers' => array_map(function($c) use ($currency) {
                $name = trim(($c->first_name ?: '') . ' ' . ($c->last_name ?: ''));
                return [
                    'id'              => (int)$c->id,
                    'name'            => $name ?: null,
                    'email'           => $c->email,
                    'phone'           => $c->phone ?: null,
                    'order_count'     => (int)$c->order_count,
                    'total_spent'     => floatval($c->total_spent),
                    'last_order_date' => $c->last_order_date
                        ? date_i18n('M j, Y', strtotime($c->last_order_date))
                        : null,
                    'currency'        => $currency,
                ];
            }, $customers),
            'total'    => $total,
            'page'     => $page,
            'has_more' => ($offset + $per_page) < $total,
        ];

        // Segment counts on first page load
        if ($page === 1) {
            $base = "SELECT c.id, COUNT(o.id) as order_count,
                        COALESCE(SUM(CASE WHEN o.status IN ('completed','processing') THEN o.total ELSE 0 END), 0) as total_spent,
                        MAX(o.created_at) as last_order_date
                     FROM {$cst} c LEFT JOIN {$ord} o ON o.customer_id = c.id GROUP BY c.id";
            $responseData['segment_counts'] = [
                'all'        => (int)DB::get_var("SELECT COUNT(*) FROM ({$base}) AS _a"),
                'repeat'     => (int)DB::get_var("SELECT COUNT(*) FROM ({$base}) AS _r WHERE order_count >= 2"),
                'high_value' => (int)DB::get_var("SELECT COUNT(*) FROM ({$base}) AS _h WHERE total_spent >= {$threshold}"),
                'inactive'   => (int)DB::get_var("SELECT COUNT(*) FROM ({$base}) AS _i WHERE last_order_date IS NULL OR last_order_date < DATE_SUB(NOW(), INTERVAL 90 DAY)"),
                'new_30d'    => (int)DB::get_var(DB::prepare("SELECT COUNT(*) FROM {$cst} WHERE created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)")),
            ];
        }

        wp_send_json_success($responseData);
    }

    /**
     * Members list for memberships stores. One row per subscription-holder,
     * surfacing the primary subscription's status, plan, price, and start date.
     * The "primary" subscription is the active one if present, else the most
     * recent (resolved via GROUP_CONCAT ordered active-first, newest-first).
     */
    private function getMembersList(string $segment, string $search, int $page, int $per_page, int $offset, string $cst, string $currency): void
    {
        $subs = DB::prefix() . 'instinctor_subscriptions';
        $prd  = DB::prefix() . 'instinctor_products';

        $where_parts = [];
        $where_args  = [];
        if ($search !== '') {
            $like = '%' . DB::esc_like($search) . '%';
            $where_parts[] = '(c.email LIKE %s OR c.first_name LIKE %s OR c.last_name LIKE %s OR c.phone LIKE %s)';
            array_push($where_args, $like, $like, $like, $like);
        }
        $where_sql = $where_parts ? 'WHERE ' . implode(' AND ', $where_parts) : '';

        $order_pref = "(s.status = 'active') DESC, s.created_at DESC";
        $inner_sql = "SELECT c.id, c.first_name, c.last_name, c.email, c.phone,
                        MAX(CASE WHEN s.status = 'active' THEN 1 ELSE 0 END) AS has_active,
                        MIN(s.created_at) AS member_since,
                        SUBSTRING_INDEX(GROUP_CONCAT(s.status     ORDER BY {$order_pref}), ',', 1) AS primary_status,
                        SUBSTRING_INDEX(GROUP_CONCAT(s.price      ORDER BY {$order_pref}), ',', 1) AS primary_price,
                        SUBSTRING_INDEX(GROUP_CONCAT(s.period     ORDER BY {$order_pref}), ',', 1) AS primary_period,
                        SUBSTRING_INDEX(GROUP_CONCAT(COALESCE(s.product_id,0) ORDER BY {$order_pref}), ',', 1) AS primary_plan_id
                     FROM {$cst} c
                     INNER JOIN {$subs} s ON s.customer_id = c.id
                     {$where_sql}
                     GROUP BY c.id";

        $having_sql = '';
        switch ($segment) {
            case 'active':   $having_sql = 'HAVING has_active = 1'; break;
            case 'canceled': $having_sql = 'HAVING has_active = 0'; break;
            case 'new':      $having_sql = 'HAVING member_since >= DATE_SUB(NOW(), INTERVAL 30 DAY)'; break;
        }

        $list_sql  = "{$inner_sql} {$having_sql} ORDER BY has_active DESC, member_since DESC LIMIT %d OFFSET %d";
        $list_args = array_merge($where_args, [$per_page, $offset]);
        $members   = DB::get_results(DB::prepare($list_sql, ...$list_args));

        // Resolve plan names in one query.
        $plan_ids = array_values(array_unique(array_filter(array_map(fn($m) => (int)$m->primary_plan_id, $members))));
        $plan_names = [];
        if ($plan_ids) {
            $in = implode(',', array_map('intval', $plan_ids));
            foreach (DB::get_results("SELECT id, name FROM {$prd} WHERE id IN ({$in})") as $p) {
                $plan_names[(int)$p->id] = $p->name;
            }
        }

        $count_sql = "SELECT COUNT(*) FROM ({$inner_sql} {$having_sql}) AS _filtered";
        $total     = $where_args
            ? (int)DB::get_var(DB::prepare($count_sql, ...$where_args))
            : (int)DB::get_var($count_sql);

        $statusLabels = [
            'active' => 'Active', 'canceled' => 'Cancelled', 'cancelled' => 'Cancelled',
            'past_due' => 'Past due', 'paused' => 'Paused', 'trialing' => 'Trial',
        ];

        $responseData = [
            'mode'      => 'memberships',
            'customers' => array_map(function($m) use ($currency, $plan_names, $statusLabels) {
                $name   = trim(($m->first_name ?: '') . ' ' . ($m->last_name ?: ''));
                $status = ((int)$m->has_active === 1) ? 'active' : ((string)$m->primary_status ?: 'canceled');
                $price  = (float)$m->primary_price;
                $per    = ((string)$m->primary_period === 'year') ? '/yr' : '/mo';
                return [
                    'id'           => (int)$m->id,
                    'name'         => $name ?: null,
                    'email'        => $m->email,
                    'phone'        => $m->phone ?: null,
                    'status'       => $status,
                    'status_label' => $statusLabels[$status] ?? ucfirst(str_replace('_', ' ', $status)),
                    'plan'         => $plan_names[(int)$m->primary_plan_id] ?? null,
                    'price'        => $price,
                    'price_label'  => $price > 0 ? $currency . number_format($price, ($price == floor($price) ? 0 : 2)) . $per : '',
                    'member_since' => $m->member_since ? date_i18n('M j, Y', strtotime($m->member_since)) : null,
                    'currency'     => $currency,
                ];
            }, $members),
            'total'    => $total,
            'page'     => $page,
            'has_more' => ($offset + $per_page) < $total,
        ];

        if ($page === 1) {
            $base = "SELECT c.id, MAX(CASE WHEN s.status='active' THEN 1 ELSE 0 END) AS has_active, MIN(s.created_at) AS member_since
                     FROM {$cst} c INNER JOIN {$subs} s ON s.customer_id = c.id GROUP BY c.id";
            $responseData['segment_counts'] = [
                'all'      => (int)DB::get_var("SELECT COUNT(*) FROM ({$base}) AS _a"),
                'active'   => (int)DB::get_var("SELECT COUNT(*) FROM ({$base}) AS _b WHERE has_active = 1"),
                'canceled' => (int)DB::get_var("SELECT COUNT(*) FROM ({$base}) AS _c WHERE has_active = 0"),
                'new_30d'  => (int)DB::get_var("SELECT COUNT(*) FROM ({$base}) AS _d WHERE member_since >= DATE_SUB(NOW(), INTERVAL 30 DAY)"),
            ];
        }

        wp_send_json_success($responseData);
    }

    /**
     * Build a blog-post (article) element tree: site header, a centred white
     * content section holding an optional cover image, the title, and the
     * body, then the footer. Everything except header/footer is a text element
     * (HTML content) so there's no per-block schema to get wrong. The post is
     * rendered in flow mode, so it reads as a plain scrolling article.
     */
    private function buildArticleElements(string $title, string $bodyHtml, string $coverUrl, ?array $headerEl, ?array $footerEl): array
    {
        $els = [];
        if ($headerEl) { $headerEl['parent_id'] = null; unset($headerEl['page_id']); $els[] = $headerEl; }
        $els[] = ['id' => 'art_sec', 'type' => 'section', 'parent_id' => null, 'settings' => [
            // No valign / content_align: sections inherit the CENTRED default from
            // the renderer. A generator must never bake an anti-centre value (Sergei
            // 2026-07-16: centred by default, no exceptions). The article body keeps
            // its own text_align:left below, which is the paragraph rule, not this.
            'bg_color' => '#ffffff', 'min_height' => '', 'height_mode' => 'auto',
            'max_content_width' => '760', 'item_space' => '18',
            'padding_top' => '56', 'padding_bottom' => '80', 'padding_left' => '24', 'padding_right' => '24',
        ]];
        if ($coverUrl !== '') {
            $els[] = ['id' => 'art_cover', 'type' => 'text', 'parent_id' => 'art_sec', 'settings' => [
                'content' => '<img src="' . esc_url($coverUrl) . '" alt="" style="width:100%;border-radius:8px;display:block">',
            ]];
        }
        $els[] = ['id' => 'art_title', 'type' => 'text', 'parent_id' => 'art_sec', 'settings' => [
            'content' => '<h1 style="font-size:36px;line-height:1.2;color:#1d2327;margin:0;text-align:left">' . esc_html($title) . '</h1>',
            'color' => '#1d2327', 'align' => 'left', 'text_align' => 'left',
        ]];
        $els[] = ['id' => 'art_body', 'type' => 'text', 'parent_id' => 'art_sec', 'settings' => [
            'content' => '<div style="text-align:left">' . ($bodyHtml !== '' ? $bodyHtml : '<p style="color:#888">Start writing…</p>') . '</div>',
            'color' => '#333333', 'align' => 'left', 'text_align' => 'left',
        ]];
        if ($footerEl) { $footerEl['parent_id'] = null; unset($footerEl['page_id']); $els[] = $footerEl; }
        return $els;
    }

    /** A clean, unique slug for a new post (no timestamp suffix). */
    private function uniquePostSlug(string $title): string
    {
        $base = sanitize_title($title);
        if ($base === '') $base = 'post';
        $p = DB::prefix();
        $slug = $base; $i = 2;
        while (DB::get_var(DB::prepare("SELECT id FROM `{$p}instinctor_pages` WHERE slug = %s LIMIT 1", $slug))) {
            $slug = $base . '-' . $i;
            $i++;
        }
        return $slug;
    }

    /** Canonical header + footer for new posts, cloned from the front page. */
    private function siteHeaderFooter(): array
    {
        $h = $f = null;
        $frontId = (int) Config::get('instinctor_front_page_id', 0);
        $src = $frontId ? PageService::getElements($frontId) : [];
        foreach ($src as $e) {
            if (($e['type'] ?? '') === 'header' && !$h) $h = $e;
            if (($e['type'] ?? '') === 'footer' && !$f) $f = $e;
        }
        return [$h, $f];
    }

    /**
     * Auto-create the /blog index page the first time a post is published.
     * It lists posts via a Posts block and renders in flow (classic, white)
     * mode, so it reads as a clean document. Element ids are NUMERIC because
     * the builder's element routes are numeric-only (a non-numeric id 404s
     * the settings panel). No-op when a /blog page already exists. Once the
     * page exists the site header adds a Blog nav link on its own. Returns
     * the blog page id, or 0 on failure.
     */
    private function ensureBlogIndexPage(): int
    {
        $existing = PageService::getByPublicSlug('blog');
        if ($existing) return (int) ($existing['id'] ?? 0);

        [$hdr, $ftr] = $this->siteHeaderFooter();

        $secId   = 1900000000001;
        $postsId = 1900000000002;

        $els = [];
        if ($hdr) { $hdr['parent_id'] = null; unset($hdr['page_id']); $els[] = $hdr; }
        $els[] = ['id' => $secId, 'type' => 'section', 'parent_id' => null, 'settings' => [
            // Centred default inherited from the renderer: see buildArticleElements().
            'bg_color' => '#ffffff', 'height_mode' => 'auto', 'min_height' => '',
            'max_content_width' => '1080', 'item_space' => '18',
            'padding_top' => '56', 'padding_bottom' => '80', 'padding_left' => '24', 'padding_right' => '24',
        ]];
        $els[] = ['id' => $postsId, 'type' => 'posts', 'parent_id' => $secId, 'settings' => [
            'heading' => 'Blog', 'columns' => 2, 'limit' => 12, 'show_date' => true, 'show_excerpt' => true,
        ]];
        if ($ftr) { $ftr['parent_id'] = null; unset($ftr['page_id']); $els[] = $ftr; }

        $blogId = (int) PageService::create([
            'name' => 'Blog', 'type' => 'page', 'status' => 'published',
            'slug' => 'blog', 'elements' => $els, 'settings' => [],
        ]);
        if (!$blogId) return 0;

        PageService::saveSettings($blogId, ['body_scroll' => 'flow', 'bg_color' => '#ffffff']);
        return $blogId;
    }

    /** Save a blog post from the simple editor (title + cover + body). */
    public function ajaxSavePost(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('edit_pages') && !Auth::can('manage_options')) { wp_send_json_error('Permission denied'); }

        $postId  = (int) ($_POST['post_id'] ?? 0);
        $title   = trim(sanitize_text_field(wp_unslash($_POST['title'] ?? '')));
        $bodyRaw = function_exists('wp_kses_post') ? wp_kses_post(wp_unslash($_POST['body'] ?? '')) : wp_unslash($_POST['body'] ?? '');
        $bodyRaw = $this->externalLinksNewTab((string) $bodyRaw);
        $cover   = esc_url_raw(wp_unslash($_POST['cover'] ?? ''));
        $excerpt = trim(sanitize_text_field(wp_unslash($_POST['excerpt'] ?? '')));
        $status  = (($_POST['status'] ?? 'draft') === 'published') ? 'published' : 'draft';

        if ($title === '') $title = 'Untitled post';
        if ($excerpt === '') {
            $plain = trim(preg_replace('/\s+/', ' ', wp_strip_all_tags((string) $bodyRaw)));
            $excerpt = mb_substr($plain, 0, 160);
        }

        [$hdr, $ftr] = $this->siteHeaderFooter();

        if ($postId > 0) {
            // Preserve the post's own header/footer if it already has them.
            $eh = $ef = null;
            $hasArtBody = false; $storedText = '';
            foreach (PageService::getElements($postId) as $e) {
                if (($e['type'] ?? '') === 'header' && !$eh) $eh = $e;
                if (($e['type'] ?? '') === 'footer' && !$ef) $ef = $e;
                if (($e['id'] ?? '') === 'art_body') $hasArtBody = true;
                if (($e['type'] ?? '') === 'text') {
                    $storedText .= ' ' . wp_strip_all_tags((string) ($e['settings']['content'] ?? ''));
                }
            }
            // Data-loss guard. ajaxGetPost() reads the body ONLY from the element
            // whose id is exactly `art_body`; a post built another way (e.g. by a
            // generator script) loads into the editor with an EMPTY body. Saving
            // then rebuilds the whole tree from that empty state and destroys the
            // article. When the structure is unreadable AND the post still holds
            // real text, refuse rather than overwrite. This never fires for posts
            // authored in the editor (they always carry art_body), so clearing a
            // normal post by hand still works.
            if (!$hasArtBody && mb_strlen(trim($storedText)) > 40) {
                wp_send_json_error(
                    'This post could not be loaded into the editor, so saving was blocked to avoid erasing its content. '
                    . 'The post needs to be repaired before it can be edited here.'
                );
            }
            PageService::saveElements($postId, $this->buildArticleElements($title, (string) $bodyRaw, $cover, $eh ?: $hdr, $ef ?: $ftr));
            $s = PageService::getSettings($postId);
        } else {
            $newId = PageService::create([
                'name' => $title, 'type' => 'post', 'status' => $status,
                'slug' => $this->uniquePostSlug($title),
                'elements' => $this->buildArticleElements($title, (string) $bodyRaw, $cover, $hdr, $ftr),
                'settings' => [],
            ]);
            if (!$newId) { wp_send_json_error('Could not create post'); }
            $postId = (int) $newId;
            $s = [];
        }

        $s['body_scroll']  = 'flow';
        $s['bg_color']     = '#ffffff';
        $s['post_excerpt'] = $excerpt;
        if (empty($s['post_date'])) $s['post_date'] = date('Y-m-d');
        PageService::saveSettings($postId, $s);

        $p = DB::prefix();
        DB::update($p . 'instinctor_pages', ['status' => $status, 'name' => $title], ['id' => $postId]);
        if ($cover !== '') PageService::saveThumbnail($postId, $cover);

        // First published post auto-creates the /blog index page; the site
        // header then surfaces a Blog nav link on its own.
        if ($status === 'published') $this->ensureBlogIndexPage();

        $slug = (string) (PageService::getById($postId)['slug'] ?? '');
        wp_send_json_success(['post_id' => $postId, 'status' => $status, 'url' => '/' . ltrim($slug, '/')]);
    }

    /** Load a post's fields back into the simple editor. */
    public function ajaxGetPost(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('edit_pages') && !Auth::can('manage_options')) { wp_send_json_error('Permission denied'); }
        $postId = (int) ($_POST['post_id'] ?? 0);
        $page = $postId ? PageService::getById($postId) : null;
        if (!$page) { wp_send_json_error('Post not found'); }
        $settings = PageService::getSettings($postId);
        $body = ''; $cover = (string) ($page['thumbnail'] ?? '');
        foreach (PageService::getElements($postId) as $e) {
            if (($e['id'] ?? '') === 'art_body') $body = (string) ($e['settings']['content'] ?? '');
        }
        wp_send_json_success([
            'post_id' => $postId,
            'title'   => (string) ($page['name'] ?? ''),
            'body'    => $body,
            'cover'   => $cover,
            'excerpt' => (string) ($settings['post_excerpt'] ?? ''),
            'status'  => (string) ($page['status'] ?? 'draft'),
        ]);
    }

    /**
     * Open external links in a new tab with rel="noopener noreferrer".
     * Blog posts are authored only by admins, so links are allowed (they're a
     * core part of a useful blog); we just sanitise (wp_kses_post already
     * strips javascript:/data: hrefs) and harden external ones. Internal
     * relative links and same-domain links stay in the same tab.
     */
    private function externalLinksNewTab(string $html): string
    {
        $host = wp_parse_url(function_exists('home_url') ? (string) home_url() : '', PHP_URL_HOST) ?: '';
        return (string) preg_replace_callback('#<a\b([^>]*)>#i', function ($m) use ($host) {
            $attrs = $m[1];
            if (!preg_match('#href\s*=\s*"(https?://[^"]+)"#i', $attrs, $h)) return $m[0]; // not an external absolute URL
            if ($host !== '' && stripos($h[1], '//' . $host) !== false) return $m[0];      // own domain — leave alone
            if (stripos($attrs, 'target=') === false) $attrs .= ' target="_blank"';
            if (stripos($attrs, 'rel=') === false)    $attrs .= ' rel="noopener noreferrer"';
            return '<a' . $attrs . '>';
        }, $html);
    }

    public function ajaxGetAllCoupons(): void
    {
        check_ajax_referer('instinctor_marketing', '_wpnonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        $couponService = new \Instinctor\Services\CouponService();
        $coupons = $couponService->getAllCoupons(['limit' => 200]);
        wp_send_json_success(['coupons' => $coupons]);
    }

    public function ajaxGetCouponDetail(): void
    {
        check_ajax_referer('instinctor_marketing', '_wpnonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $couponId = intval($_POST['coupon_id'] ?? 0);
        if (!$couponId) {
            wp_send_json_error('Invalid coupon');
        }

        $couponService = new \Instinctor\Services\CouponService();
        $coupon = $couponService->getCouponById($couponId);

        if (!$coupon) {
            wp_send_json_error('Coupon not found');
        }

        wp_send_json_success([
            'id'                   => $coupon->id,
            'code'                 => $coupon->code,
            'description'          => $coupon->description ?? '',
            'discount_type'        => $coupon->discount_type,
            'amount'               => $coupon->amount,
            'shipping'        => !empty($coupon->shipping),
            'minimum_amount'       => $coupon->minimum_amount,
            'maximum_amount'       => $coupon->maximum_amount ?? '',
            'usage_limit'          => $coupon->usage_limit,
            'usage_limit_per_user' => $coupon->usage_limit_per_user ?? '',
            'usage_count'          => $coupon->usage_count ?? 0,
            'start_date'           => $coupon->start_date ? date('Y-m-d', strtotime($coupon->start_date)) : '',
            'end_date'             => $coupon->end_date ? date('Y-m-d', strtotime($coupon->end_date)) : '',
            'status'               => $coupon->status,
        ]);
    }

    public function ajaxSaveCoupon(): void
    {
        check_ajax_referer('instinctor_marketing', '_wpnonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $couponService = new \Instinctor\Services\CouponService();
        
        $couponId = intval($_POST['coupon_id'] ?? 0);
        $code = sanitize_text_field($_POST['code'] ?? '');
        
        if (empty($code)) {
            wp_send_json_error(['message' => 'Coupon code is required']);
        }
        
        $couponData = [
            'code'                 => strtoupper($code),
            'description'          => sanitize_textarea_field($_POST['description'] ?? ''),
            'discount_type'        => sanitize_text_field($_POST['discount_type'] ?? 'percent'),
            'amount'               => floatval($_POST['amount'] ?? 0),
            'shipping'        => !empty($_POST['shipping']),
            'minimum_amount'       => !empty($_POST['minimum_amount']) ? floatval($_POST['minimum_amount']) : null,
            'maximum_amount'       => !empty($_POST['maximum_amount']) ? floatval($_POST['maximum_amount']) : null,
            'usage_limit'          => !empty($_POST['usage_limit']) ? intval($_POST['usage_limit']) : null,
            'usage_limit_per_user' => !empty($_POST['usage_limit_per_user']) ? intval($_POST['usage_limit_per_user']) : null,
            'start_date'           => !empty($_POST['start_date']) ? sanitize_text_field($_POST['start_date']) : null,
            'end_date'             => !empty($_POST['end_date']) ? sanitize_text_field($_POST['end_date']) : null,
            'status'               => sanitize_text_field($_POST['status'] ?? 'active'),
        ];

        if ($couponId) {
            $couponService->updateCoupon($couponId, $couponData);
            wp_send_json_success(['message' => 'Coupon updated']);
        } else {
            // Check if code exists
            if ($couponService->getCoupon($couponData['code'])) {
                wp_send_json_error(['message' => 'Coupon code already exists']);
            }
            $couponService->createCoupon($couponData);
            wp_send_json_success(['message' => 'Coupon created']);
        }
    }

    public function ajaxDeleteCoupon(): void
    {
        check_ajax_referer('instinctor_marketing', '_wpnonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $couponId = intval($_POST['coupon_id'] ?? 0);
        if (!$couponId) {
            wp_send_json_error('Invalid coupon');
        }

        $couponService = new \Instinctor\Services\CouponService();
        $couponService->deleteCoupon($couponId);
        
        wp_send_json_success();
    }

    public function ajaxGetGiftCards(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $status = sanitize_text_field($_POST['status'] ?? '');
        $search = sanitize_text_field($_POST['search'] ?? '');

        $args = ['limit' => 100];
        if ($status) $args['status'] = $status;
        if ($search) $args['search'] = $search;

        $cards = \Instinctor\Services\GiftCardService::getAll($args);
        $currency = Config::get('instinctor_currency_symbol', '$');

        $out = [];
        foreach ($cards as $c) {
            $out[] = [
                'id'               => $c->id,
                'code'             => $c->code,
                'initial_balance'  => $currency . number_format($c->initial_balance, 2),
                'current_balance'  => $currency . number_format($c->current_balance, 2),
                'status'           => $c->status,
                'recipient_email'  => $c->recipient_email,
                'recipient_name'   => $c->recipient_name,
                'expires_at'       => $c->expires_at ? wp_date('M j, Y', strtotime($c->expires_at)) : 'Never',
                'is_delivered'     => (bool) $c->is_delivered,
                'created_at'       => wp_date('M j, Y', strtotime($c->created_at)),
            ];
        }

        wp_send_json_success(['cards' => $out]);
    }

    public function ajaxGetGiftCardDetail(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $id = intval($_POST['id'] ?? 0);
        if (!$id) {
            wp_send_json_error('Invalid ID');
        }

        $gc = \Instinctor\Services\GiftCardService::getById($id);
        if (!$gc) {
            wp_send_json_error('Gift card not found');
        }

        $currency = Config::get('instinctor_currency_symbol', '$');
        $transactions = \Instinctor\Services\GiftCardService::getTransactions($id);

        $txOut = [];
        foreach ($transactions as $t) {
            $txOut[] = [
                'type'    => $t->type,
                'amount'  => ($t->amount >= 0 ? '+' : '') . $currency . number_format(abs($t->amount), 2),
                'note'    => $t->note,
                'date'    => wp_date('M j, Y', strtotime($t->created_at)),
            ];
        }

        wp_send_json_success([
            'id'               => $gc->id,
            'code'             => $gc->code,
            'initial_balance'  => $currency . number_format($gc->initial_balance, 2),
            'current_balance'  => $currency . number_format($gc->current_balance, 2),
            'status'           => $gc->status,
            'recipient_email'  => $gc->recipient_email,
            'recipient_name'   => $gc->recipient_name,
            'sender_name'      => $gc->sender_name,
            'message'          => $gc->message,
            'expires_at'       => $gc->expires_at ? wp_date('M j, Y', strtotime($gc->expires_at)) : 'Never',
            'is_delivered'     => (bool) $gc->is_delivered,
            'created_at'       => wp_date('M j, Y', strtotime($gc->created_at)),
            'transactions'     => $txOut,
        ]);
    }

    public function ajaxCreateGiftCard(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $card = \Instinctor\Services\GiftCardService::create([
            'amount'          => floatval($_POST['amount'] ?? 0),
            'recipient_email' => sanitize_email($_POST['recipient_email'] ?? ''),
            'recipient_name'  => sanitize_text_field($_POST['recipient_name'] ?? ''),
            'sender_name'     => sanitize_text_field($_POST['sender_name'] ?? get_bloginfo('name')),
            'message'         => sanitize_textarea_field($_POST['message'] ?? ''),
            'expires_days'    => intval($_POST['expires_days'] ?? 0),
        ]);

        if (!$card) {
            wp_send_json_error(['message' => 'Failed to create gift card — check amount is > 0']);
        }

        if (!empty($_POST['send_now']) && $card->recipient_email) {
            \Instinctor\Services\GiftCardService::sendEmail($card->id);
        }

        wp_send_json_success(['id' => $card->id, 'code' => $card->code]);
    }

    public function ajaxSendGiftCard(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $id = intval($_POST['id'] ?? 0);

        if (\Instinctor\Services\GiftCardService::sendEmail($id)) {
            wp_send_json_success(['message' => 'Gift card sent']);
        } else {
            wp_send_json_error(['message' => 'Failed to send — check recipient email is set']);
        }
    }

    public function ajaxDeactivateGiftCard(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $id = intval($_POST['id'] ?? 0);

        if (\Instinctor\Services\GiftCardService::updateStatus($id, 'cancelled')) {
            wp_send_json_success();
        } else {
            wp_send_json_error(['message' => 'Failed to deactivate']);
        }
    }

    public function ajaxAdjustLoyaltyPoints(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $customerId = intval($_POST['customer_id'] ?? 0);
        $delta      = intval($_POST['delta'] ?? 0);
        $reason     = sanitize_text_field($_POST['reason'] ?? 'Manual adjustment');

        if (!$customerId || $delta === 0) {
            wp_send_json_error('Invalid parameters');
        }

        $pt = DB::prefix() . 'instinctor_loyalty_points';
        $tt = DB::prefix() . 'instinctor_loyalty_transactions';

        // Upsert points row
        $existing = DB::get_row(DB::prepare("SELECT * FROM $pt WHERE customer_id = %d", $customerId));
        if ($existing) {
            $newPoints = max(0, (int)$existing->points + $delta);
            $newLifetime = $delta > 0 ? (int)$existing->lifetime_points + $delta : (int)$existing->lifetime_points;
            DB::update($pt, ['points' => $newPoints, 'lifetime_points' => $newLifetime], ['customer_id' => $customerId]);
        } else {
            $newPoints = max(0, $delta);
            DB::insert($pt, ['customer_id' => $customerId, 'points' => $newPoints, 'lifetime_points' => max(0, $delta)]);
        }

        // Log transaction
        DB::insert($tt, [
            'customer_id' => $customerId,
            'points'      => $delta,
            'type'        => 'adjust',
            'description' => $reason,
            'created_at'  => current_time('mysql'),
        ]);

        wp_send_json_success(['points' => $newPoints]);
    }

    public function ajaxGetLoyaltySettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $settings = [
            'enabled'            => (bool) Config::get('instinctor_loyalty_enabled', false),
            'points_per_dollar'  => floatval(Config::get('instinctor_loyalty_points_per_dollar', 1)),
            'points_value'       => floatval(Config::get('instinctor_loyalty_points_value', 0.01)),
            'min_redeem'         => intval(Config::get('instinctor_loyalty_min_redeem', 100)),
            'signup_points'      => intval(Config::get('instinctor_loyalty_signup_points', 100)),
            'referral_points'    => intval(Config::get('instinctor_loyalty_referral_points', 200)),
            'expiry_days'        => intval(Config::get('instinctor_loyalty_expiry_days', 365)),
        ];

        wp_send_json_success(['settings' => $settings]);
    }

    public function ajaxSaveLoyaltySettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');

        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        Config::set('instinctor_loyalty_enabled',         !empty($_POST['enabled']));
        Config::set('instinctor_loyalty_points_per_dollar', max(0.1, floatval($_POST['points_per_dollar'] ?? 1)));
        Config::set('instinctor_loyalty_points_value',    max(0.001, floatval($_POST['points_value'] ?? 0.01)));
        Config::set('instinctor_loyalty_min_redeem',      max(1, intval($_POST['min_redeem'] ?? 100)));
        Config::set('instinctor_loyalty_signup_points',   max(0, intval($_POST['signup_points'] ?? 100)));
        Config::set('instinctor_loyalty_referral_points', max(0, intval($_POST['referral_points'] ?? 200)));
        Config::set('instinctor_loyalty_expiry_days',     max(0, intval($_POST['expiry_days'] ?? 365)));

        wp_send_json_success(['message' => 'Settings saved']);
    }

    public function ajaxGetLoyaltyStats(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $pt = DB::prefix() . 'instinctor_loyalty_points';
        $tt = DB::prefix() . 'instinctor_loyalty_transactions';

        // Check tables exist before querying
        $pt_exists = DB::get_var("SHOW TABLES LIKE '$pt'") === $pt;
        $tt_exists = DB::get_var("SHOW TABLES LIKE '$tt'") === $tt;

        $members       = $pt_exists ? (int) DB::get_var("SELECT COUNT(*) FROM $pt") : 0;
        $active_points = $pt_exists ? (int) DB::get_var("SELECT COALESCE(SUM(points),0) FROM $pt") : 0;
        $redeemed      = $tt_exists ? (int) DB::get_var("SELECT COALESCE(ABS(SUM(points)),0) FROM $tt WHERE type = 'redeem'") : 0;

        wp_send_json_success([
            'members'       => $members,
            'active_points' => $active_points,
            'redeemed'      => $redeemed,
        ]);
    }

    public function ajaxSaveTracking(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        

        $p = DB::prefix();
        $orderId = intval($_POST['order_id'] ?? 0);
        $trackingNumber = sanitize_text_field($_POST['tracking_number'] ?? '');
        $trackingUrl = esc_url_raw($_POST['tracking_url'] ?? '');
        $carrier = sanitize_text_field($_POST['carrier'] ?? 'other');

        if (!$orderId) {
            wp_send_json_error('Invalid order');
        }

        $trackingTable = DB::prefix() . 'instinctor_order_tracking';

        // Auto-generate tracking URL if carrier provided but no URL
        if ($trackingNumber && empty($trackingUrl) && $carrier !== 'other') {
            $trackingUrl = \Instinctor\Services\OrderTrackingService::generateTrackingUrl($carrier, $trackingNumber);
        }

        if ($trackingNumber) {
            // Upsert: check if tracking row exists for this order
            $existing = DB::get_row(DB::prepare(
                "SELECT id FROM $trackingTable WHERE order_id = %d ORDER BY created_at DESC LIMIT 1",
                $orderId
            ));

            if ($existing) {
                // Update existing tracking row
                DB::update($trackingTable, [
                    'carrier' => $carrier,
                    'tracking_number' => $trackingNumber,
                    'tracking_url' => $trackingUrl,
                    'last_update' => current_time('mysql'),
                ], ['id' => $existing->id]);
                // Sync update to AfterShip if enabled
                if (\Instinctor\Services\AfterShipService::isEnabled() && $trackingNumber) {
                    \Instinctor\Services\AfterShipService::onTrackingCreated($existing->id, [
                        'order_id'        => $orderId,
                        'tracking_number' => $trackingNumber,
                        'carrier'         => $carrier,
                    ]);
                }
            } else {
                // Insert new tracking row
                DB::insert($trackingTable, [
                    'order_id' => $orderId,
                    'carrier' => $carrier,
                    'tracking_number' => $trackingNumber,
                    'tracking_url' => $trackingUrl,
                    'status' => 'in_transit',
                    'shipped_at' => current_time('mysql'),
                ]);
                $newId = DB::insert_id();
                // Register new tracking with AfterShip if enabled
                if (\Instinctor\Services\AfterShipService::isEnabled() && $newId) {
                    \Instinctor\Services\AfterShipService::onTrackingCreated($newId, [
                        'order_id'        => $orderId,
                        'tracking_number' => $trackingNumber,
                        'carrier'         => $carrier,
                    ]);
                }
            }
        } else {
            // Tracking number cleared — remove tracking rows
            DB::delete($trackingTable, ['order_id' => $orderId]);
        }

        // Sync to orders.meta for quick admin reads and email templates
        $order = DB::get_row(DB::prepare(
            "SELECT meta FROM {$p}instinctor_orders WHERE id = %d",
            $orderId
        ));

        $meta = json_decode($order->meta ?? '{}', true) ?: [];
        $meta['tracking_number'] = $trackingNumber;
        $meta['tracking_url'] = $trackingUrl;
        $meta['tracking_carrier'] = $carrier;

        DB::update(
            DB::prefix() . 'instinctor_orders',
            ['meta' => json_encode($meta)],
            ['id' => $orderId]
        );

        // Add note
        if ($trackingNumber) {
            \Instinctor\Services\OrderNoteService::addSystemNote(
                $orderId,
                /* translators: %1\$s: tracking number, %2\$s: carrier name */
                sprintf(__('Tracking updated: %1\$s (%2\$s)', 'instinctor'), $trackingNumber, strtoupper($carrier))
            );
        }

        wp_send_json_success();
    }

    public function ajaxSendTrackingEmail(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        

        $p = DB::prefix();
        $orderId = intval($_POST['order_id'] ?? 0);

        $order = DB::get_row(DB::prepare(
            "SELECT * FROM {$p}instinctor_orders WHERE id = %d",
            $orderId
        ));

        if (!$order) {
            wp_send_json_error('Order not found');
        }

        // Get tracking info from tracking table (primary) with meta fallback
        $trackingRow = DB::get_row(DB::prepare(
            "SELECT carrier, tracking_number, tracking_url FROM {$p}instinctor_order_tracking WHERE order_id = %d ORDER BY created_at DESC LIMIT 1",
            $orderId
        ));
        
        if ($trackingRow && $trackingRow->tracking_number) {
            $trackingNumber = $trackingRow->tracking_number;
            $trackingUrl = $trackingRow->tracking_url ?? '';
        } else {
            $meta = json_decode($order->meta ?? '{}', true);
            $trackingNumber = $meta['tracking_number'] ?? '';
            $trackingUrl = $meta['tracking_url'] ?? '';
        }

        if (!$trackingNumber) {
            wp_send_json_error('No tracking number set');
        }

        $billing = json_decode($order->billing_address, true) ?: [];
        $customerName = trim(($billing['first_name'] ?? '') . ' ' . ($billing['last_name'] ?? ''));
        
        // Get customer email from billing address or customer record
        $customerEmail = $billing['email'] ?? '';
        if (empty($customerEmail) && $order->customer_id) {
            $customerEmail = DB::get_var(DB::prepare(
                "SELECT email FROM {$p}instinctor_customers WHERE id = %d",
                $order->customer_id
            )) ?: '';
        }
        
        if (empty($customerEmail)) {
            wp_send_json_error('No customer email found');
        }

        // Send email using EmailService
        $result = \Instinctor\Services\EmailService::send('order_shipped', $customerEmail, [
            'order_number' => $order->order_number,
            'customer_name' => $customerName,
            'customer_first_name' => $billing['first_name'] ?? '',
            'tracking_number' => $trackingNumber,
            'tracking_url' => $trackingUrl,
            'store_name' => get_bloginfo('name'),
            'store_url' => home_url(),
        ]);

        if ($result) {
            \Instinctor\Services\OrderNoteService::addSystemNote(
                $orderId,
                __('Shipping notification email sent to customer', 'instinctor')
            );
            wp_send_json_success();
        } else {
            wp_send_json_error('Failed to send email');
        }
    }

    public function ajaxProcessRefund(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }


        $p = DB::prefix();
        $orderId = intval($_POST['order_id'] ?? 0);
        $amount = floatval($_POST['amount'] ?? 0);
        $reason = sanitize_text_field($_POST['reason'] ?? '');
        $restoreStock = !empty($_POST['restore_stock']);
        $sendEmail = !isset($_POST['send_email']) || !empty($_POST['send_email']); // Default true

        if (!$orderId || $amount <= 0) {
            wp_send_json_error('Invalid order or amount');
        }

        $order = DB::get_row(DB::prepare(
            "SELECT o.*, c.email, c.first_name, c.last_name 
             FROM {$p}instinctor_orders o
             LEFT JOIN {$p}instinctor_customers c ON o.customer_id = c.id
             WHERE o.id = %d",
            $orderId
        ));

        if (!$order) {
            wp_send_json_error('Order not found');
        }

        // Check refundable amount
        $totalRefunded = \Instinctor\Services\RefundService::getOrderTotalRefunded($orderId);
        $refundable = $order->total - $totalRefunded;

                if ($amount > $refundable) {
            /* translators: %s: maximum refundable amount */
            wp_send_json_error(sprintf(__('Amount exceeds refundable amount ($%s)', 'instinctor'), number_format($refundable, 2)));
        }

        // Get line items if restoring stock
        $lineItems = [];
        if ($restoreStock) {
            $items = DB::get_results(DB::prepare(
                "SELECT product_id, quantity FROM {$p}instinctor_order_items WHERE order_id = %d",
                $orderId
            ));
            foreach ($items as $item) {
                $lineItems[] = [
                    'product_id' => $item->product_id,
                    'quantity' => $item->quantity,
                ];
            }
        }

        // Try Stripe refund if payment was via Stripe
        if ($order->payment_method === 'stripe' && !empty($order->payment_intent_id)) {
            $result = \Instinctor\Services\RefundService::processStripeRefund($orderId, $amount, $reason);
            if (!$result['success']) {
                wp_send_json_error($result['error']);
            }
        } else {
            // Manual refund (no payment gateway)
            \Instinctor\Services\RefundService::createRefund([
                'order_id' => $orderId,
                'amount' => $amount,
                'reason' => $reason,
                'restore_stock' => $restoreStock,
                'line_items' => $lineItems,
            ]);
        }
        
        // Add order note
        \Instinctor\Services\OrderNoteService::addSystemNote(
            $orderId,
            /* translators: %1\$s: refund amount, %2\$s: refund reason */
            sprintf(__('Refund of %1\$s processed. Reason: %2\$s', 'instinctor'), 
                \Instinctor\Services\CurrencyService::formatPrice($amount),
                $reason ?: 'Not specified'
            )
        );
        
        // Send refund notification email
        $customerEmail = $order->email ?: '';
        if (empty($customerEmail)) {
            $billing = json_decode($order->billing_address ?? '{}', true);
            $customerEmail = $billing['email'] ?? '';
        }
        if ($sendEmail && $customerEmail) {
            $customerName = trim(($order->first_name ?: '') . ' ' . ($order->last_name ?: ''));
            if (empty($customerName)) {
                $billing = $billing ?? json_decode($order->billing_address ?? '{}', true);
                $customerName = trim(($billing['first_name'] ?? '') . ' ' . ($billing['last_name'] ?? ''));
            }
            
            // Get order items for email
            $items = DB::get_results(DB::prepare(
                "SELECT * FROM {$p}instinctor_order_items WHERE order_id = %d",
                $orderId
            ));
            $orderDetails = $this->buildOrderDetailsHtml($items, $order);
            
            \Instinctor\Services\EmailService::send('order_refunded', $customerEmail, [
                'order_number' => $order->order_number,
                'order_date' => date_i18n(\Instinctor\Core\Config::get('date_format', 'Y-m-d'), strtotime($order->created_at)),
                'order_total' => \Instinctor\Services\CurrencyService::formatPrice($order->total),
                'order_details' => $orderDetails,
                'refund_amount' => \Instinctor\Services\CurrencyService::formatPrice($amount),
                'customer_name' => $customerName ?: 'Customer',
                'customer_first_name' => $order->first_name ?: ($billing['first_name'] ?? 'Customer'),
                'customer_email' => $customerEmail,
            ]);
        }

        wp_send_json_success(['message' => 'Refund processed' . ($sendEmail ? ' and email sent' : '')]);
    }
    
    /**
     * AJAX: Update order status and send appropriate email
     */
    public function ajaxUpdateOrderStatus(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        

        $p = DB::prefix();
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        
        $orderId = intval($_POST['order_id'] ?? 0);
        $newStatus = sanitize_text_field($_POST['status'] ?? '');
        $sendEmail = !empty($_POST['send_email']);
        
        $validStatuses = ['pending', 'processing', 'shipped', 'completed', 'cancelled', 'refunded', 'on-hold'];
        
        if (!$orderId || !in_array($newStatus, $validStatuses)) {
            wp_send_json_error(['message' => 'Invalid order or status']);
        }

        $order = DB::get_row(DB::prepare(
            "SELECT o.*, c.email, c.first_name, c.last_name 
             FROM {$p}instinctor_orders o
             LEFT JOIN {$p}instinctor_customers c ON o.customer_id = c.id
             WHERE o.id = %d",
            $orderId
        ));

        if (!$order) {
            wp_send_json_error(['message' => 'Order not found']);
        }
        
        $oldStatus = $order->status;
        
        // Update status
        DB::update(
            DB::prefix() . 'instinctor_orders',
            ['status' => $newStatus, 'updated_at' => current_time('mysql')],
            ['id' => $orderId]
        );
        
        // Add order note
        \Instinctor\Services\OrderNoteService::addSystemNote(
            $orderId,
            /* translators: %1\$s: old status, %2\$s: new status */
            sprintf(__('Order status changed from %1\$s to %2\$s', 'instinctor'), $oldStatus, $newStatus)
        );
        
        // Fire action hook for other services to react
        do_action('instinctor_order_status_changed', $orderId, $oldStatus, $newStatus);

        // Complete an offline order (COD / bank transfer) when the owner confirms
        // payment by moving it out of Pending. finalizeOrder runs stock, loyalty,
        // coupons, and bookings; it is idempotent (skips if already finalized at
        // checkout), so this is a no-op for orders that were paid online. Limited
        // to processing/completed because finalizeOrder normalizes any other
        // status back to processing.
        if ($oldStatus === 'pending' && in_array($newStatus, ['processing', 'completed'], true)) {
            try {
                $co = new \Instinctor\Services\CheckoutService(new \Instinctor\Services\CartService());
                $co->finalizeOrder($orderId);
            } catch (\Throwable $e) {
                // Best-effort; the status change is already saved.
            }
        }

        if ($newStatus === 'completed') {
            do_action('instinctor_order_completed', $orderId);
        }
        if (in_array($newStatus, ['processing', 'completed']) && !in_array($oldStatus, ['processing', 'completed'])) {
            do_action('instinctor_order_paid', $orderId);
        }
        
        // Send email if requested — wrapped so email failure doesn't block status update
        $emailSent = false;
        if ($sendEmail && $order->email) {
            try {
                $this->sendOrderStatusEmail($order, $newStatus);
                $emailSent = true;
            } catch (\Throwable $e) {
                // Email failed but status was already updated
            }
        }
        
        wp_send_json_success(['message' => 'Status updated', 'status' => $newStatus, 'email_sent' => $emailSent]);
    }
    
    /**
     * Send order status change email
     */
    private function sendOrderStatusEmail(object $order, string $status): void
    {
        $p = DB::prefix();
        
        $items = DB::get_results(DB::prepare(
            "SELECT * FROM {$p}instinctor_order_items WHERE order_id = %d",
            $order->id
        ));
        
        $orderDetails = $this->buildOrderDetailsHtml($items, $order);
        $customerName = trim($order->first_name . ' ' . $order->last_name);
        
        $emailData = [
            'order_number' => $order->order_number,
            'order_date' => date_i18n(\Instinctor\Core\Config::get('date_format', 'Y-m-d'), strtotime($order->created_at)),
            'order_total' => \Instinctor\Services\CurrencyService::formatPrice($order->total),
            'order_details' => $orderDetails,
            'customer_name' => $customerName,
            'customer_first_name' => $order->first_name,
            'customer_email' => $order->email,
        ];
        
        // Map status to email template
        $templateMap = [
            'processing' => 'order_processing',
            'shipped' => 'order_shipped',
            'completed' => 'order_completed',
            'cancelled' => 'order_cancelled',
            'refunded' => 'order_refunded',
        ];
        
        if (isset($templateMap[$status])) {
            // Add tracking info for shipped status
            if ($status === 'shipped') {
                // Read from tracking table (primary) with meta fallback
                $trackingRow = DB::get_row(DB::prepare(
                    "SELECT tracking_number, tracking_url FROM {$p}instinctor_order_tracking WHERE order_id = %d ORDER BY created_at DESC LIMIT 1",
                    $order->id
                ));
                if ($trackingRow && $trackingRow->tracking_number) {
                    $trackingNumber = $trackingRow->tracking_number;
                    $trackingUrl = $trackingRow->tracking_url ?? '';
                } else {
                    $meta = json_decode($order->meta ?? '{}', true);
                    $trackingNumber = $meta['tracking_number'] ?? '';
                    $trackingUrl = $meta['tracking_url'] ?? '';
                }
                
                $trackingInfo = '';
                if ($trackingNumber) {
                    $trackingInfo = __('Tracking Number:', 'instinctor') . ' ' . $trackingNumber;
                    if ($trackingUrl) {
                        $trackingInfo .= "\n" . __('Track your package:', 'instinctor') . ' ' . $trackingUrl;
                    }
                }
                $emailData['tracking_number'] = $trackingNumber;
                $emailData['tracking_url'] = $trackingUrl;
                $emailData['tracking_info'] = $trackingInfo;
            }
            
            \Instinctor\Services\EmailService::send($templateMap[$status], $order->email, $emailData);
        }
    }
    
    /**
     * Build order details HTML for emails
     */
    private function buildOrderDetailsHtml(array $items, object $order): string
    {
        $html = '<table style="width:100%;border-collapse:collapse;margin:15px 0">';
        $html .= '<tr style="background:#f5f5f5"><th style="padding:10px;text-align:left;border-bottom:1px solid #ddd">Item</th><th style="padding:10px;text-align:center;border-bottom:1px solid #ddd">Qty</th><th style="padding:10px;text-align:right;border-bottom:1px solid #ddd">Price</th></tr>';
        
        foreach ($items as $item) {
            $html .= '<tr>';
            $html .= '<td style="padding:10px;border-bottom:1px solid #eee">' . esc_html($item->name);
            
            $itemMeta = json_decode($item->meta ?? '{}', true);
            if (!empty($itemMeta['variation_name'])) {
                $html .= '<br><small style="color:#666">' . esc_html($itemMeta['variation_name']) . '</small>';
            }
            
            $html .= '</td>';
            $html .= '<td style="padding:10px;text-align:center;border-bottom:1px solid #eee">' . intval($item->quantity) . '</td>';
            $html .= '<td style="padding:10px;text-align:right;border-bottom:1px solid #eee">' . \Instinctor\Services\CurrencyService::formatPrice($item->total) . '</td>';
            $html .= '</tr>';
        }
        
        $html .= '<tr><td colspan="2" style="padding:10px;text-align:right"><strong>Subtotal</strong></td><td style="padding:10px;text-align:right">' . \Instinctor\Services\CurrencyService::formatPrice($order->subtotal) . '</td></tr>';
        
        if ($order->shipping_total > 0) {
            $html .= '<tr><td colspan="2" style="padding:10px;text-align:right">Shipping</td><td style="padding:10px;text-align:right">' . \Instinctor\Services\CurrencyService::formatPrice($order->shipping_total) . '</td></tr>';
        }
        
        if ($order->tax_total > 0) {
            $html .= '<tr><td colspan="2" style="padding:10px;text-align:right">Tax</td><td style="padding:10px;text-align:right">' . \Instinctor\Services\CurrencyService::formatPrice($order->tax_total) . '</td></tr>';
        }
        
        if ($order->discount_total > 0) {
            $html .= '<tr><td colspan="2" style="padding:10px;text-align:right;color:#16a34a">Discount</td><td style="padding:10px;text-align:right;color:#16a34a">-' . \Instinctor\Services\CurrencyService::formatPrice($order->discount_total) . '</td></tr>';
        }
        
        $html .= '<tr style="background:#f5f5f5"><td colspan="2" style="padding:10px;text-align:right"><strong>Total</strong></td><td style="padding:10px;text-align:right"><strong>' . \Instinctor\Services\CurrencyService::formatPrice($order->total) . '</strong></td></tr>';
        $html .= '</table>';
        
        return $html;
    }

    public function ajaxSaveCurrency(): void
    {
        check_ajax_referer('instinctor_currency', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $data = [
            'code' => sanitize_text_field($_POST['code'] ?? ''),
            'name' => sanitize_text_field($_POST['name'] ?? ''),
            'symbol' => sanitize_text_field($_POST['symbol'] ?? ''),
            'exchange_rate' => floatval($_POST['exchange_rate'] ?? 1),
            'decimals' => intval($_POST['decimals'] ?? 2),
            'symbol_position' => sanitize_text_field($_POST['symbol_position'] ?? 'left'),
            'thousand_separator' => sanitize_text_field($_POST['thousand_separator'] ?? ','),
            'decimal_separator' => sanitize_text_field($_POST['decimal_separator'] ?? '.'),
            'enabled' => !empty($_POST['enabled']),
            'is_default' => !empty($_POST['is_default']),
            'auto_update' => !empty($_POST['auto_update']),
            'sort_order' => intval($_POST['sort_order'] ?? 0),
        ];

        if (empty($data['code']) || strlen($data['code']) !== 3) {
            wp_send_json_error(['message' => 'Invalid currency code']);
        }
        
        // Block Russian Ruble
        if (strtoupper($data['code']) === 'RUB') {
            wp_send_json_error(['message' => 'This currency is not supported']);
        }

        $id = \Instinctor\Services\CurrencyService::saveCurrency($data);
        
        wp_send_json_success(['id' => $id]);
    }

    public function ajaxDeleteCurrency(): void
    {
        check_ajax_referer('instinctor_currency', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

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

        $success = \Instinctor\Services\CurrencyService::deleteCurrency($id);
        
        if (!$success) {
            wp_send_json_error(['message' => 'Cannot delete the default currency']);
        }

        wp_send_json_success();
    }

    public function ajaxUpdateExchangeRates(): void
    {
        check_ajax_referer('instinctor_currency', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $success = \Instinctor\Services\CurrencyService::updateExchangeRates();
        
        if (!$success) {
            wp_send_json_error(['message' => 'Failed to update rates. Check your API key.']);
        }

        wp_send_json_success(['message' => 'Exchange rates updated successfully']);
    }

    public function ajaxSaveOption(): void
    {
        check_ajax_referer('instinctor_currency', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $option = sanitize_text_field($_POST['option'] ?? '');
        $value = sanitize_text_field($_POST['value'] ?? '');
        
        // Only allow specific options
        $allowedOptions = [
            'instinctor_exchange_rate_api',
            'instinctor_auto_detect_currency',
        ];
        
        if (!in_array($option, $allowedOptions)) {
            wp_send_json_error(['message' => 'Invalid option']);
        }

        update_option($option, $value);
        
        wp_send_json_success();
    }

    /**
     * AJAX: Toggle email template enabled status
     */
    /**
     * Intercept WordPress password reset email when Instinctor template is enabled.
     * Sends via relay and suppresses WP's own email to avoid duplicates.
     */
    public function handlePasswordResetEmail(string $message, string $key, string $userLogin, \WP_User $userData): string
    {
        $template = \Instinctor\Services\EmailService::getTemplate('customer_reset_password');
        if (empty($template['enabled'])) {
            return $message; // Let WordPress handle it normally
        }

        $resetLink = network_site_url(
            'wp-login.php?action=rp&key=' . $key . '&login=' . rawurlencode($userLogin),
            'login'
        );

        \Instinctor\Services\EmailService::send('customer_reset_password', $userData->user_email, [
            'customer_name'  => $userData->display_name,
            'customer_email' => $userData->user_email,
            'reset_link'     => $resetLink,
        ]);

        // Suppress WordPress's own email by clearing recipients in the next wp_mail call
        add_action('phpmailer_init', function (\PHPMailer\PHPMailer\PHPMailer $phpmailer): void {
            static $suppressed = false;
            if (!$suppressed) {
                $phpmailer->clearAllRecipients();
                $suppressed = true;
            }
        });

        return $message;
    }

    public function ajaxToggleEmailTemplate(): void
    {
        check_ajax_referer('instinctor_toggle_email', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $type = sanitize_text_field($_POST['type'] ?? '');
        $enabled = !empty($_POST['enabled']);
        
        if (empty($type)) {
            wp_send_json_error('Invalid template type');
        }
        
        $templates = Config::get('instinctor_email_templates', []);
        if (!isset($templates[$type])) {
            $templates[$type] = [];
        }
        $templates[$type]['enabled'] = $enabled;
        Config::set('instinctor_email_templates', $templates);
        
        wp_send_json_success();
    }

    /**
     * AJAX: Save email footer settings
     */
    public function ajaxSaveFooterSettings(): void
    {
        check_ajax_referer('instinctor_save_footer', 'instinctor_footer_nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $settings = [
            'show_store_name' => isset($_POST['footer_store_name']),
            'show_address' => isset($_POST['footer_address']),
            'show_city_state_zip' => isset($_POST['footer_city_state_zip']),
            'show_country' => isset($_POST['footer_country']),
            'show_email' => isset($_POST['footer_email']),
            'show_phone' => isset($_POST['footer_phone']),
            'show_social' => isset($_POST['footer_social']),
            'custom_text' => sanitize_text_field($_POST['footer_custom_text'] ?? ''),
            // Styling options
            'text_align' => sanitize_text_field($_POST['footer_text_align'] ?? 'center'),
            'show_logo' => isset($_POST['footer_show_logo']),
            'store_name_size' => sanitize_text_field($_POST['footer_store_name_size'] ?? '14'),
            'store_name_weight' => sanitize_text_field($_POST['footer_store_name_weight'] ?? '600'),
            'address_size' => sanitize_text_field($_POST['footer_address_size'] ?? '12'),
            'address_weight' => sanitize_text_field($_POST['footer_address_weight'] ?? '400'),
            'contact_size' => sanitize_text_field($_POST['footer_contact_size'] ?? '12'),
            'contact_weight' => sanitize_text_field($_POST['footer_contact_weight'] ?? '400'),
            'custom_size' => sanitize_text_field($_POST['footer_custom_size'] ?? '11'),
            'custom_weight' => sanitize_text_field($_POST['footer_custom_weight'] ?? '400'),
        ];
        
        \Instinctor\Services\EmailService::saveFooterSettings($settings);
        
        wp_send_json_success($settings);
    }

    /**
     * AJAX: Newsletter subscription (public)
     */
    public function ajaxNewsletterSubscribe(): void
    {
        // Public-facing form — verify the frontend nonce minted via wp_create_nonce('instinctor_frontend')
        // and exposed in ImpulseConfig.nonce. Prevents drive-by signups from foreign origins.
        check_ajax_referer('instinctor_frontend', 'nonce');
        $email = sanitize_email(wp_unslash($_POST['email'] ?? ''));
        
        if (!is_email($email)) {
            wp_send_json_error(['message' => 'Invalid email address']);
        }
        
        // Get existing subscribers
        $subscribers = Config::get('instinctor_newsletter_subscribers', []);
        
        // Check if already subscribed
        if (in_array($email, $subscribers)) {
            wp_send_json_success(['message' => 'Already subscribed']);
        }
        
        // Add subscriber
        $subscribers[] = $email;
        Config::set('instinctor_newsletter_subscribers', $subscribers);
        
        // Hook for integrations (Mailchimp, etc.)
        do_action('instinctor_newsletter_subscribed', $email);
        
        wp_send_json_success(['message' => 'Successfully subscribed']);
    }

    // v1.3.0 AJAX Handlers

    public function ajaxAddTracking(): void
    {
        check_ajax_referer('instinctor_tracking', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $id = \Instinctor\Services\OrderTrackingService::addTracking([
            'order_id' => intval($_POST['order_id'] ?? 0),
            'carrier' => sanitize_text_field($_POST['carrier'] ?? ''),
            'tracking_number' => sanitize_text_field($_POST['tracking_number'] ?? ''),
            'tracking_url' => esc_url_raw($_POST['tracking_url'] ?? ''),
            'estimated_delivery' => sanitize_text_field($_POST['estimated_delivery'] ?? ''),
            'notify_customer' => !empty($_POST['notify_customer']),
        ]);

        if (!$id) {
            wp_send_json_error(['message' => 'Failed to add tracking']);
        }

        wp_send_json_success(['id' => $id]);
    }

    public function ajaxUpdateTrackingStatus(): void
    {
        check_ajax_referer('instinctor_tracking', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $id = intval($_POST['id'] ?? 0);
        $status = sanitize_text_field($_POST['status'] ?? '');

        if (\Instinctor\Services\OrderTrackingService::updateStatus($id, $status)) {
            wp_send_json_success();
        } else {
            wp_send_json_error(['message' => 'Failed to update status']);
        }
    }

    public function ajaxDeleteTracking(): void
    {
        check_ajax_referer('instinctor_tracking', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $id = intval($_POST['id'] ?? 0);

        if (\Instinctor\Services\OrderTrackingService::delete($id)) {
            wp_send_json_success();
        } else {
            wp_send_json_error(['message' => 'Failed to delete tracking']);
        }
    }

    public function ajaxUpdateStock(): void
    {
        check_ajax_referer('instinctor_inventory', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

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

        if (\Instinctor\Services\InventoryService::setStock($productId, $quantity)) {
            wp_send_json_success();
        } else {
            wp_send_json_error(['message' => 'Failed to update stock']);
        }
    }

    public function ajaxExportInventory(): void
    {
        check_ajax_referer('instinctor_inventory', 'nonce');
        

        $p = DB::prefix();
        if (!Auth::can('manage_options')) {
            wp_die('Permission denied');
        }

        $products = DB::get_results(
            "SELECT id, name, sku, price, stock_quantity, stock_status 
             FROM {$p}instinctor_products 
             WHERE status = 'published' 
             ORDER BY name"
        );

        header('Content-Type: text/csv');
        header('Content-Disposition: attachment; filename="inventory-' . date('Y-m-d') . '.csv"');

        $output = fopen('php://output', 'w');
        fputcsv($output, ['ID', 'Name', 'SKU', 'Price', 'Stock Quantity', 'Stock Status']);

        foreach ($products as $p) {
            fputcsv($output, [
                $p->id,
                $p->name,
                $p->sku,
                $p->price,
                $p->stock_quantity,
                $p->stock_status,
            ]);
        }

        fclose($output);
        exit;
    }

    public function ajaxUpdateRMAStatus(): void
    {
        check_ajax_referer('instinctor_rma', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        $id     = intval($_POST['id'] ?? 0);
        $status = sanitize_text_field($_POST['status'] ?? '');
        if (\Instinctor\Services\RMAService::updateStatus($id, $status, Auth::userId())) {
            wp_send_json_success();
        } else {
            wp_send_json_error(['message' => 'Failed to update status']);
        }
    }

    public function ajaxGetRMAs(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) wp_send_json_error('Permission denied');
        $p = DB::prefix();
        $args = [
            'status' => sanitize_text_field($_POST['status'] ?? ''),
            'search' => sanitize_text_field($_POST['search'] ?? ''),
            'limit'  => 50,
        ];
        $rmas  = \Instinctor\Services\RMAService::getAll($args);

        // Enrich with order_number and customer name
        foreach ($rmas as &$rma) {
            if ($rma->order_id) {
                $orderRow = DB::get_row(DB::prepare(
                    "SELECT o.order_number, c.first_name, c.last_name, c.email
                     FROM {$p}instinctor_orders o
                     LEFT JOIN {$p}instinctor_customers c ON o.customer_id = c.id
                     WHERE o.id = %d",
                    $rma->order_id
                ));
                $rma->order_number = $orderRow->order_number ?? ('#' . $rma->order_id);
                $rma->customer_name = $orderRow ? trim(($orderRow->first_name ?: '') . ' ' . ($orderRow->last_name ?: '')) : '';
                $rma->customer_email = $orderRow->email ?? '';
            }
        }
        unset($rma);

        // Stats from unfiltered data
        $allStats = DB::get_results("SELECT status, COUNT(*) as cnt FROM {$p}instinctor_rma GROUP BY status");
        $stats = ['pending' => 0, 'approved' => 0, 'shipped' => 0, 'received' => 0, 'refunded' => 0, 'completed' => 0, 'rejected' => 0, 'total' => 0];
        foreach ($allStats as $row) {
            $stats[$row->status] = (int)$row->cnt;
            $stats['total'] += (int)$row->cnt;
        }

        wp_send_json_success(['rmas' => $rmas, 'stats' => $stats]);
    }

    public function ajaxGetRMA(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) wp_send_json_error('Permission denied');
        $p = DB::prefix();
        $id  = intval($_POST['id'] ?? 0);
        $rma = \Instinctor\Services\RMAService::getById($id);
        if (!$rma) wp_send_json_error('Not found');

        // Enrich with order_number and customer
        if ($rma->order_id) {
            $orderRow = DB::get_row(DB::prepare(
                "SELECT o.order_number, c.first_name, c.last_name, c.email, c.id as customer_id
                 FROM {$p}instinctor_orders o
                 LEFT JOIN {$p}instinctor_customers c ON o.customer_id = c.id
                 WHERE o.id = %d",
                $rma->order_id
            ));
            $rma->order_number = $orderRow->order_number ?? ('#' . $rma->order_id);
            $rma->customer_name = $orderRow ? trim(($orderRow->first_name ?: '') . ' ' . ($orderRow->last_name ?: '')) : '';
            $rma->customer_email = $orderRow->email ?? '';
            $rma->customer_id = $orderRow->customer_id ?? null;
        }

        wp_send_json_success(['rma' => $rma, 'currency' => Config::get('instinctor_currency_symbol', '$')]);
    }

    public function ajaxApproveRMA(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) wp_send_json_error('Permission denied');
        $id            = intval($_POST['id'] ?? 0);
        $refundAmount  = floatval($_POST['refund_amount'] ?? 0);
        $restockingFee = floatval($_POST['restocking_fee'] ?? 0);
        $notes         = sanitize_textarea_field($_POST['notes'] ?? '');
        if (\Instinctor\Services\RMAService::approve($id, $refundAmount, $restockingFee, $notes ?: null)) {
            wp_send_json_success(['rma' => \Instinctor\Services\RMAService::getById($id), 'currency' => Config::get('instinctor_currency_symbol', '$')]);
        } else {
            wp_send_json_error('Failed to approve');
        }
    }

    public function ajaxRejectRMA(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) wp_send_json_error('Permission denied');
        $id     = intval($_POST['id'] ?? 0);
        $reason = sanitize_textarea_field($_POST['reason'] ?? '');
        if (\Instinctor\Services\RMAService::reject($id, $reason)) {
            wp_send_json_success();
        } else {
            wp_send_json_error('Failed to reject');
        }
    }

    /**
     * AJAX: Immediate refund from approved RMA — skips shipped/received steps.
     * Hits Stripe API if order was paid via Stripe, then marks RMA as refunded.
     */
    public function ajaxRMARefundNow(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) wp_send_json_error('Permission denied');

        $p  = DB::prefix();
        $id = intval($_POST['id'] ?? 0);
        $rma = \Instinctor\Services\RMAService::getById($id);

        if (!$rma || $rma->status !== 'approved') {
            wp_send_json_error('RMA must be in approved status to refund');
        }

        if (!$rma->refund_amount || $rma->refund_amount <= 0) {
            wp_send_json_error('No refund amount set');
        }

        // Get order to check payment method
        $order = DB::get_row(DB::prepare(
            "SELECT payment_method, payment_intent_id, order_number FROM {$p}instinctor_orders WHERE id = %d",
            $rma->order_id
        ));

        $refundType = $rma->refund_type ?? 'original';

        if ($refundType === 'store_credit') {
            // Store credit path — processRefund handles gift card creation + status update
            if (!\Instinctor\Services\RMAService::processRefund($id)) {
                wp_send_json_error('Failed to process store credit');
            }
        } elseif ($order && $order->payment_method === 'stripe' && !empty($order->payment_intent_id)) {
            // Stripe path — processStripeRefund already creates the DB refund record
            $result = \Instinctor\Services\RefundService::processStripeRefund(
                $rma->order_id,
                $rma->refund_amount,
                'RMA: ' . ($rma->rma_number ?? $id)
            );
            if (!$result['success']) {
                wp_send_json_error($result['error'] ?? 'Stripe refund failed');
            }
            // Just update RMA status — DB record already created by processStripeRefund
            \Instinctor\Services\RMAService::updateStatus($id, 'refunded', \Instinctor\Core\Auth::userId());
        } else {
            // Manual/non-Stripe path — processRefund creates the DB record + status update
            if (!\Instinctor\Services\RMAService::processRefund($id)) {
                wp_send_json_error('Failed to process refund');
            }
        }

        // Add order note
        \Instinctor\Services\OrderNoteService::addSystemNote(
            $rma->order_id,
            sprintf('Immediate refund of %s processed via RMA %s',
                \Instinctor\Services\CurrencyService::formatPrice($rma->refund_amount),
                $rma->rma_number ?? $id
            )
        );

        // Return updated RMA
        $updated = \Instinctor\Services\RMAService::getById($id);
        if ($updated && $updated->order_id) {
            $orderRow = DB::get_row(DB::prepare(
                "SELECT o.order_number, c.first_name, c.last_name, c.email, c.id as customer_id
                 FROM {$p}instinctor_orders o LEFT JOIN {$p}instinctor_customers c ON o.customer_id = c.id
                 WHERE o.id = %d", $updated->order_id
            ));
            $updated->order_number = $orderRow->order_number ?? '';
            $updated->customer_name = $orderRow ? trim(($orderRow->first_name ?: '') . ' ' . ($orderRow->last_name ?: '')) : '';
            $updated->customer_email = $orderRow->email ?? '';
            $updated->customer_id = $orderRow->customer_id ?? null;
        }

        wp_send_json_success(['rma' => $updated, 'currency' => Config::get('instinctor_currency_symbol', '$')]);
    }

    public function ajaxGetRMASettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) wp_send_json_error('Permission denied');
        wp_send_json_success(['settings' => [
            'return_days'       => intval(Config::get('instinctor_return_days', 30)),
            'restocking_fee'    => floatval(Config::get('instinctor_restocking_fee', 0)),
            'notify_admin'      => (bool) Config::get('instinctor_rma_notify_admin', true),
            'auto_approve'      => (bool) Config::get('instinctor_rma_auto_approve', false),
        ]]);
    }

    public function ajaxSaveRMASettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) wp_send_json_error('Permission denied');
        Config::set('instinctor_return_days',       max(1, intval($_POST['return_days'] ?? 30)));
        Config::set('instinctor_restocking_fee',    max(0, floatval($_POST['restocking_fee'] ?? 0)));
        Config::set('instinctor_rma_notify_admin',  !empty($_POST['notify_admin']));
        Config::set('instinctor_rma_auto_approve',  !empty($_POST['auto_approve']));
        wp_send_json_success();
    }

    public function ajaxGetTrackingSettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) wp_send_json_error('Permission denied');
        $aftership = \Instinctor\Services\AfterShipService::getSettings();
        wp_send_json_success(['settings' => [
            'aftership_enabled'    => $aftership['enabled'] ?? false,
            'aftership_api_key'    => $aftership['api_key'] ?? '',
            'aftership_frequency'  => $aftership['update_frequency'] ?? 'hourly',
            'notify_on_ship'       => (bool) Config::get('instinctor_tracking_notify_ship', true),
            'notify_on_delivery'   => (bool) Config::get('instinctor_tracking_notify_delivery', true),
            'tracking_page'        => Config::get('instinctor_tracking_page_url', ''),
        ]]);
    }

    public function ajaxSaveAfterShipSettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) wp_send_json_error('Permission denied');
        \Instinctor\Services\AfterShipService::saveSettings([
            'enabled'          => !empty($_POST['aftership_enabled']),
            'api_key'          => sanitize_text_field($_POST['aftership_api_key'] ?? ''),
            'update_frequency' => sanitize_text_field($_POST['aftership_frequency'] ?? 'hourly'),
        ]);
        Config::set('instinctor_tracking_notify_ship',     !empty($_POST['notify_on_ship']));
        Config::set('instinctor_tracking_notify_delivery', !empty($_POST['notify_on_delivery']));
        Config::set('instinctor_tracking_page_url',        esc_url_raw($_POST['tracking_page'] ?? ''));
        wp_send_json_success();
    }



    public function ajaxPrintInvoice(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_die('Permission denied');
        }

        $orderId = intval($_GET['order_id'] ?? 0);
        
        if (!$orderId) {
            wp_die('Invalid order');
        }

        $html = \Instinctor\Services\InvoiceService::generateInvoice($orderId);

        if (!$html) {
            wp_die('Order not found');
        }

        // Invoice is a complete HTML document (<!doctype>/<head>/<style>/<body>)
        // generated by InvoiceService, which escapes every dynamic value at the point
        // of output (esc_html/esc_url/esc_attr). wp_kses_post would strip the
        // doctype/head/style and break the printable invoice, so it is emitted as-is.
        echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        exit;
    }

    public function ajaxPrintPackingSlip(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_die('Permission denied');
        }

        $orderId = intval($_GET['order_id'] ?? 0);
        
        if (!$orderId) {
            wp_die('Invalid order');
        }

        $html = \Instinctor\Services\InvoiceService::generatePackingSlip($orderId);

        if (!$html) {
            wp_die('Order not found');
        }

        // Packing slip is a complete HTML document (<!doctype>/<head>/<style>/<body>)
        // generated by InvoiceService, which escapes every dynamic value at the point
        // of output (esc_html/esc_url/esc_attr). wp_kses_post would strip the
        // doctype/head/style and break the layout, so it is emitted as-is.
        echo $html; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
        exit;
    }
    
    /**
     * Get products for pricing table dropdown
     */
    public function ajaxGetProducts(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $p = DB::prefix();
        
        $products = DB::get_results("
            SELECT p.id, p.name, p.price 
            FROM {$p}instinctor_products p 
            WHERE p.status = 'published' 
            ORDER BY p.name ASC 
            LIMIT 100
        ");
        
        $result = [];
        foreach ($products as $product) {
            $result[] = [
                'id' => $product->id,
                'title' => $product->name,
                'price' => '$' . number_format(floatval($product->price), 2)
            ];
        }
        
        wp_send_json_success($result);
    }
    
    /**
     * Get subscription plans for pricing table dropdown
     */
    public function ajaxGetSubscriptionPlans(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $p = DB::prefix();
        
        // Check if subscription plans table exists
        $table = DB::prefix() . 'instinctor_subscription_plans';
        $tableExists = DB::get_var("SHOW TABLES LIKE '$table'") === $table;
        
        if (!$tableExists) {
            // Fall back to products with subscription type
            $products = DB::get_results("
                SELECT p.id, p.name 
                FROM {$p}instinctor_products p 
                WHERE p.product_type = 'subscription' AND p.status = 'published' 
                ORDER BY p.name ASC 
                LIMIT 50
            ");
            
            $result = [];
            foreach ($products as $product) {
                $result[] = [
                    'id' => $product->id,
                    'name' => $product->name
                ];
            }
            
            wp_send_json_success($result);
            return;
        }
        
        $plans = DB::get_results("
            SELECT id, name 
            FROM {$table} 
            WHERE status = 'active' 
            ORDER BY name ASC 
            LIMIT 50
        ");
        
        $result = [];
        foreach ($plans as $plan) {
            $result[] = [
                'id' => $plan->id,
                'name' => $plan->name
            ];
        }
        
        wp_send_json_success($result);
    }



    /**
     * AJAX: Save card design
     */
    public function ajaxSaveCardDesign(): void
    {
        check_ajax_referer('instinctor_card_designer', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $design = isset($_POST['design']) ? json_decode(sanitize_textarea_field(wp_unslash($_POST['design'])), true) : null;
        
        if (!$design) {
            wp_send_json_error('Invalid design data');
        }
        
        // Sanitize the design data
        $sanitized = $this->sanitizeCardDesign($design);
        
        Config::set('instinctor_card_design', $sanitized);
        wp_send_json_success(['message' => 'Design saved']);
    }
    
    /**
     * Sanitize card design data
     */
    private function sanitizeCardDesign(array $design): array
    {
        $sanitized = [];
        
        // Preset
        $sanitized['preset'] = sanitize_text_field($design['preset'] ?? 'modern');
        
        // Elements (array of strings)
        $valid_elements = ['image', 'title', 'price', 'add_to_cart', 'quick_view', 'sale_badge', 'description', 'sku', 'stock'];
        $sanitized['elements'] = array_filter(
            array_map('sanitize_text_field', $design['elements'] ?? []),
            function($el) use ($valid_elements) { return in_array($el, $valid_elements); }
        );
        
        // Card settings
        $sanitized['card'] = [
            'background' => sanitize_hex_color($design['card']['background'] ?? '#ffffff'),
            'border_color' => sanitize_hex_color($design['card']['border_color'] ?? '#e0e0e0'),
            'border_width' => max(0, min(5, intval($design['card']['border_width'] ?? 1))),
            'border_radius' => max(0, min(50, intval($design['card']['border_radius'] ?? 8))),
            'shadow' => in_array($design['card']['shadow'] ?? 'small', ['none', 'small', 'medium', 'large']) ? $design['card']['shadow'] : 'small',
            'padding' => max(0, min(30, intval($design['card']['padding'] ?? 0)))
        ];
        
        // Image settings
        $sanitized['image'] = [
            'aspect_ratio' => in_array($design['image']['aspect_ratio'] ?? 'square', ['square', 'portrait', 'landscape']) ? $design['image']['aspect_ratio'] : 'square',
            'border_radius' => max(0, min(50, intval($design['image']['border_radius'] ?? 8)))
        ];
        
        // Title settings
        $sanitized['title'] = [
            'font_size' => max(10, min(32, intval($design['title']['font_size'] ?? 16))),
            'font_weight' => in_array($design['title']['font_weight'] ?? '600', ['300', '400', '500', '600', '700', '800']) ? $design['title']['font_weight'] : '600',
            'color' => sanitize_hex_color($design['title']['color'] ?? '#1d2327'),
            'margin_top' => max(0, min(30, intval($design['title']['margin_top'] ?? 12))),
            'lines' => max(1, min(5, intval($design['title']['lines'] ?? 2)))
        ];
        
        // Price settings
        $sanitized['price'] = [
            'font_size' => max(10, min(32, intval($design['price']['font_size'] ?? 18))),
            'font_weight' => in_array($design['price']['font_weight'] ?? '700', ['400', '500', '600', '700', '800']) ? $design['price']['font_weight'] : '700',
            'color' => sanitize_hex_color($design['price']['color'] ?? '#1d2327'),
            'sale_color' => sanitize_hex_color($design['price']['sale_color'] ?? '#dc2626'),
            'margin_top' => max(0, min(30, intval($design['price']['margin_top'] ?? 8)))
        ];
        
        // Add to cart settings
        $sanitized['add_to_cart'] = [
            'style' => in_array($design['add_to_cart']['style'] ?? 'button', ['button', 'link', 'icon']) ? $design['add_to_cart']['style'] : 'button',
            'bg_color' => sanitize_hex_color($design['add_to_cart']['bg_color'] ?? '#1d2327'),
            'text_color' => sanitize_hex_color($design['add_to_cart']['text_color'] ?? '#ffffff'),
            'border_radius' => max(0, min(50, intval($design['add_to_cart']['border_radius'] ?? 6))),
            'full_width' => !empty($design['add_to_cart']['full_width']),
            'margin_top' => max(0, min(30, intval($design['add_to_cart']['margin_top'] ?? 12))),
            'button_text' => sanitize_text_field($design['add_to_cart']['button_text'] ?? 'Add to Cart')
        ];
        
        // Sale badge settings
        $sanitized['sale_badge'] = [
            'enabled' => $design['sale_badge']['enabled'] ?? true,
            'bg_color' => sanitize_hex_color($design['sale_badge']['bg_color'] ?? '#dc2626'),
            'text_color' => sanitize_hex_color($design['sale_badge']['text_color'] ?? '#ffffff'),
            'text' => sanitize_text_field($design['sale_badge']['text'] ?? 'Sale')
        ];
        
        // Quick view settings
        $sanitized['quick_view'] = [
            'enabled' => !empty($design['quick_view']['enabled']),
            'style' => in_array($design['quick_view']['style'] ?? 'icon', ['icon', 'button']) ? $design['quick_view']['style'] : 'icon'
        ];
        
        return $sanitized;
    }

    /**
     * AJAX: Save card design as template
     */
    public function ajaxSaveCardTemplate(): void
    {
        check_ajax_referer('instinctor_card_designer', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
        $design = isset($_POST['design']) ? json_decode(sanitize_textarea_field(wp_unslash($_POST['design'])), true) : null;
        
        if (empty($name) || !$design) {
            wp_send_json_error('Missing template name or design data');
        }
        
        // Sanitize the design
        $sanitized = $this->sanitizeCardDesign($design);
        
        // Generate slug from name
        $slug = sanitize_title($name);
        
        // Get existing templates
        $templates = Config::get('instinctor_card_templates', []);
        
        // Add/update template
        $templates[$slug] = [
            'name' => $name,
            'design' => $sanitized,
            'created' => isset($templates[$slug]) ? $templates[$slug]['created'] : current_time('mysql'),
            'updated' => current_time('mysql')
        ];
        
        Config::set('instinctor_card_templates', $templates);
        
        wp_send_json_success([
            'message' => 'Template saved',
            'slug' => $slug,
            'templates' => $this->getCardTemplatesList()
        ]);
    }
    
    /**
     * AJAX: Delete card template
     */
    public function ajaxDeleteCardTemplate(): void
    {
        check_ajax_referer('instinctor_card_designer', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $slug = isset($_POST['slug']) ? sanitize_text_field($_POST['slug']) : '';
        
        if (empty($slug)) {
            wp_send_json_error('Missing template slug');
        }
        
        $templates = Config::get('instinctor_card_templates', []);
        
        if (isset($templates[$slug])) {
            unset($templates[$slug]);
            Config::set('instinctor_card_templates', $templates);
        }
        
        wp_send_json_success([
            'message' => 'Template deleted',
            'templates' => $this->getCardTemplatesList()
        ]);
    }
    
    /**
     * AJAX: Load card template
     */
    public function ajaxLoadCardTemplate(): void
    {
        check_ajax_referer('instinctor_card_designer', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $slug = isset($_POST['slug']) ? sanitize_text_field($_POST['slug']) : '';
        
        if (empty($slug)) {
            wp_send_json_error('Missing template slug');
        }
        
        $templates = Config::get('instinctor_card_templates', []);
        
        if (!isset($templates[$slug])) {
            wp_send_json_error('Template not found');
        }
        
        wp_send_json_success([
            'design' => $templates[$slug]['design']
        ]);
    }
    
    /**
     * Get card templates list for dropdown
     */
    public function getCardTemplatesList(): array
    {
        $templates = Config::get('instinctor_card_templates', []);
        $list = [];
        
        foreach ($templates as $slug => $template) {
            $list[] = [
                'slug' => $slug,
                'name' => $template['name']
            ];
        }
        
        return $list;
    }
    
    /**
     * AJAX: Save header as template
     */
    public function ajaxSaveHeaderTemplate(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
        $settings = isset($_POST['settings']) ? json_decode(sanitize_textarea_field(wp_unslash($_POST['settings'])), true) : [];
        
        if (empty($name)) {
            wp_send_json_error('Missing template name');
        }
        
        $slug = sanitize_title($name) . '_' . time();
        $templates = Config::get('instinctor_header_templates', []);
        
        $templates[$slug] = [
            'name' => $name,
            'settings' => $settings,
            'created' => current_time('mysql')
        ];
        
        Config::set('instinctor_header_templates', $templates);
        
        wp_send_json_success([
            'message' => 'Template saved',
            'slug' => $slug,
            'templates' => $this->getHeaderTemplatesList()
        ]);
    }
    
    /**
     * AJAX: Save header globally
     */
    public function ajaxSaveHeaderGlobal(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $settings = isset($_POST['settings']) ? json_decode(sanitize_textarea_field(wp_unslash($_POST['settings'])), true) : [];
        
        Config::set('instinctor_global_header', $settings);
        
        wp_send_json_success(['message' => 'Header saved globally']);
    }
    
    /**
     * AJAX: Load header template
     */
    public function ajaxLoadHeaderTemplate(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $slug = isset($_POST['slug']) ? sanitize_text_field($_POST['slug']) : '';
        
        if (empty($slug)) {
            wp_send_json_error('Missing template slug');
        }
        
        $templates = Config::get('instinctor_header_templates', []);
        
        if (!isset($templates[$slug])) {
            wp_send_json_error('Template not found');
        }
        
        wp_send_json_success([
            'settings' => $templates[$slug]['settings']
        ]);
    }
    
    /**
     * Get header templates list
     */
    public function getHeaderTemplatesList(): array
    {
        $templates = Config::get('instinctor_header_templates', []);
        $list = [];
        
        foreach ($templates as $slug => $template) {
            $list[] = ['slug' => $slug, 'name' => $template['name']];
        }
        
        return $list;
    }
    
    /**
     * AJAX: Save footer as template
     */
    public function ajaxSaveFooterTemplate(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
        $settings = isset($_POST['settings']) ? json_decode(sanitize_textarea_field(wp_unslash($_POST['settings'])), true) : [];
        
        if (empty($name)) {
            wp_send_json_error('Missing template name');
        }
        
        $slug = sanitize_title($name) . '_' . time();
        $templates = Config::get('instinctor_footer_templates', []);
        
        $templates[$slug] = [
            'name' => $name,
            'settings' => $settings,
            'created' => current_time('mysql')
        ];
        
        Config::set('instinctor_footer_templates', $templates);
        
        wp_send_json_success([
            'message' => 'Template saved',
            'slug' => $slug,
            'templates' => $this->getFooterTemplatesList()
        ]);
    }
    
    /**
     * AJAX: Save footer globally
     */
    public function ajaxSaveFooterGlobal(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $settings = isset($_POST['settings']) ? json_decode(sanitize_textarea_field(wp_unslash($_POST['settings'])), true) : [];
        
        Config::set('instinctor_global_footer', $settings);
        
        wp_send_json_success(['message' => 'Footer saved globally']);
    }
    
    /**
     * AJAX: Load footer template
     */
    public function ajaxLoadFooterTemplate(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $slug = isset($_POST['slug']) ? sanitize_text_field($_POST['slug']) : '';
        
        if (empty($slug)) {
            wp_send_json_error('Missing template slug');
        }
        
        $templates = Config::get('instinctor_footer_templates', []);
        
        if (!isset($templates[$slug])) {
            wp_send_json_error('Template not found');
        }
        
        wp_send_json_success([
            'settings' => $templates[$slug]['settings']
        ]);
    }
    
    /**
     * Get footer templates list
     */
    public function getFooterTemplatesList(): array
    {
        $templates = Config::get('instinctor_footer_templates', []);
        $list = [];
        
        foreach ($templates as $slug => $template) {
            $list[] = ['slug' => $slug, 'name' => $template['name']];
        }
        
        return $list;
    }
    
    /**
     * AJAX: Get header templates
     */
    public function ajaxGetHeaderTemplates(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        wp_send_json_success([
            'templates' => $this->getHeaderTemplatesList()
        ]);
    }
    
    /**
     * AJAX: Delete header template
     */
    public function ajaxDeleteHeaderTemplate(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $slug = isset($_POST['slug']) ? sanitize_text_field($_POST['slug']) : '';
        
        if (empty($slug)) {
            wp_send_json_error('Missing template slug');
        }
        
        $templates = Config::get('instinctor_header_templates', []);
        
        if (isset($templates[$slug])) {
            unset($templates[$slug]);
            Config::set('instinctor_header_templates', $templates);
        }
        
        wp_send_json_success(['message' => 'Template deleted']);
    }
    
    /**
     * AJAX: Get footer templates
     */
    public function ajaxGetFooterTemplates(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        wp_send_json_success([
            'templates' => $this->getFooterTemplatesList()
        ]);
    }
    
    /**
     * AJAX: Delete footer template
     */
    public function ajaxDeleteFooterTemplate(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $slug = isset($_POST['slug']) ? sanitize_text_field($_POST['slug']) : '';
        
        if (empty($slug)) {
            wp_send_json_error('Missing template slug');
        }
        
        $templates = Config::get('instinctor_footer_templates', []);
        
        if (isset($templates[$slug])) {
            unset($templates[$slug]);
            Config::set('instinctor_footer_templates', $templates);
        }
        
        wp_send_json_success(['message' => 'Template deleted']);
    }

    /**
     * AJAX: Save global header settings
     */
    public function ajaxSaveGlobalHeader(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- raw JSON; wp_unslash + json_decode below; structure validated post-decode
        $settings = isset($_POST['settings']) ? wp_unslash($_POST['settings']) : '';

        if (empty($settings)) {
            wp_send_json_error('Missing settings');
        }

        $decoded = json_decode((string) $settings, true);
        
        if (json_last_error() !== JSON_ERROR_NONE) {
            wp_send_json_error('Invalid JSON');
        }

        // Sanitize every leaf string in the decoded settings tree before
        // storing. Reviewer flagged the prior path (decode → set) as
        // "no field sanitization". sanitize_text_field strips control
        // chars, tags, slashes; safe for the field shapes this endpoint
        // accepts (plain text labels, URLs, color codes). The function is
        // defined inline once at file scope further down.
        $decoded = self::sanitizeSettingsTreeRecursive($decoded);

        Config::set('instinctor_global_header', $decoded);

        wp_send_json_success(['message' => 'Global header saved']);
    }

    /**
     * Recursively sanitize a settings tree's leaf strings. Used by the
     * global-header / global-footer save endpoints (and any future endpoint
     * accepting a JSON settings blob from an authenticated admin).
     * Required by WP.org review — every $_POST-derived value must pass
     * through a WordPress sanitization function before persistence.
     */
    private static function sanitizeSettingsTreeRecursive($value)
    {
        if (is_array($value)) {
            $out = [];
            foreach ($value as $k => $v) {
                // Keys: limit to printable ASCII identifier chars.
                $clean_k = is_string($k) ? sanitize_key((string) $k) : (int) $k;
                $out[$clean_k] = self::sanitizeSettingsTreeRecursive($v);
            }
            return $out;
        }
        if (is_string($value)) {
            // Allow newlines (multi-line labels, descriptions); strip tags.
            return sanitize_textarea_field($value);
        }
        // Numbers, bools, null pass through untouched.
        return $value;
    }

    /**
     * AJAX: Get global header settings
     */
    public function ajaxGetGlobalHeader(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $settings = Config::get('instinctor_global_header', null);
        
        if (empty($settings)) {
            wp_send_json_error('No global header saved');
        }
        
        wp_send_json_success(['settings' => $settings]);
    }

    /**
     * AJAX: Save global footer settings
     */
    public function ajaxSaveGlobalFooter(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized,WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- raw JSON; wp_unslash + json_decode below; structure validated post-decode
        $settings = isset($_POST['settings']) ? wp_unslash($_POST['settings']) : '';

        if (empty($settings)) {
            wp_send_json_error('Missing settings');
        }

        $decoded = json_decode((string) $settings, true);
        
        if (json_last_error() !== JSON_ERROR_NONE) {
            wp_send_json_error('Invalid JSON');
        }

        // Sanitize every leaf string before persistence (see header save above).
        $decoded = self::sanitizeSettingsTreeRecursive($decoded);

        Config::set('instinctor_global_footer', $decoded);

        wp_send_json_success(['message' => 'Global footer saved']);
    }

    /**
     * AJAX: Get global footer settings
     */
    public function ajaxGetGlobalFooter(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        $settings = Config::get('instinctor_global_footer', null);
        
        if (empty($settings)) {
            wp_send_json_error('No global footer saved');
        }
        
        wp_send_json_success(['settings' => $settings]);
    }

    /**
     * AJAX: Get card templates list
     */
    public function ajaxGetCardTemplates(): void
    {
        check_ajax_referer('instinctor_card_designer', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }
        
        wp_send_json_success([
            'templates' => $this->getCardTemplatesList()
        ]);
    }

    /**
     * AJAX: Save translation
     */

    /**
     * v1.7.0 - Bulk product actions
     */
    public function ajaxBulkProducts(): void
    {
        check_ajax_referer('instinctor_bulk', 'nonce');
        

        $p = DB::prefix();
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $action = sanitize_text_field($_POST['bulk_action'] ?? '');
        $ids = array_map('intval', $_POST['ids'] ?? []);
        
        if (empty($ids)) {
            wp_send_json_error(['message' => 'No products selected']);
        }
        
        $table = DB::prefix() . 'instinctor_products';
        $count = count($ids);
        $idList = implode(',', $ids);
        
        switch ($action) {
            case 'publish':
                DB::query("UPDATE $table SET status = 'published' WHERE id IN ($idList)");
                wp_send_json_success(['message' => "$count products published"]);
                break;
                
            case 'draft':
                DB::query("UPDATE $table SET status = 'draft' WHERE id IN ($idList)");
                wp_send_json_success(['message' => "$count products set to draft"]);
                break;
            
            case 'trash':
                DB::query("UPDATE $table SET status = 'trash' WHERE id IN ($idList)");
                wp_send_json_success(['message' => "$count products moved to trash"]);
                break;
            
            case 'restore':
                DB::query("UPDATE $table SET status = 'published' WHERE id IN ($idList)");
                wp_send_json_success(['message' => "$count products restored"]);
                break;
                
            case 'delete':
                DB::query("DELETE FROM $table WHERE id IN ($idList)");
                // Also clean up category relationships
                DB::query("DELETE FROM {$p}instinctor_product_categories WHERE product_id IN ($idList)");
                wp_send_json_success(['message' => "$count products permanently deleted"]);
                break;
            
            case 'duplicate':
                $duplicated = 0;
                foreach ($ids as $id) {
                    $product = DB::get_row(DB::prepare("SELECT * FROM $table WHERE id = %d", $id), ARRAY_A);
                    if ($product) {
                        unset($product['id']);
                        $product['name'] = $product['name'] . ' (Copy)';
                        $product['slug'] = $product['slug'] . '-copy-' . time();
                        $product['status'] = 'draft';
                        $product['created_at'] = current_time('mysql');
                        $product['updated_at'] = current_time('mysql');
                        DB::insert($table, $product);
                        $duplicated++;
                    }
                }
                wp_send_json_success(['message' => "$duplicated products duplicated"]);
                break;
                
            default:
                wp_send_json_error(['message' => 'Invalid action']);
        }
    }

    /**
     * Empty trash for products or categories
     */
    public function ajaxEmptyTrash(): void
    {
        check_ajax_referer('instinctor_bulk', 'nonce');
        

        $p = DB::prefix();
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $type = sanitize_text_field($_POST['type'] ?? '');
        
        
        if ($type === 'products') {
            // Get trashed product IDs first
            $trashedIds = DB::get_col("SELECT id FROM {$p}instinctor_products WHERE status = 'trash'");
            if (!empty($trashedIds)) {
                $idList = implode(',', array_map('intval', $trashedIds));
                DB::query("DELETE FROM {$p}instinctor_products WHERE status = 'trash'");
                DB::query("DELETE FROM {$p}instinctor_product_categories WHERE product_id IN ($idList)");
            }
            wp_send_json_success(['message' => 'Product trash emptied']);
        } elseif ($type === 'categories') {
            DB::query("DELETE FROM {$p}instinctor_categories WHERE status = 'trash'");
            wp_send_json_success(['message' => 'Category trash emptied']);
        } else {
            wp_send_json_error(['message' => 'Invalid type']);
        }
    }

    /**
     * Bulk actions for categories
     */
    public function ajaxBulkCategories(): void
    {
        check_ajax_referer('instinctor_bulk', 'nonce');
        

        $p = DB::prefix();
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $action = sanitize_text_field($_POST['bulk_action'] ?? '');
        $ids = array_map('intval', $_POST['ids'] ?? []);
        
        if (empty($ids)) {
            wp_send_json_error(['message' => 'No categories selected']);
        }
        
        $table = DB::prefix() . 'instinctor_categories';
        $count = count($ids);
        $idList = implode(',', $ids);
        
        switch ($action) {
            case 'trash':
                DB::query("UPDATE $table SET status = 'trash' WHERE id IN ($idList)");
                wp_send_json_success(['message' => "$count categories moved to trash"]);
                break;
            
            case 'restore':
                DB::query("UPDATE $table SET status = 'active' WHERE id IN ($idList)");
                wp_send_json_success(['message' => "$count categories restored"]);
                break;
                
            case 'delete':
                // First remove from product_categories
                DB::query("DELETE FROM {$p}instinctor_product_categories WHERE category_id IN ($idList)");
                // Then delete the categories
                DB::query("DELETE FROM $table WHERE id IN ($idList)");
                wp_send_json_success(['message' => "$count categories permanently deleted"]);
                break;
                
            default:
                wp_send_json_error(['message' => 'Invalid action']);
        }
    }

    /**
     * v1.7.0 - Inline edit product field
     */
    public function ajaxInlineEditProduct(): void
    {
        check_ajax_referer('instinctor_inline', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $id = intval($_POST['id'] ?? 0);
        $field = sanitize_text_field($_POST['field'] ?? '');
        $value = sanitize_text_field($_POST['value'] ?? '');
        
        if (!$id || !$field) {
            wp_send_json_error(['message' => 'Invalid request']);
        }
        
        $table = DB::prefix() . 'instinctor_products';
        
        $allowed_fields = ['price', 'stock_quantity', 'sale_price', 'sku'];
        if (!in_array($field, $allowed_fields)) {
            wp_send_json_error(['message' => 'Invalid field']);
        }
        
        // Map field names
        $db_field = $field === 'stock' ? 'stock_quantity' : $field;
        
        DB::update($table, [$db_field => $value], ['id' => $id]);
        
        wp_send_json_success(['message' => 'Updated']);
    }

    /**
     * v1.7.0 - Update product status inline
     */
    public function ajaxUpdateProductStatus(): void
    {
        check_ajax_referer('instinctor_inline', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $id = intval($_POST['id'] ?? 0);
        $status = sanitize_text_field($_POST['status'] ?? '');
        
        if (!$id || !in_array($status, ['published', 'draft'])) {
            wp_send_json_error(['message' => 'Invalid request']);
        }
        
        DB::update(DB::prefix() . 'instinctor_products', ['status' => $status], ['id' => $id]);
        
        wp_send_json_success(['message' => 'Status updated']);
    }

    /**
     * v1.7.0 - Global search
     */
    public function ajaxGlobalSearch(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        $p = DB::prefix();
        $query = sanitize_text_field($_POST['query'] ?? '');
        
        if (strlen($query) < 2) {
            wp_send_json_success(['html' => '']);
        }
        
        $like = '%' . DB::esc_like($query) . '%';
        $html = '';
        
        // Search products
        $products = DB::get_results(DB::prepare(
            "SELECT id, name FROM {$p}instinctor_products WHERE name LIKE %s OR sku LIKE %s LIMIT 5",
            $like, $like
        ));
        
        foreach ($products as $prod) {
            $url = admin_url('admin.php?page=instinctor&workspace=store&product=' . $prod->id);
            $html .= '<a href="' . esc_url($url) . '" class="search-result-item"><span class="search-result-type">Product</span>' . esc_html($prod->name) . '</a>';
        }
        
        // Search orders
        $orders = DB::get_results(DB::prepare(
            "SELECT o.id, o.order_number, c.first_name, c.last_name 
             FROM {$p}instinctor_orders o
             LEFT JOIN {$p}instinctor_customers c ON o.customer_id = c.id
             WHERE o.order_number LIKE %s OR c.first_name LIKE %s OR c.last_name LIKE %s OR c.email LIKE %s
             LIMIT 5",
            $like, $like, $like, $like
        ));
        
        foreach ($orders as $o) {
            $url = admin_url('admin.php?page=instinctor-orders&action=view&id=' . $o->id);
            $name = trim($o->first_name . ' ' . $o->last_name) ?: 'Guest';
            $html .= '<a href="' . esc_url($url) . '" class="search-result-item"><span class="search-result-type">Order</span>#' . esc_html($o->order_number) . ' - ' . esc_html($name) . '</a>';
        }
        
        // Search customers
        $customers = DB::get_results(DB::prepare(
            "SELECT id, first_name, last_name, email FROM {$p}instinctor_customers 
             WHERE first_name LIKE %s OR last_name LIKE %s OR email LIKE %s LIMIT 5",
            $like, $like, $like
        ));
        
        foreach ($customers as $c) {
            $url = admin_url('admin.php?page=instinctor-customers&action=view&id=' . $c->id);
            $name = trim($c->first_name . ' ' . $c->last_name) ?: $c->email;
            $html .= '<a href="' . esc_url($url) . '" class="search-result-item"><span class="search-result-type">Customer</span>' . esc_html($name) . '</a>';
        }
        
        if (empty($html)) {
            $html = '<div class="search-no-results">No results found</div>';
        }
        
        wp_send_json_success(['html' => $html]);
    }

    /**
     * v1.7.0 - Bulk order actions
     */
    public function ajaxBulkOrders(): void
    {
        check_ajax_referer('instinctor_bulk', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $action = sanitize_text_field($_POST['bulk_action'] ?? '');
        $ids = array_map('intval', $_POST['ids'] ?? []);
        
        if (empty($ids)) {
            wp_send_json_error(['message' => 'No orders selected']);
        }
        
        $table = DB::prefix() . 'instinctor_orders';
        $count = count($ids);
        
        $valid_statuses = ['pending', 'processing', 'shipped', 'completed', 'cancelled', 'on-hold', 'refunded'];
        
        if (in_array($action, $valid_statuses)) {
            DB::query("UPDATE $table SET status = '$action' WHERE id IN (" . implode(',', $ids) . ")");
            wp_send_json_success(['message' => "$count orders updated to $action"]);
        } else {
            wp_send_json_error(['message' => 'Invalid action']);
        }
    }

    /**
     * Return orders list + status counts for dynamic reload
     */
    public function ajaxGetOrdersList(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');

        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $p = DB::prefix();
        $batch   = 50;
        $offset  = max(0, intval($_POST['offset'] ?? 0));
        $status  = sanitize_text_field($_POST['status'] ?? '');
        $search  = sanitize_text_field($_POST['search'] ?? '');
        $sortBy  = sanitize_text_field($_POST['sort_by'] ?? 'date');
        $sortDir = strtoupper(sanitize_text_field($_POST['sort_dir'] ?? 'DESC')) === 'ASC' ? 'ASC' : 'DESC';
        $allowed = ['pending', 'processing', 'shipped', 'completed', 'cancelled', 'refunded', 'on-hold'];

        $sortMap = [
            'date'     => 'o.created_at',
            'customer' => 'c.last_name',
            'status'   => 'o.status',
            'total'    => 'o.total',
        ];
        $orderClause = ($sortMap[$sortBy] ?? 'o.created_at') . ' ' . $sortDir;

        $conditions = [];
        if ($status && in_array($status, $allowed, true)) {
            $conditions[] = DB::prepare('o.status = %s', $status);
        }
        if ($search !== '') {
            $like = '%' . DB::esc_like($search) . '%';
            $conditions[] = DB::prepare(
                '(o.order_number LIKE %s OR c.email LIKE %s OR c.first_name LIKE %s OR c.last_name LIKE %s)',
                $like, $like, $like, $like
            );
        }
        $where = $conditions ? ' WHERE ' . implode(' AND ', $conditions) : '';

        $orders = DB::get_results(DB::prepare(
            "SELECT o.id, o.order_number, o.status, o.total, o.created_at, o.customer_id,
                    c.email AS customer_email, c.first_name, c.last_name
             FROM {$p}instinctor_orders o
             LEFT JOIN {$p}instinctor_customers c ON o.customer_id = c.id
             {$where}
             ORDER BY {$orderClause}
             LIMIT %d OFFSET %d",
            $batch + 1,  // fetch one extra to detect has_more
            $offset
        ));

        $has_more = count($orders) > $batch;
        if ($has_more) array_pop($orders);

        $currency = Config::get('instinctor_currency_symbol', '$');

        $items = [];
        foreach ($orders as $order) {
            $name = trim(($order->first_name ?: '') . ' ' . ($order->last_name ?: ''));
            if (empty($name)) $name = $order->customer_email ?: 'Guest';
            $items[] = [
                'id'           => (int)$order->id,
                'order_number' => $order->order_number,
                'status'       => $order->status,
                'total'        => $currency . number_format((float)$order->total, 2),
                'customer'     => $name,
                'customer_id'  => (int)$order->customer_id,
                'email'        => $order->customer_email ?: '',
                'date'         => date('M j', strtotime($order->created_at)),
                'time_ago'     => human_time_diff(strtotime($order->created_at), current_time('timestamp')) . ' ago',
            ];
        }

        $response = [
            'orders'   => $items,
            'has_more' => $has_more,
            'offset'   => $offset,
            'currency' => $currency,
        ];

        // Only compute counts and finance on first load (offset 0) — avoid repeated heavy queries
        if ($offset === 0) {
            $response['status_counts'] = [
                'all'        => (int)DB::get_var("SELECT COUNT(*) FROM {$p}instinctor_orders"),
                'pending'    => (int)DB::get_var("SELECT COUNT(*) FROM {$p}instinctor_orders WHERE status = 'pending'"),
                'processing' => (int)DB::get_var("SELECT COUNT(*) FROM {$p}instinctor_orders WHERE status = 'processing'"),
                'completed'  => (int)DB::get_var("SELECT COUNT(*) FROM {$p}instinctor_orders WHERE status = 'completed'"),
                'shipped'    => (int)DB::get_var("SELECT COUNT(*) FROM {$p}instinctor_orders WHERE status = 'shipped'"),
                'cancelled'  => (int)DB::get_var("SELECT COUNT(*) FROM {$p}instinctor_orders WHERE status = 'cancelled'"),
                'refunded'   => (int)DB::get_var("SELECT COUNT(*) FROM {$p}instinctor_orders WHERE status = 'refunded'"),
            ];
            $response['finance'] = [
                'today_revenue' => (float)DB::get_var("SELECT COALESCE(SUM(total),0) FROM {$p}instinctor_orders WHERE status IN ('completed','processing') AND DATE(created_at)=CURDATE()"),
                'today_orders'  => (int)DB::get_var("SELECT COUNT(*) FROM {$p}instinctor_orders WHERE DATE(created_at)=CURDATE()"),
                'month_revenue' => (float)DB::get_var("SELECT COALESCE(SUM(total),0) FROM {$p}instinctor_orders WHERE status IN ('completed','processing') AND created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)"),
            ];
        }

        wp_send_json_success($response);
    }

    /**
     * v1.7.0 - Reorder categories
     */
    public function ajaxReorderCategories(): void
    {
        check_ajax_referer('instinctor_reorder', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $order = array_map('intval', $_POST['order'] ?? []);
        
        if (empty($order)) {
            wp_send_json_error(['message' => 'No order provided']);
        }
        
        $table = DB::prefix() . 'instinctor_categories';
        
        foreach ($order as $position => $id) {
            DB::update(
                $table,
                ['sort_order' => $position],
                ['id' => $id],
                ['%d'],
                ['%d']
            );
        }
        
        wp_send_json_success(['message' => 'Order saved']);
    }

    /**
     * v1.7.0 - Generic inline update
     */
    public function ajaxInlineUpdate(): void
    {
        if (!wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['nonce'] ?? '')), 'instinctor_admin')) {
            wp_send_json_error(['message' => 'Invalid nonce']);
        }
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $id = intval($_POST['id'] ?? 0);
        $field = sanitize_text_field($_POST['field'] ?? '');
        $value = sanitize_text_field($_POST['value'] ?? '');
        
        if (!$id || !$field) {
            wp_send_json_error(['message' => 'Missing parameters']);
        }
        
        $table = DB::prefix() . 'instinctor_products';
        $display = $value;
        
        // Map field to column
        $allowed_fields = [
            'price' => ['column' => 'price', 'format' => '%f'],
            'stock' => ['column' => 'stock_quantity', 'format' => '%d'],
        ];
        
        if (!isset($allowed_fields[$field])) {
            wp_send_json_error(['message' => 'Invalid field']);
        }
        
        $column = $allowed_fields[$field]['column'];
        $format = $allowed_fields[$field]['format'];
        
        $result = DB::update(
            $table,
            [$column => $value],
            ['id' => $id],
            [$format],
            ['%d']
        );
        
        if ($result !== false) {
            // Format display value
            if ($field === 'price') {
                $currency = \Instinctor\Services\CurrencyService::getSymbol();
                $display = $currency . number_format((float)$value, 2);
            }
            
            wp_send_json_success(['display' => $display, 'value' => $value]);
        } else {
            wp_send_json_error(['message' => 'Update failed']);
        }
    }

    /**
     * Create a new subscription via AJAX
     */
    public function ajaxCreateSubscription(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $table = DB::prefix() . 'instinctor_subscriptions';

        $customer_id = intval($_POST['customer_id'] ?? 0);
        $product_id = intval($_POST['product_id'] ?? 0);
        $price = floatval($_POST['price'] ?? 0);
        $period = sanitize_text_field($_POST['period'] ?? 'monthly');

        if (!$customer_id || $price <= 0) {
            wp_send_json_error(['message' => 'Customer and price are required']);
        }

        // Calculate next payment date
        $period_end = new \DateTime();
        switch ($period) {
            case 'weekly':
                $period_end->modify('+1 week');
                break;
            case 'yearly':
                $period_end->modify('+1 year');
                break;
            default:
                $period_end->modify('+1 month');
        }

        $result = DB::insert($table, [
            'customer_id' => $customer_id,
            'product_id' => $product_id ?: null,
            'price' => $price,
            'period' => $period,
            'status' => 'active',
            'current_period_start' => current_time('mysql'),
            'current_period_end' => $period_end->format('Y-m-d H:i:s'),
            'created_at' => current_time('mysql')
        ]);

        if ($result) {
            wp_send_json_success(['subscription_id' => DB::insert_id()]);
        } else {
            wp_send_json_error(['message' => 'Failed to create subscription']);
        }
    }

    /**
     * Update subscription status via AJAX
     */
    public function ajaxUpdateSubscriptionStatus(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        

        $p = DB::prefix();
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $subscription_id = intval($_POST['subscription_id'] ?? 0);
        $status = sanitize_text_field($_POST['status'] ?? '');

        if (!$subscription_id || !in_array($status, ['active', 'paused', 'cancelled', 'expired'])) {
            wp_send_json_error(['message' => 'Invalid subscription or status']);
        }

        
        // Get subscription to check payment method
        $subscription = DB::get_row(DB::prepare(
            "SELECT * FROM {$p}instinctor_subscriptions WHERE id = %d",
            $subscription_id
        ));

        if (!$subscription) {
            wp_send_json_error(['message' => 'Subscription not found']);
        }

        $isStripe = !empty($subscription->external_id) && strpos($subscription->external_id, 'sub_') === 0;

        $result = ['success' => false];

        if ($isStripe) {
            $stripeService = \Instinctor\Services\SubscriptionService::instance();
            
            switch ($status) {
                case 'paused':
                    $result = $stripeService->pauseSubscription($subscription_id);
                    break;
                case 'active':
                    $result = $stripeService->resumeSubscription($subscription_id);
                    break;
                case 'cancelled':
                    $settings = Config::get('instinctor_subscription_settings', []);
                    $immediately = !empty($settings['cancel_immediately']);
                    $result = $stripeService->cancelSubscription($subscription_id, $immediately);
                    break;
            }
        } else {
            // Local-only subscription (demo mode)
            $update_data = ['status' => $status];
            
            if ($status === 'active') {
                $period = $subscription->period ?? 'monthly';
                $period_end = new \DateTime();
                switch ($period) {
                    case 'weekly': $period_end->modify('+1 week'); break;
                    case 'yearly': $period_end->modify('+1 year'); break;
                    default: $period_end->modify('+1 month');
                }
                $update_data['current_period_start'] = current_time('mysql');
                $update_data['current_period_end'] = $period_end->format('Y-m-d H:i:s');
            } elseif ($status === 'cancelled') {
                $update_data['canceled_at'] = current_time('mysql');
            }
            
            $dbResult = DB::update(
                DB::prefix() . 'instinctor_subscriptions',
                $update_data,
                ['id' => $subscription_id]
            );
            $result = ['success' => $dbResult !== false];
        }

        if (!empty($result['success'])) {
            wp_send_json_success();
        } else {
            wp_send_json_error(['message' => $result['error'] ?? 'Failed to update subscription']);
        }
    }

    /**
     * Save subscription settings via AJAX
     */
    public function ajaxSaveSubscriptionSettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        // Parse the serialized form data
        $formData = [];
        if (!empty($_POST['settings'])) {
            parse_str($_POST['settings'], $formData);
        }

        $settings = [
            'allow_pause' => !empty($formData['allow_pause']),
            'pause_limit' => intval($formData['pause_limit'] ?? 2),
            'cancel_immediately' => !empty($formData['cancel_immediately']),
            'prorate_upgrades' => !empty($formData['prorate_upgrades']),
            'failed_payment_retries' => intval($formData['failed_payment_retries'] ?? 3),
            'dunning_emails' => !empty($formData['dunning_emails']),
            'renewal_reminder_days' => intval($formData['renewal_reminder_days'] ?? 3)
        ];

        Config::set('instinctor_subscription_settings', $settings);
        
        wp_send_json_success(['message' => 'Settings saved']);
    }

    // =========================================================================
    // INQUIRIES (form submissions) WORKSPACE HANDLER
    // =========================================================================

    public function ajaxGetFormSubmissions(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        $p = DB::prefix();
        $t = $p . 'instinctor_form_submissions';
        $submissions = [];
        if (DB::get_var(DB::prepare("SHOW TABLES LIKE %s", $t)) === $t) {
            // Vendor rows only: tenant leads belong to the tenant, not this list.
            $rows = DB::get_results("SELECT id, form_name, data, created_at, is_read FROM {$t} WHERE owner_id IS NULL ORDER BY id DESC LIMIT 500");
            foreach ($rows as $r) {
                $submissions[] = [
                    'id'         => (int) $r->id,
                    'form_name'  => (string) $r->form_name,
                    'created_at' => (string) $r->created_at,
                    'is_read'    => (int) $r->is_read,
                    'fields'     => json_decode($r->data, true) ?: [],
                ];
            }
        }
        wp_send_json_success(['submissions' => $submissions, 'count' => count($submissions)]);
    }

    // =========================================================================
    // BOOKINGS WORKSPACE HANDLERS
    // =========================================================================

    public function ajaxGetBookingsList(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');

        $p = DB::prefix();
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $bt = DB::prefix() . 'instinctor_bookings';
        $ct = DB::prefix() . 'instinctor_customers';
        $st = DB::prefix() . 'instinctor_services';

        $status = sanitize_text_field($_POST['status'] ?? 'upcoming');
        $search = sanitize_text_field($_POST['search'] ?? '');
        $date   = sanitize_text_field($_POST['date']   ?? '');

        $where  = ['1=1'];
        $params = [];

        if ($status === 'upcoming') {
            $where[]  = "b.start_time >= %s AND b.status IN ('pending','confirmed')";
            $params[] = current_time('mysql');
        } elseif ($status !== 'all') {
            $where[]  = 'b.status = %s';
            $params[] = $status;
        }

        if ($date) {
            $where[]  = 'DATE(b.start_time) = %s';
            $params[] = $date;
        }

        if ($search) {
            $like    = '%' . DB::esc_like($search) . '%';
            $where[] = '(c.first_name LIKE %s OR c.last_name LIKE %s OR c.email LIKE %s OR sv.name LIKE %s)';
            $params  = array_merge($params, [$like, $like, $like, $like]);
        }

        $sql = "SELECT b.id, b.status, b.start_time, b.end_time, b.order_id, b.series_id,
                       CONCAT(COALESCE(c.first_name,''), ' ', COALESCE(c.last_name,'')) AS customer_name,
                       sv.name AS service_name
                FROM {$bt} b
                LEFT JOIN {$ct} c  ON b.customer_id = c.id
                LEFT JOIN {$st} sv ON b.product_id  = sv.id
                WHERE " . implode(' AND ', $where) . "
                ORDER BY b.start_time ASC
                LIMIT 200";

        $rows = $params
            ? DB::get_results(DB::prepare($sql, ...$params), ARRAY_A) // phpcs:ignore WordPress.DB.PreparedSQL
            : DB::get_results($sql, ARRAY_A); // phpcs:ignore WordPress.DB.PreparedSQL

        $bookings = array_map(function ($r) {
            $dt = strtotime($r['start_time']);
            return [
                'id'            => (int) $r['id'],
                'status'        => $r['status'],
                'date_label'    => date_i18n('M j', $dt),
                'time_label'    => date_i18n('g:i a', $dt),
                'customer_name' => trim($r['customer_name']) ?: 'Guest',
                'service_name'  => $r['service_name'] ?? '',
                'order_id'      => $r['order_id'],
                'repeats'       => !empty($r['series_id']),
            ];
        }, $rows ?: []);

        $today   = current_time('Y-m-d');
        $weekEnd = date('Y-m-d', strtotime('+7 days'));
        $stats = [
            'today'    => (int) DB::get_var(DB::prepare("SELECT COUNT(*) FROM {$bt} WHERE DATE(start_time) = %s", $today)),
            'pending'  => (int) DB::get_var("SELECT COUNT(*) FROM {$bt} WHERE status = 'pending'"),
            'week'     => (int) DB::get_var(DB::prepare("SELECT COUNT(*) FROM {$bt} WHERE start_time BETWEEN %s AND %s AND status IN ('pending','confirmed')", current_time('mysql'), $weekEnd . ' 23:59:59')),
            'services' => (int) DB::get_var("SELECT COUNT(*) FROM {$p}instinctor_services WHERE status = 'active'"),
        ];

        wp_send_json_success(['bookings' => $bookings, 'stats' => $stats]);
    }

    public function ajaxGetBookingsStats(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        $bt      = DB::prefix() . 'instinctor_bookings';
        $today   = current_time('Y-m-d');
        $weekEnd = date('Y-m-d', strtotime('+7 days'));
        $monthStart = date('Y-m-01');
        $monthEnd   = date('Y-m-t');
        wp_send_json_success([
            'today'   => (int) DB::get_var(DB::prepare("SELECT COUNT(*) FROM {$bt} WHERE DATE(start_time) = %s", $today)),
            'pending' => (int) DB::get_var("SELECT COUNT(*) FROM {$bt} WHERE status = 'pending'"),
            'week'    => (int) DB::get_var(DB::prepare("SELECT COUNT(*) FROM {$bt} WHERE start_time BETWEEN %s AND %s AND status IN ('pending','confirmed')", current_time('mysql'), $weekEnd . ' 23:59:59')),
            'month'   => (int) DB::get_var(DB::prepare("SELECT COUNT(*) FROM {$bt} WHERE DATE(start_time) BETWEEN %s AND %s", $monthStart, $monthEnd)),
        ]);
    }

    public function ajaxGetServices(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');

        $p = DB::prefix();
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        $rows = DB::get_results(
            "SELECT id, name, description, duration, buffer_before, buffer_after, price, capacity, color, availability, status
             FROM {$p}instinctor_services
             ORDER BY sort_order ASC, name ASC",
            ARRAY_A
        ) ?: [];

        $services = array_map(function ($r) {
            return [
                'id'            => (int) $r['id'],
                'name'          => $r['name'],
                'description'   => $r['description'] ?? '',
                'duration'      => (int) $r['duration'],
                'buffer_before' => (int) $r['buffer_before'],
                'buffer_after'  => (int) $r['buffer_after'],
                'price'         => $r['price'],
                'capacity'      => (int) $r['capacity'],
                'color'         => $r['color'] ?? '#555555',
                'availability'  => $r['availability'] ? json_decode($r['availability'], true) : null,
                'status'        => $r['status'],
            ];
        }, $rows);

        wp_send_json_success(['services' => $services]);
    }

    public function ajaxSaveService(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $id   = intval($_POST['id'] ?? 0);
        $name = sanitize_text_field($_POST['name'] ?? '');

        if (!$name) {
            wp_send_json_error('Service name is required');
        }

        // Parse availability JSON from POST
        $availRaw = stripslashes(sanitize_text_field(wp_unslash($_POST['availability'] ?? '')));
        $availData = $availRaw ? json_decode($availRaw, true) : null;
        $availability = null;
        if ($availData) {
            $days = ['monday','tuesday','wednesday','thursday','friday','saturday','sunday'];
            $sanitized = [];
            foreach ($days as $day) {
                if (isset($availData[$day])) {
                    $d = $availData[$day];
                    $sanitized[$day] = [
                        'open' => !empty($d['open']),
                        'from' => preg_match('/^\d{2}:\d{2}$/', $d['from'] ?? '') ? $d['from'] : '09:00',
                        'to'   => preg_match('/^\d{2}:\d{2}$/', $d['to']   ?? '') ? $d['to']   : '17:00',
                    ];
                }
            }
            if ($sanitized) $availability = $sanitized;
        }

        $data = [
            'name'          => $name,
            'description'   => sanitize_textarea_field($_POST['description'] ?? ''),
            'duration'      => max(5, intval($_POST['duration'] ?? 60)),
            'buffer_before' => max(0, intval($_POST['buffer_before'] ?? 0)),
            'buffer_after'  => max(0, intval($_POST['buffer_after'] ?? 15)),
            'price'         => floatval($_POST['price'] ?? 0),
            'deposit'       => max(0, floatval($_POST['deposit'] ?? 0)),
            'capacity'      => max(1, intval($_POST['capacity'] ?? 1)),
            'color'         => sanitize_hex_color($_POST['color'] ?? '#555555') ?: '#555555',
            'availability'  => $availability ? wp_json_encode($availability) : null,
            'status'        => in_array(($_inStatus = sanitize_text_field(wp_unslash($_POST['status'] ?? ''))), ['active', 'inactive'], true) ? $_inStatus : 'active',
            'updated_at'    => current_time('mysql'),
        ];

        // Shared with the tenant dashboard. Besides removing the duplicate write,
        // this closes a real gap: DB::update() keyed on id ALONE, so an id from a
        // forged request could reach another owner's row. The service scopes the
        // update by owner. Audit 2026-08-03.
        [$savedId, $svcErr] = \Instinctor\Services\BookingService::instance()
            ->saveService(\Instinctor\Core\Tenant::currentOwnerId(), $id, $data);
        if ($svcErr !== '') {
            wp_send_json_error($svcErr);
        }
        wp_send_json_success(['id' => $savedId]);
    }

    public function ajaxDeleteService(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        $id = intval($_POST['id'] ?? 0);
        if (!$id) {
            wp_send_json_error('Invalid ID');
        }
        // Never orphan booking history: the service keeps the row when bookings
        // reference it and retires it instead. Also owner-scoped, which the bare
        // DB::delete() was not.
        $outcome = \Instinctor\Services\BookingService::instance()
            ->deleteService(\Instinctor\Core\Tenant::currentOwnerId(), $id);
        if ($outcome === 'missing') {
            wp_send_json_error('That service could not be found');
        }
        wp_send_json_success([
            'outcome' => $outcome,
            'message' => $outcome === 'retired'
                ? 'Retired. Past bookings that used it keep their history.'
                : 'Deleted.',
        ]);
    }

    // ===== Staff / Team (Phase 1) =============================================

    /** Weekday index (0=Sun) -> day key, for hours (de)serialisation. */
    private static $staffDayKeys = ['sunday','monday','tuesday','wednesday','thursday','friday','saturday'];

    public function ajaxGetStaff(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        $p = DB::prefix();

        $rows = DB::get_results(
            // Tenant-scoped: staff is shared storage. A NULL owner means this install's
            // own staff, which is every standalone client, so nothing changes for them.
            "SELECT id, name, title, bio, photo_url, calendar_color, commission_rate, bookable, status, sort_order
             FROM {$p}instinctor_staff WHERE " . \Instinctor\Core\Tenant::scopeSql('owner_id') . "
             ORDER BY sort_order ASC, name ASC",
            ARRAY_A
        ) ?: [];

        $hoursByStaff = [];
        foreach (DB::get_results("SELECT staff_id, day_of_week, start_time, end_time FROM {$p}instinctor_staff_hours", ARRAY_A) ?: [] as $h) {
            $day = self::$staffDayKeys[(int) $h['day_of_week']] ?? null;
            if ($day) {
                $hoursByStaff[(int) $h['staff_id']][$day] = [
                    'open' => true,
                    'from' => substr((string) $h['start_time'], 0, 5),
                    'to'   => substr((string) $h['end_time'], 0, 5),
                ];
            }
        }

        $svcByStaff = [];
        foreach (DB::get_results("SELECT staff_id, service_id FROM {$p}instinctor_service_staff", ARRAY_A) ?: [] as $r) {
            $svcByStaff[(int) $r['staff_id']][] = (int) $r['service_id'];
        }

        $staff = array_map(function ($r) use ($hoursByStaff, $svcByStaff) {
            $id = (int) $r['id'];
            return [
                'id'              => $id,
                'name'            => $r['name'],
                'title'           => $r['title'] ?? '',
                'bio'             => $r['bio'] ?? '',
                'photo_url'       => $r['photo_url'] ?? '',
                'calendar_color'  => $r['calendar_color'] ?: '#7E946C',
                'commission_rate' => (float) $r['commission_rate'],
                'bookable'        => (int) $r['bookable'] === 1,
                'status'          => $r['status'],
                'hours'           => $hoursByStaff[$id] ?? null,
                'service_ids'     => $svcByStaff[$id] ?? [],
            ];
        }, $rows);

        $svcs = DB::get_results("SELECT id, name FROM {$p}instinctor_services WHERE status = 'active' ORDER BY sort_order ASC, name ASC", ARRAY_A) ?: [];

        wp_send_json_success([
            'staff'    => $staff,
            'services' => array_map(fn($s) => ['id' => (int) $s['id'], 'name' => $s['name']], $svcs),
        ]);
    }

    public function ajaxSaveStaff(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        $p = DB::prefix();

        $id   = intval($_POST['id'] ?? 0);
        $name = sanitize_text_field(wp_unslash($_POST['name'] ?? ''));
        if (!$name) {
            wp_send_json_error('Staff name is required');
        }

        $data = [
            'name'            => $name,
            'title'           => sanitize_text_field(wp_unslash($_POST['title'] ?? '')),
            'bio'             => sanitize_textarea_field(wp_unslash($_POST['bio'] ?? '')),
            'photo_url'       => esc_url_raw(wp_unslash($_POST['photo_url'] ?? '')),
            'calendar_color'  => sanitize_hex_color(wp_unslash($_POST['calendar_color'] ?? '#7E946C')) ?: '#7E946C',
            'commission_rate' => max(0, min(100, floatval($_POST['commission_rate'] ?? 0))),
            'bookable'        => !empty($_POST['bookable']) && $_POST['bookable'] !== '0' ? 1 : 0,
            'status'          => in_array(($st = sanitize_text_field(wp_unslash($_POST['status'] ?? ''))), ['active', 'inactive'], true) ? $st : 'active',
            'updated_at'      => current_time('mysql'),
        ];

        // Shared with the tenant dashboard: one implementation owns the ownership
        // check, the insert defaults AND the team-card sync, so staff edited here
        // updates the site exactly as it does from the simplified dashboard.
        [$id, $staffErr] = \Instinctor\Services\BookingService::instance()
            ->saveStaff(\Instinctor\Core\Tenant::currentOwnerId(), $id, $data);
        if ($staffErr !== '') {
            wp_send_json_error($staffErr);
        }
        if (!$id) {
            wp_send_json_error('Failed to save staff');
        }

        // Weekly hours — replace the whole set when the field is provided.
        if (isset($_POST['hours'])) {
            $hoursRaw  = stripslashes(sanitize_textarea_field(wp_unslash($_POST['hours'])));
            $hoursData = $hoursRaw ? json_decode($hoursRaw, true) : null;
            DB::delete($p . 'instinctor_staff_hours', ['staff_id' => $id]);
            if (is_array($hoursData)) {
                $dowMap = array_flip(self::$staffDayKeys);
                foreach ($hoursData as $day => $d) {
                    if (!isset($dowMap[$day]) || empty($d['open'])) continue;
                    $from = preg_match('/^\d{2}:\d{2}$/', $d['from'] ?? '') ? $d['from'] : '09:00';
                    $to   = preg_match('/^\d{2}:\d{2}$/', $d['to']   ?? '') ? $d['to']   : '17:00';
                    DB::insert($p . 'instinctor_staff_hours', [
                        'staff_id'    => $id,
                        'day_of_week' => $dowMap[$day],
                        'start_time'  => $from . ':00',
                        'end_time'    => $to . ':00',
                    ]);
                }
            }
        }

        // Service assignments — replace when the field is provided.
        if (isset($_POST['service_ids'])) {
            $svcRaw = sanitize_text_field(wp_unslash($_POST['service_ids']));
            $svcIds = $svcRaw !== '' ? array_unique(array_filter(array_map('intval', explode(',', $svcRaw)))) : [];
            DB::delete($p . 'instinctor_service_staff', ['staff_id' => $id]);
            foreach ($svcIds as $svcId) {
                if ($svcId > 0) DB::insert($p . 'instinctor_service_staff', ['service_id' => $svcId, 'staff_id' => $id]);
            }
        }

        wp_send_json_success(['id' => $id]);
    }

    public function ajaxDeleteStaff(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        $p  = DB::prefix();
        $id = intval($_POST['id'] ?? 0);
        if (!$id) {
            wp_send_json_error('Invalid ID');
        }
        DB::delete($p . 'instinctor_staff', ['id' => $id]);
        DB::delete($p . 'instinctor_staff_hours', ['staff_id' => $id]);
        DB::delete($p . 'instinctor_staff_timeoff', ['staff_id' => $id]);
        DB::delete($p . 'instinctor_service_staff', ['staff_id' => $id]);
        // Keep past bookings, just unassign the deleted staff.
        DB::query(DB::prepare("UPDATE {$p}instinctor_bookings SET staff_id = NULL WHERE staff_id = %d", $id));
        wp_send_json_success();
    }

    // =========================================================================

    /**
     * Get bookings for calendar view
     */
    public function ajaxGetBookingsCalendar(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $start = sanitize_text_field($_POST['start'] ?? date('Y-m-01'));
        $end = sanitize_text_field($_POST['end'] ?? date('Y-m-t'));
        
        $bookingService = \Instinctor\Services\BookingService::instance();
        $events = $bookingService->getBookingsForCalendar($start, $end);
        
        wp_send_json_success($events);
    }

    /**
     * Get single booking details
     */
    public function ajaxGetBooking(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        

        $p = DB::prefix();
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $bookingId = intval($_POST['booking_id'] ?? 0);
        if (!$bookingId) {
            wp_send_json_error(['message' => 'Invalid booking ID']);
        }

        $booking = DB::get_row(DB::prepare(
            "SELECT b.*, sv.name as service_name, c.first_name, c.last_name, c.email, c.phone,
                    st.name as staff_name
             FROM {$p}instinctor_bookings b
             LEFT JOIN {$p}instinctor_services sv ON b.product_id = sv.id
             LEFT JOIN {$p}instinctor_customers c ON b.customer_id = c.id
             LEFT JOIN {$p}instinctor_staff st ON b.staff_id = st.id
             WHERE b.id = %d",
            $bookingId
        ));

        if (!$booking) {
            wp_send_json_error(['message' => 'Booking not found']);
        }

        $customerName = trim($booking->first_name . ' ' . $booking->last_name);
        
        $statusLabels = [
            'pending' => __('Pending', 'instinctor'),
            'confirmed' => __('Confirmed', 'instinctor'),
            'completed' => __('Completed', 'instinctor'),
            'cancelled' => __('Cancelled', 'instinctor'),
            'no_show' => __('No Show', 'instinctor'),
        ];
        
        wp_send_json_success([
            'id' => $booking->id,
            'service_name' => $booking->service_name ?? '',
            'customer_name' => $customerName ?: 'Customer #' . $booking->customer_id,
            'email' => $booking->email,
            'phone' => $booking->phone,
            'date' => date_i18n(\Instinctor\Core\Config::get('date_format', 'Y-m-d'), strtotime($booking->start_time)),
            'time' => date_i18n(\Instinctor\Core\Config::get('time_format', 'H:i'), strtotime($booking->start_time)) . ' - ' . date_i18n(\Instinctor\Core\Config::get('time_format', 'H:i'), strtotime($booking->end_time)),
            'status' => $booking->status,
            'status_label' => $statusLabels[$booking->status] ?? ucfirst($booking->status),
            'note' => $booking->customer_note,
            'order_id' => $booking->order_id,
            'staff_id' => (int) ($booking->staff_id ?? 0),
            'staff_name' => $booking->staff_name ?? null,
            // Present only when this visit belongs to a standing appointment. The
            // owner manages the series from any of its visits, which is how they
            // think of it ("my Tuesday client"), rather than from a separate screen.
            'series' => !empty($booking->series_id)
                ? \Instinctor\Services\BookingService::instance()->seriesSummary(
                    ($booking->owner_id === null || $booking->owner_id === '') ? null : (int) $booking->owner_id,
                    (int) $booking->series_id)
                : null,
        ]);
    }

    /**
     * Stop a standing appointment: cancel the visits still ahead, keep the ones
     * already served.
     *
     * Scope is deliberately NOT a parameter from the client. 'all' would cancel
     * appointments the shop has already delivered, which is history, not a
     * schedule; nothing in this screen should be able to rewrite that.
     */
    public function ajaxStopBookingSeries(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        $seriesId  = intval($_POST['series_id'] ?? 0);
        $bookingId = intval($_POST['booking_id'] ?? 0);
        if (!$seriesId || !$bookingId) {
            wp_send_json_error(['message' => 'Invalid schedule']);
        }
        // Identity comes from the booking the admin opened this from, and that
        // booking must actually belong to the series being stopped. Ambient owner
        // context is not it: an admin session carries no tenant, so scoping by it
        // would refuse every tenant's series while still leaving the id guessable.
        $p       = DB::prefix();
        $anchor  = DB::get_row(DB::prepare(
            "SELECT owner_id, series_id FROM {$p}instinctor_bookings WHERE id = %d", $bookingId));
        if (!$anchor || (int) $anchor->series_id !== $seriesId) {
            wp_send_json_error(['message' => 'That schedule could not be found']);
        }
        $res = \Instinctor\Services\BookingService::instance()->cancelSeries(
            ($anchor->owner_id === null || $anchor->owner_id === '') ? null : (int) $anchor->owner_id,
            $seriesId, 'future'
        );
        if (empty($res['success'])) {
            wp_send_json_error(['message' => (string) ($res['error'] ?? 'Could not stop that schedule')]);
        }
        $n = (int) ($res['cancelled'] ?? 0);
        wp_send_json_success([
            'cancelled' => $n,
            'message'   => $n === 1 ? 'Stopped. One upcoming visit cancelled.'
                                    : 'Stopped. ' . $n . ' upcoming visits cancelled.',
        ]);
    }

    /**
     * Owner-side staff assignment: assign (or unassign) the specialist handling a
     * booking / reservation / work order / job. Customers never pick staff here
     * (the salon frontend picker is a separate flow); this is the admin override.
     */
    public function ajaxUpdateBookingStaff(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        $p         = DB::prefix();
        $bookingId = intval($_POST['booking_id'] ?? 0);
        $staffId   = intval($_POST['staff_id'] ?? 0); // 0 = unassign
        if (!$bookingId) {
            wp_send_json_error(['message' => 'Invalid booking']);
        }
        $exists = DB::get_var(DB::prepare("SELECT id FROM {$p}instinctor_bookings WHERE id = %d", $bookingId));
        if (!$exists) {
            wp_send_json_error(['message' => 'Booking not found']);
        }
        $staffName = null;
        if ($staffId) {
            $staff = DB::get_row(DB::prepare("SELECT id, name FROM {$p}instinctor_staff WHERE id = %d AND status = 'active'", $staffId));
            if (!$staff) {
                wp_send_json_error(['message' => 'Staff member not found']);
            }
            $staffName = $staff->name;
        }
        DB::update($p . 'instinctor_bookings', ['staff_id' => $staffId ?: null], ['id' => $bookingId]);
        wp_send_json_success(['staff_id' => $staffId, 'staff_name' => $staffName]);
    }

    /**
     * Create a manual booking
     */
    public function ajaxCreateBooking(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        

        $p = DB::prefix();
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $productId = intval($_POST['product_id'] ?? 0);
        $customerEmail = sanitize_email($_POST['customer_email'] ?? '');
        $customerName = sanitize_text_field($_POST['customer_name'] ?? '');
        $customerPhone = sanitize_text_field($_POST['customer_phone'] ?? '');
        $date = sanitize_text_field($_POST['booking_date'] ?? '');
        $time = sanitize_text_field($_POST['booking_time'] ?? '');
        $note = sanitize_textarea_field($_POST['note'] ?? '');

        if (!$productId || !$customerEmail || !$date || !$time) {
            wp_send_json_error(['message' => 'Missing required fields']);
        }

        
        // Get or create customer. Email is unique per owner, not per install
        // (0058), so the lookup must stay inside the current tenant scope --
        // on the vendor/single-tenant admin that is "owner_id IS NULL", the
        // same rows an unscoped query returned before.
        $customer = DB::get_row(DB::prepare(
            "SELECT id FROM {$p}instinctor_customers WHERE email = %s
               AND " . \Instinctor\Core\Tenant::scopeSql('owner_id'),
            $customerEmail
        ));

        if ($customer) {
            $customerId = $customer->id;
            // Update phone if provided
            if ($customerPhone) {
                DB::update(
                    DB::prefix() . 'instinctor_customers',
                    ['phone' => $customerPhone],
                    ['id' => $customerId]
                );
            }
        } else {
            $nameParts = explode(' ', $customerName, 2);
            DB::insert(DB::prefix() . 'instinctor_customers', [
                'email' => $customerEmail,
                'first_name' => $nameParts[0] ?? '',
                'last_name' => $nameParts[1] ?? '',
                'phone' => $customerPhone,
                'owner_id' => \Instinctor\Core\Tenant::currentOwnerId(),
                'created_at' => current_time('mysql')
            ]);
            $customerId = DB::insert_id();
        }

        $bookingService = \Instinctor\Services\BookingService::instance();
        $result = $bookingService->createBooking([
            'product_id' => $productId,
            'customer_id' => $customerId,
            'date' => $date,
            'time' => $time,
            'note' => $note,
            'created_via' => 'admin'
        ]);

        if ($result['success']) {
            // Mark as confirmed for manual bookings
            DB::update(
                DB::prefix() . 'instinctor_bookings',
                ['status' => 'confirmed'],
                ['id' => $result['booking_id']]
            );
            wp_send_json_success(['booking_id' => $result['booking_id']]);
        } else {
            wp_send_json_error(['message' => $result['error'] ?? 'Failed to create booking']);
        }
    }

    /**
     * Update booking status
     */
    public function ajaxUpdateBookingStatus(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $bookingId = intval($_POST['booking_id'] ?? 0);
        $status = sanitize_text_field($_POST['status'] ?? '');

        if (!$bookingId || !$status) {
            wp_send_json_error(['message' => 'Invalid parameters']);
        }

        $bookingService = \Instinctor\Services\BookingService::instance();
        $result = $bookingService->updateStatus($bookingId, $status);

        if ($result['success']) {
            wp_send_json_success();
        } else {
            wp_send_json_error(['message' => $result['error'] ?? 'Failed to update']);
        }
    }

    /**
     * Save booking settings
     */
    public function ajaxSaveBookingSettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        parse_str(sanitize_text_field(wp_unslash($_POST['settings'] ?? '')), $formData);

        $bookingService = \Instinctor\Services\BookingService::instance();
        $currentSettings = $bookingService->getSettings();

        // Process business hours
        $businessHours = [];
        $days = ['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'];
        foreach ($days as $day) {
            $businessHours[$day] = [
                'enabled' => !empty($formData['business_hours'][$day]['enabled']),
                'start' => sanitize_text_field($formData['business_hours'][$day]['start'] ?? '09:00'),
                'end' => sanitize_text_field($formData['business_hours'][$day]['end'] ?? '17:00')
            ];
        }

        // Process blocked dates
        $blockedDates = [];
        if (!empty($formData['blocked_dates']) && is_array($formData['blocked_dates'])) {
            foreach ($formData['blocked_dates'] as $date) {
                $date = sanitize_text_field($date);
                if ($date && preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) {
                    $blockedDates[] = $date;
                }
            }
        }

        $settings = [
            'default_duration' => intval($formData['default_duration'] ?? 60),
            'buffer_before' => intval($formData['buffer_before'] ?? 0),
            'buffer_after' => intval($formData['buffer_after'] ?? 15),
            'min_advance' => intval($formData['min_advance'] ?? 24),
            'max_advance' => intval($formData['max_advance'] ?? 60),
            'slot_interval' => intval($formData['slot_interval'] ?? 30),
            'allow_cancellation' => !empty($formData['allow_cancellation']),
            'cancellation_deadline' => intval($formData['cancellation_deadline'] ?? 24),
            'require_payment' => !empty($formData['require_payment']),
            'auto_refund_within_window' => !empty($formData['auto_refund_within_window']),
            'send_reminders' => !empty($formData['send_reminders']),
            'reminder_hours' => intval($formData['reminder_hours'] ?? 24),
            'notify_sms' => sanitize_text_field($formData['notify_sms'] ?? ''),
            'notify_customer_sms' => !empty($formData['notify_customer_sms']),
            'business_hours' => $businessHours,
            'blocked_dates' => $blockedDates,
            'timezone' => sanitize_text_field($formData['timezone'] ?? wp_timezone_string())
        ];

        $bookingService->saveSettings($settings);

        wp_send_json_success(['message' => 'Settings saved']);
    }

    // ── Instructor vertical: class schedule CRUD ────────────────────────────

    
    
    /**
     * Handle bug report submission
     */
    
    // =========================================================================
    // IMPULSE WORDPRESS PAGES INTEGRATION
    // =========================================================================
    
    /**
     * Check if a page is a Impulse page
     * Checks both _impulse_enabled meta AND presence of _impulse_elements or _impulse_settings
     */
    public static function isImpulsePage(int $post_id): bool
    {
        if (!$post_id) {
            return false;
        }
        
        // Check explicit flag first
        $enabled = get_post_meta($post_id, '_impulse_enabled', true);
        if ($enabled === '1' || $enabled === 1 || $enabled === true) {
            return true;
        }
        
        // Fallback: check for presence of Impulse elements or settings
        $elements = get_post_meta($post_id, '_impulse_elements', true);
        $settings = get_post_meta($post_id, '_impulse_settings', true);
        
        // If either elements or settings exist, it's a Impulse page
        if (!empty($elements) || !empty($settings)) {
            // Auto-fix: set the enabled flag for future checks
            update_post_meta($post_id, '_impulse_enabled', '1');
            return true;
        }
        
        return false;
    }
    
    /**
     * Redirect standard edit links to Impulse for Impulse-enabled pages
     */
    public function redirectImpulseEdit(): void
    {
        global $pagenow;
        
        if ($pagenow !== 'post.php') {
            return;
        }
        
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $post_id = isset($_GET['post']) ? intval($_GET['post']) : 0;
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended
        $action = isset($_GET['action']) ? sanitize_text_field($_GET['action']) : '';
        
        if (!$post_id || $action !== 'edit') {
            return;
        }
        
        $post = get_post($post_id);
        if (!$post || $post->post_type !== 'page') {
            return;
        }

        // Only redirect users who can actually edit THIS page into the Impulse
        // editor. A user-supplied ?post=<id> must not bypass per-post caps
        // (the generic admin-panel gate is not page-specific). WP.org round 4.
        if (!current_user_can('edit_post', $post_id)) {
            return;
        }

        if (self::isImpulsePage($post_id)) {
            wp_safe_redirect(admin_url('admin.php?page=instinctor&action=edit&page_id=' . $post_id));
            exit;
        }
    }
    
    /**
     * Add "Edit with Impulse" action to Pages list
     */
    public function addImpulseRowAction(array $actions, \WP_Post $post): array
    {
        if ($post->post_type !== 'page') {
            return $actions;
        }
        
        $impulse_url = admin_url('admin.php?page=instinctor&action=edit&page_id=' . $post->ID);
        
        if (self::isImpulsePage($post->ID)) {
            // Replace Edit link with Impulse edit
            $actions['edit'] = '<a href="' . esc_url($impulse_url) . '">Edit</a>';
            // Remove Quick Edit for Impulse pages
            unset($actions['inline hide-if-no-js']);
        } else {
            // Add "Edit with Impulse" option for regular pages
            $actions['impulse'] = '<a href="' . esc_url($impulse_url . '&convert=1') . '">Edit with Impulse</a>';
        }
        
        return $actions;
    }
    
    /**
     * Filter the edit post link for Impulse pages
     */
    public function filterImpulseEditLink(string $link, int $post_id, string $context): string
    {
        $post = get_post($post_id);
        if (!$post || $post->post_type !== 'page') {
            return $link;
        }
        
        if (self::isImpulsePage($post_id)) {
            return admin_url('admin.php?page=instinctor&action=edit&page_id=' . $post_id);
        }
        
        return $link;
    }
    
    /**
     * Disable Gutenberg for Impulse-enabled pages
     */
    public function disableGutenbergForImpulse(bool $use, \WP_Post $post): bool
    {
        if ($post->post_type === 'page' && self::isImpulsePage($post->ID)) {
            return false;
        }
        return $use;
    }
    
    /**
     * Add meta box showing Impulse edit button on page edit screen
     */
    public function addImpulseMetaBox(): void
    {
        add_meta_box(
            'impulse_edit_box',
            'Impulse',
            [$this, 'renderImpulseMetaBox'],
            'page',
            'side',
            'high'
        );
    }
    
    /**
     * Render Impulse meta box content
     */
    public function renderImpulseMetaBox(\WP_Post $post): void
    {
        $impulse_url = admin_url('admin.php?page=instinctor&action=edit&page_id=' . $post->ID);
        
        if (self::isImpulsePage($post->ID)) {
            echo '<p>This page is built with Impulse.</p>';
            echo '<a href="' . esc_url($impulse_url) . '" class="button button-primary">Open in Impulse</a>';
        } else {
            echo '<p>Convert this page to use Impulse page builder.</p>';
            echo '<a href="' . esc_url($impulse_url . '&convert=1') . '" class="button">Edit with Impulse</a>';
        }
    }
    
    // =====================================================================
    // Impulse Products Workspace AJAX Handlers
    // =====================================================================
    
    /**
     * Get products for Impulse workspace
     */
    public function ajaxImpulseGetProducts(): void
    {
        $start_time = microtime(true);
        
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $p = DB::prefix();
        $table = $p . 'instinctor_products';
        
        // Filters
        $status = isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'all';
        $stock_filter = isset($_POST['stock_filter']) ? sanitize_text_field($_POST['stock_filter']) : '';
        $category = isset($_POST['category']) ? intval($_POST['category']) : 0;
        $search = isset($_POST['search']) ? sanitize_text_field($_POST['search']) : '';
        $page = isset($_POST['page']) ? max(1, intval($_POST['page'])) : 1;
        $per_page = 50;
        $offset = ($page - 1) * $per_page;
        
        // Build WHERE clause
        $where = ['1=1'];
        $params = [];
        
        // Status filter
        if ($status === 'all') {
            $where[] = "p.status != 'trash'";
        } elseif ($status === 'archived') {
            $where[] = "p.status = 'archived'";
        } elseif ($status === 'published') {
            $where[] = "p.status = 'published'";
        } elseif ($status === 'draft') {
            $where[] = "p.status = 'draft'";
        }
        
        // Product type filter (for bundle tab)
        $type_filter = isset($_POST['type']) ? sanitize_text_field($_POST['type']) : '';
        if (!empty($type_filter)) {
            $where[] = 'p.product_type = %s';
            $params[] = $type_filter;
        }

        // Stock filter
        if ($stock_filter === 'low_stock') {
            $threshold = Config::get('instinctor_low_stock_threshold', 5);
            $where[] = "p.manage_stock = 1 AND p.stock_quantity > 0 AND p.stock_quantity <= %d";
            $params[] = $threshold;
        } elseif ($stock_filter === 'out_of_stock') {
            $where[] = "(p.stock_status = 'outofstock' OR (p.manage_stock = 1 AND p.stock_quantity <= 0))";
        }
        
        // Category filter
        if ($category > 0) {
            $where[] = "p.id IN (SELECT product_id FROM {$p}instinctor_product_categories WHERE category_id = %d)";
            $params[] = $category;
        }
        
        // Search
        if (!empty($search)) {
            $like = '%' . DB::esc_like($search) . '%';
            $where[] = "(p.name LIKE %s OR p.sku LIKE %s)";
            $params[] = $like;
            $params[] = $like;
        }
        
        $where_sql = implode(' AND ', $where);
        
        // Get total count
        $count_sql = "SELECT COUNT(*) FROM $table p WHERE $where_sql";
        if (!empty($params)) {
            $total = DB::get_var(DB::prepare($count_sql, $params));
        } else {
            $total = DB::get_var($count_sql);
        }
        
        // Get products
        $sql = "SELECT p.id, p.name, p.slug, p.product_type, p.status, p.price, p.sale_price, 
                       p.sku, p.stock_quantity, p.stock_status, p.manage_stock, p.featured_image,
                       p.short_description, p.description, p.attributes, p.gallery_images, p.images, p.meta,
                       p.is_featured, p.sort_order, p.created_at, p.updated_at
                FROM $table p 
                WHERE $where_sql 
                ORDER BY p.sort_order ASC, p.updated_at DESC 
                LIMIT %d OFFSET %d";
        
        $params[] = $per_page;
        $params[] = $offset;
        
        $products = DB::get_results(DB::prepare($sql, $params));
        
        // Get all product IDs for batch category fetch
        $product_ids = array_map(function($p) { return $p->id; }, $products);
        $product_categories = [];
        
        if (!empty($product_ids)) {
            $ids_str = implode(',', $product_ids);
            $cat_rows = DB::get_results("
                SELECT product_id, category_id 
                FROM {$p}instinctor_product_categories 
                WHERE product_id IN ($ids_str)
            ");
            foreach ($cat_rows as $row) {
                if (!isset($product_categories[$row->product_id])) {
                    $product_categories[$row->product_id] = [];
                }
                $product_categories[$row->product_id][] = (int) $row->category_id;
            }
        }
        
        // Format products
        $result = [];
        $low_threshold = Config::get('instinctor_low_stock_threshold', 5);
        
        // Batch collect all attachment IDs for a single optimized lookup
        $all_attachment_ids = [];
        foreach ($products as $p) {
            if ($p->featured_image) {
                $all_attachment_ids[] = (int) $p->featured_image;
            }
            if ($p->gallery_images) {
                $gallery_ids = json_decode($p->gallery_images, true) ?: [];
                foreach ($gallery_ids as $img_id) {
                    $all_attachment_ids[] = (int) $img_id;
                }
            }
        }
        
        // Batch fetch all attachment URLs in one query
        $attachment_urls = [];
        if (!empty($all_attachment_ids)) {
            $unique_ids = array_unique($all_attachment_ids);
            $ids_placeholder = implode(',', $unique_ids);
            
            // Try instinctor_images table first (standalone-compatible)
            $imgTable = DB::prefix() . 'instinctor_images';
            $imgRows = DB::get_results(
                "SELECT id, rel_path FROM {$imgTable} WHERE id IN ({$ids_placeholder}) AND parent_id IS NULL"
            );
            $base_url = Storage::baseurl();
            foreach ($imgRows as $row) {
                if (!empty($row->rel_path)) {
                    $attachment_urls[(int)$row->id] = $base_url . '/' . ltrim($row->rel_path, '/');
                }
            }
            
            // Fallback: any IDs not found in instinctor_images — try WP media
            $missing = array_diff($unique_ids, array_keys($attachment_urls));
            if (!empty($missing) && (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress')) {
                global $wpdb;
                $miss_placeholder = implode(',', $missing);
                $attachments = DB::get_results("
                    SELECT p.ID, pm.meta_value as file
                    FROM {$wpdb->posts} p
                    LEFT JOIN {$wpdb->postmeta} pm ON p.ID = pm.post_id AND pm.meta_key = '_wp_attached_file'
                    WHERE p.ID IN ($miss_placeholder)
                ");
                foreach ($attachments as $att) {
                    if ($att->file) {
                        $file_info = pathinfo($att->file);
                        $thumbnail_file = $file_info['dirname'] . '/' . $file_info['filename'] . '-150x150.' . $file_info['extension'];
                        if (Storage::exists($thumbnail_file)) {
                            $attachment_urls[$att->ID] = $base_url . '/' . $thumbnail_file;
                        } else {
                            $attachment_urls[$att->ID] = $base_url . '/' . $att->file;
                        }
                    }
                }
            }
        }
        
        foreach ($products as $p) {
            // Get image URL from batch lookup
            $image_url = '';
            if ($p->featured_image && isset($attachment_urls[$p->featured_image])) {
                $image_url = $attachment_urls[$p->featured_image];
            }
            // Fallback to images JSON field (URL-based)
            if (empty($image_url) && !empty($p->images)) {
                $imagesData = json_decode($p->images, true);
                if (is_array($imagesData) && !empty($imagesData[0]['url'])) {
                    $image_url = $imagesData[0]['url'];
                }
            }
            
            // Determine stock status for display
            $display_stock = 'in_stock';
            if ($p->stock_status === 'outofstock' || ($p->manage_stock && $p->stock_quantity <= 0)) {
                $display_stock = 'out';
            } elseif ($p->manage_stock && $p->stock_quantity <= $low_threshold) {
                $display_stock = 'low';
            }
            
            // Parse meta JSON
            $meta = $p->meta ? json_decode($p->meta, true) : [];
            
            // Parse gallery images from batch lookup
            $gallery = [];
            if ($p->gallery_images) {
                $gallery_ids = json_decode($p->gallery_images, true) ?: [];
                foreach ($gallery_ids as $img_id) {
                    if (isset($attachment_urls[$img_id])) {
                        $gallery[] = ['id' => (int) $img_id, 'url' => $attachment_urls[$img_id]];
                    }
                }
            }
            
            // Parse attributes
            $attributes = $p->attributes ? json_decode($p->attributes, true) : [];
            
            // Parse variations from meta
            $variations = isset($meta['variations']) ? json_decode($meta['variations'], true) : [];
            
            $result[] = [
                'id' => (int) $p->id,
                'name' => $p->name,
                'slug' => $p->slug,
                'type' => $p->product_type,
                'subscription_interval' => $meta['subscription_interval'] ?? 'monthly',
                'subscription_interval_count' => isset($meta['subscription_interval_count']) ? (int)$meta['subscription_interval_count'] : 1,
                'trial_days' => isset($meta['trial_days']) ? (int)$meta['trial_days'] : 0,
                'status' => $p->status,
                'price' => $p->price ? floatval($p->price) : null,
                'sale_price' => $p->sale_price ? floatval($p->sale_price) : null,
                'sku' => $p->sku ?: '',
                'stock' => $p->manage_stock ? (int) $p->stock_quantity : null,
                'stock_status' => $display_stock,
                'image' => $image_url ?: '',
                'featured_image' => $p->featured_image ? (int) $p->featured_image : null,
                'image_fit' => $meta['image_fit'] ?? 'cover',
                'short_description' => $p->short_description ?: '',
                'description' => $p->description ?: '',
                'meta_title' => $meta['meta_title'] ?? '',
                'meta_description' => $meta['meta_description'] ?? '',
                'gallery' => $gallery,
                'attributes' => $attributes,
                'variations' => $variations,
                'categories' => isset($product_categories[$p->id]) ? $product_categories[$p->id] : [],
                'updated_at' => $p->updated_at,
                // Bundle-specific fields (from meta)
                'bundle_type'           => $meta['bundle_type'] ?? 'fixed',
                'bundle_discount_type'  => $meta['bundle_discount_type'] ?? 'percent',
                'bundle_discount_value' => isset($meta['bundle_discount_value']) ? floatval($meta['bundle_discount_value']) : 0,
                'bundle_min'            => isset($meta['bundle_min']) ? intval($meta['bundle_min']) : 0,
                'bundle_max'            => isset($meta['bundle_max']) ? intval($meta['bundle_max']) : 0,
                'bundle_products'       => $meta['bundle_products'] ?? '[]',
                'bundle_product_names'  => $meta['bundle_product_names'] ?? [],
                'bundle_product_images' => $meta['bundle_product_images'] ?? [],
            ];
        }
        
        $elapsed = round((microtime(true) - $start_time) * 1000);
        
        wp_send_json_success([
            'products' => $result,
            'total' => (int) $total,
            'page' => $page,
            'per_page' => $per_page,
            'pages' => ceil($total / $per_page),
            'query_time_ms' => $elapsed
        ]);
    }
    
    /**
     * Get categories for Impulse workspace
     */
    public function ajaxImpulseGetCategories(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $p = DB::prefix();
        $table = $p . 'instinctor_categories';
        
        $categories = DB::get_results("
            SELECT c.id, c.name, c.slug, c.parent_id, c.sort_order,
                   (SELECT COUNT(*) FROM {$p}instinctor_product_categories pc WHERE pc.category_id = c.id) as product_count
            FROM $table c 
            WHERE c.status = 'active'
            ORDER BY c.parent_id ASC, c.sort_order ASC, c.name ASC
        ");
        
        // Build tree structure
        $tree = [];
        $lookup = [];
        
        foreach ($categories as $cat) {
            $lookup[$cat->id] = [
                'id' => (int) $cat->id,
                'name' => $cat->name,
                'slug' => $cat->slug,
                'parent_id' => $cat->parent_id ? (int) $cat->parent_id : null,
                'count' => (int) $cat->product_count,
                'children' => []
            ];
        }
        
        foreach ($lookup as $id => &$cat) {
            if ($cat['parent_id'] && isset($lookup[$cat['parent_id']])) {
                $lookup[$cat['parent_id']]['children'][] = &$cat;
            } else {
                $tree[] = &$cat;
            }
        }
        
        wp_send_json_success(['categories' => $tree]);
    }
    
    /**
     * Create category for Impulse workspace
     */
    public function ajaxImpulseCreateCategory(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
        $parent_id = isset($_POST['parent_id']) ? intval($_POST['parent_id']) : 0;
        
        if (empty($name)) {
            wp_send_json_error(['message' => 'Category name is required']);
        }
        
        $table = DB::prefix() . 'instinctor_categories';
        
        // Generate slug
        $slug = sanitize_title($name);
        $original_slug = $slug;
        $counter = 1;
        while (DB::get_var(DB::prepare("SELECT id FROM $table WHERE slug = %s", $slug))) {
            $slug = $original_slug . '-' . $counter;
            $counter++;
        }
        
        // Get next sort order
        $max_sort = DB::get_var(DB::prepare(
            "SELECT MAX(sort_order) FROM $table WHERE parent_id = %d OR (parent_id IS NULL AND %d = 0)",
            $parent_id, $parent_id
        ));
        $sort_order = ($max_sort !== null) ? (int)$max_sort + 1 : 0;
        
        // Insert without updated_at (column may not exist in older installations)
        $insert_data = [
            'name' => $name,
            'slug' => $slug,
            'parent_id' => $parent_id > 0 ? $parent_id : null,
            'sort_order' => $sort_order,
            'status' => 'active',
            'created_at' => current_time('mysql')
        ];
        
        $result = DB::insert($table, $insert_data);
        
        if ($result) {
            wp_send_json_success([
                'message' => 'Category created',
                'id' => DB::insert_id(),
                'name' => $name,
                'slug' => $slug
            ]);
        } else {
            // Include database error for debugging
            wp_send_json_error([
                'message' => 'Failed to create category',
                'db_error' => DB::last_error()
            ]);
        }
    }
    
    /**
     * Update category for Impulse workspace
     */
    public function ajaxImpulseUpdateCategory(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        $name = isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '';
        
        if (!$id) {
            wp_send_json_error(['message' => 'Category ID is required']);
        }
        
        if (empty($name)) {
            wp_send_json_error(['message' => 'Category name is required']);
        }
        
        $table = DB::prefix() . 'instinctor_categories';
        
        // Generate new slug
        $slug = sanitize_title($name);
        $original_slug = $slug;
        $counter = 1;
        while (DB::get_var(DB::prepare("SELECT id FROM $table WHERE slug = %s AND id != %d", $slug, $id))) {
            $slug = $original_slug . '-' . $counter;
            $counter++;
        }
        
        $result = DB::update($table, [
            'name' => $name,
            'slug' => $slug,
            'updated_at' => current_time('mysql')
        ], ['id' => $id]);
        
        if ($result !== false) {
            wp_send_json_success([
                'message' => 'Category updated',
                'id' => $id,
                'name' => $name,
                'slug' => $slug
            ]);
        } else {
            wp_send_json_error(['message' => 'Failed to update category']);
        }
    }
    
    /**
     * Delete category for Impulse workspace
     */
    public function ajaxImpulseDeleteCategory(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(['message' => 'Category ID is required']);
        }
        
        $table = DB::prefix() . 'instinctor_categories';
        $productCatTable = DB::prefix() . 'instinctor_product_categories';
        
        // Check for child categories
        $children = DB::get_var(DB::prepare(
            "SELECT COUNT(*) FROM $table WHERE parent_id = %d AND status = 'active'",
            $id
        ));
        
        if ($children > 0) {
            wp_send_json_error(['message' => 'Cannot delete category with subcategories. Delete subcategories first.']);
        }
        
        // Remove product associations
        DB::delete($productCatTable, ['category_id' => $id]);
        
        // Soft delete the category
        $result = DB::update($table, [
            'status' => 'deleted'
        ], ['id' => $id]);
        
        if ($result !== false) {
            wp_send_json_success(['message' => 'Category deleted']);
        } else {
            wp_send_json_error(['message' => 'Failed to delete category']);
        }
    }
    
    /**
     * Reorder categories for Impulse workspace
     */
    public function ajaxImpulseReorderCategories(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $order = isset($_POST['order']) ? json_decode(sanitize_textarea_field(wp_unslash($_POST['order'])), true) : [];
        
        if (empty($order)) {
            wp_send_json_error(['message' => 'Order data is required']);
        }
        
        $table = DB::prefix() . 'instinctor_categories';
        
        foreach ($order as $item) {
            if (isset($item['id']) && isset($item['sort_order'])) {
                DB::update($table, [
                    'sort_order' => (int) $item['sort_order']
                ], ['id' => (int) $item['id']]);
            }
        }
        
        wp_send_json_success(['message' => 'Order updated']);
    }
    
    /**
     * Move category to new parent for Impulse workspace
     */
    public function ajaxImpulseMoveCategory(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        $parent_id = isset($_POST['parent_id']) ? intval($_POST['parent_id']) : 0;
        $target_id = isset($_POST['target_id']) ? intval($_POST['target_id']) : 0;
        $position = isset($_POST['position']) ? sanitize_text_field($_POST['position']) : 'nest';
        
        if (!$id) {
            wp_send_json_error(['message' => 'Category ID is required']);
        }
        
        $table = DB::prefix() . 'instinctor_categories';
        
        // Prevent moving to self
        if ($id === $parent_id) {
            wp_send_json_error(['message' => 'Cannot move category to itself']);
        }
        
        // Prevent moving to own descendant (check recursively)
        if ($parent_id > 0) {
            $check_id = $parent_id;
            $max_depth = 10;
            $depth = 0;
            while ($check_id > 0 && $depth < $max_depth) {
                $ancestor = DB::get_row(DB::prepare(
                    "SELECT id, parent_id FROM $table WHERE id = %d",
                    $check_id
                ));
                if (!$ancestor) break;
                if ($ancestor->id == $id) {
                    wp_send_json_error(['message' => 'Cannot move category to its own descendant']);
                }
                $check_id = $ancestor->parent_id ?: 0;
                $depth++;
            }
        }
        
        // Update parent
        $result = DB::update($table, [
            'parent_id' => $parent_id > 0 ? $parent_id : null
        ], ['id' => $id]);
        
        // If positioning relative to target, update sort order
        if ($position === 'above' || $position === 'below') {
            $target_order = DB::get_var(DB::prepare(
                "SELECT sort_order FROM $table WHERE id = %d",
                $target_id
            ));
            
            if ($target_order !== null) {
                $new_order = ($position === 'above') ? $target_order : $target_order + 1;
                
                // Shift other items
                DB::query(DB::prepare(
                    "UPDATE $table SET sort_order = sort_order + 1 
                     WHERE parent_id " . ($parent_id > 0 ? "= $parent_id" : "IS NULL") . "
                     AND sort_order >= %d AND id != %d",
                    $new_order, $id
                ));
                
                // Set new order for moved item
                DB::update($table, ['sort_order' => $new_order], ['id' => $id]);
            }
        } else {
            // Nest - put at end of children
            $max_order = DB::get_var(DB::prepare(
                "SELECT MAX(sort_order) FROM $table WHERE parent_id " . ($parent_id > 0 ? "= %d" : "IS NULL"),
                $parent_id
            ));
            DB::update($table, ['sort_order' => ($max_order ?? -1) + 1], ['id' => $id]);
        }
        
        if ($result !== false) {
            wp_send_json_success(['message' => 'Category moved']);
        } else {
            wp_send_json_error(['message' => 'Failed to move category', 'db_error' => DB::last_error()]);
        }
    }
    
    /**
     * Get inventory stats for Impulse workspace
     */
    public function ajaxImpulseGetInventoryStats(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $table = DB::prefix() . 'instinctor_products';
        $threshold = Config::get('instinctor_low_stock_threshold', 5);
        
        $total = DB::get_var("SELECT COUNT(*) FROM $table WHERE status != 'trash'");
        
        $in_stock = DB::get_var("
            SELECT COUNT(*) FROM $table 
            WHERE status != 'trash' 
            AND (stock_status = 'instock' OR (manage_stock = 0) OR (manage_stock = 1 AND stock_quantity > $threshold))
        ");
        
        $low_stock = DB::get_var(DB::prepare("
            SELECT COUNT(*) FROM $table 
            WHERE status != 'trash' 
            AND manage_stock = 1 
            AND stock_quantity > 0 
            AND stock_quantity <= %d
        ", $threshold));
        
        $out_of_stock = DB::get_var("
            SELECT COUNT(*) FROM $table 
            WHERE status != 'trash' 
            AND (stock_status = 'outofstock' OR (manage_stock = 1 AND stock_quantity <= 0))
        ");
        
        wp_send_json_success([
            'total' => (int) $total,
            'in_stock' => (int) $in_stock,
            'low_stock' => (int) $low_stock,
            'out_of_stock' => (int) $out_of_stock
        ]);
    }
    
    /**
     * Save product from Impulse workspace
     */
    public function ajaxImpulseSaveProduct(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $table = DB::prefix() . 'instinctor_products';
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        $data = [
            'name' => isset($_POST['name']) ? sanitize_text_field($_POST['name']) : '',
            'sku' => isset($_POST['sku']) ? sanitize_text_field($_POST['sku']) : '',
            'price' => isset($_POST['price']) ? floatval($_POST['price']) : null,
            'sale_price' => isset($_POST['sale_price']) && $_POST['sale_price'] !== '' ? floatval($_POST['sale_price']) : null,
            'stock_quantity' => isset($_POST['stock']) ? intval($_POST['stock']) : null,
            'status' => isset($_POST['status']) ? sanitize_text_field($_POST['status']) : 'draft'
        ];
        
        // Handle featured image
        if (isset($_POST['featured_image'])) {
            $data['featured_image'] = $_POST['featured_image'] !== '' ? intval($_POST['featured_image']) : null;
        }
        
        // Handle short description
        if (isset($_POST['short_description'])) {
            $data['short_description'] = sanitize_textarea_field($_POST['short_description']);
        }
        
        // Handle long description
        if (isset($_POST['description'])) {
            $data['description'] = wp_kses_post($_POST['description']);
        }
        
        // Handle product type
        if (isset($_POST['product_type'])) {
            $allowed_types = ['physical', 'digital', 'subscription', 'variable', 'bundle'];
            $ptype = sanitize_text_field($_POST['product_type']);
            $data['product_type'] = in_array($ptype, $allowed_types) ? $ptype : 'physical';
        } elseif (isset($_POST['type'])) {
            // Legacy fallback
            $type = sanitize_text_field($_POST['type']);
            $data['product_type'] = $type === 'variable' ? 'variable' : 'physical';
        }
        
        // Handle attributes for variable products
        if (isset($_POST['attributes'])) {
            $data['attributes'] = sanitize_text_field($_POST['attributes']); // JSON string
        }
        
        // Handle gallery images (accept both gallery[] array and gallery_json string)
        $gallery = null;
        if (isset($_POST['gallery_json'])) {
            $gallery = json_decode(sanitize_textarea_field(wp_unslash($_POST['gallery_json'])), true);
            if (!is_array($gallery)) {
                $gallery = null;
            }
        } elseif (isset($_POST['gallery']) && is_array($_POST['gallery'])) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- array of IDs cast to int below
            $gallery = wp_unslash($_POST['gallery']);
        }

        if ($gallery !== null) {
            $gallery = array_map('intval', $gallery);
            $gallery = array_filter($gallery, function($id) { return $id > 0; });
            $gallery = array_values($gallery);
            $data['gallery_images'] = wp_json_encode($gallery);
            
            // Also populate images field with URLs for frontend use
            $imgTable = DB::prefix() . 'instinctor_images';
            $images = [];
            // Add featured image first
            $feat_id = $data['featured_image'] ?? null;
            if (!$feat_id && $id > 0) {
                // Load existing featured image from DB if not in this request
                $feat_id = DB::get_var(DB::prepare("SELECT featured_image FROM $table WHERE id = %d", $id));
            }
            if ($feat_id) {
                $url = $this->resolveImageUrl((int)$feat_id, $imgTable);
                if ($url) {
                    $images[] = ['id' => (int)$feat_id, 'url' => $url];
                }
            }
            // Add gallery images
            foreach ($gallery as $attId) {
                $url = $this->resolveImageUrl($attId, $imgTable);
                if ($url) {
                    $images[] = ['id' => $attId, 'url' => $url];
                }
            }
            $data['images'] = wp_json_encode($images);
        } elseif (!empty($data['featured_image'])) {
            // If no gallery but has featured image, populate images field
            $imgTable = DB::prefix() . 'instinctor_images';
            $url = $this->resolveImageUrl($data['featured_image'], $imgTable);
            if ($url) {
                $data['images'] = wp_json_encode([['id' => $data['featured_image'], 'url' => $url]]);
            }
        }
        
        // Handle meta (meta_title, meta_description, image_fit)
        $meta = [];
        if ($id > 0) {
            // Get existing meta
            $existing = DB::get_var(DB::prepare("SELECT meta FROM $table WHERE id = %d", $id));
            if ($existing) {
                $meta = json_decode($existing, true) ?: [];
            }
        }
        if (isset($_POST['meta_title'])) {
            $meta['meta_title'] = sanitize_text_field($_POST['meta_title']);
        }
        if (isset($_POST['meta_description'])) {
            $meta['meta_description'] = sanitize_textarea_field($_POST['meta_description']);
        }
        if (isset($_POST['image_fit'])) {
            $meta['image_fit'] = sanitize_text_field($_POST['image_fit']);
        }
        if (isset($_POST['variations'])) {
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- raw JSON; structurally validated on read
            $meta['variations'] = wp_unslash($_POST['variations']);
        }
        // Bundle fields → stored in meta + synced to instinctor_product_bundles
        if (!empty($_POST['bundle_type'])) {
            $meta['bundle_type']          = sanitize_text_field($_POST['bundle_type']);
            $meta['bundle_discount_type'] = sanitize_text_field($_POST['bundle_discount_type'] ?? 'percent');
            $meta['bundle_discount_value']= floatval($_POST['bundle_discount_value'] ?? 0);
            $meta['bundle_min']           = intval($_POST['bundle_min'] ?? 0);
            $meta['bundle_max']           = intval($_POST['bundle_max'] ?? 0);

            // Parse included product IDs
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- raw JSON; structurally cast to ints below
            $bProducts = json_decode((string) wp_unslash($_POST['bundle_products'] ?? '[]'), true);
            if (!is_array($bProducts)) $bProducts = [];
            $bProducts = array_values(array_map('intval', $bProducts));
            $meta['bundle_products'] = wp_json_encode($bProducts);

            // Enrich with product names and images for display
            if (!empty($bProducts)) {
                $ptable = DB::prefix() . 'instinctor_products';
                $pids_placeholders = implode(',', array_fill(0, count($bProducts), '%d'));
                $prows = DB::get_results(
                    DB::prepare("SELECT id, name, images, featured_image FROM {$ptable} WHERE id IN ({$pids_placeholders})", ...$bProducts), // phpcs:ignore
                    ARRAY_A
                ) ?: [];
                $bNames  = [];
                $bImages = [];
                foreach ($prows as $pr) {
                    $bNames[$pr['id']] = $pr['name'];
                    $img = '';
                    if (!empty($pr['images'])) {
                        $imgs = json_decode($pr['images'], true);
                        $img = is_array($imgs) && !empty($imgs[0]['url']) ? $imgs[0]['url'] : '';
                    }
                    if (!$img && !empty($pr['featured_image'])) {
                        $src = wp_get_attachment_image_src(intval($pr['featured_image']), 'thumbnail');
                        $img = $src ? $src[0] : '';
                    }
                    if ($img) $bImages[$pr['id']] = $img;
                }
                $meta['bundle_product_names']  = $bNames;
                $meta['bundle_product_images'] = $bImages;
            } else {
                $meta['bundle_product_names']  = [];
                $meta['bundle_product_images'] = [];
            }
        }

        // Subscription fields → stored in meta
        if (isset($_POST['subscription_interval']) && $_POST['subscription_interval'] !== '') {
            $meta['subscription_interval'] = sanitize_text_field($_POST['subscription_interval']);
        }
        if (isset($_POST['subscription_interval_count']) && $_POST['subscription_interval_count'] !== '') {
            $meta['subscription_interval_count'] = max(1, (int) $_POST['subscription_interval_count']);
        }
        if (isset($_POST['trial_days']) && $_POST['trial_days'] !== '') {
            $meta['trial_days'] = max(0, (int) $_POST['trial_days']);
        }

        if (!empty($meta)) {
            $data['meta'] = wp_json_encode($meta);
        }
        
        // Generate slug if new product
        if (!$id && !empty($data['name'])) {
            $data['slug'] = sanitize_title($data['name']);
            // Make slug unique
            $base_slug = $data['slug'];
            $counter = 1;
            while (DB::get_var(DB::prepare("SELECT id FROM $table WHERE slug = %s", $data['slug']))) {
                $data['slug'] = $base_slug . '-' . $counter++;
            }
        }
        
        if ($id > 0) {
            DB::update($table, $data, ['id' => $id]);
        } else {
            if (!isset($data['product_type'])) {
                $data['product_type'] = 'physical';
            }
            $data['manage_stock'] = 1;
            DB::insert($table, $data);
            $id = DB::insert_id();
        }
        
        // Sync bundle product to instinctor_product_bundles table (for CartService compatibility)
        if (($data['product_type'] ?? '') === 'bundle' && !empty($meta['bundle_products'])) {
            $bundlesTable = DB::prefix() . 'instinctor_product_bundles';
            if (DB::get_var("SHOW TABLES LIKE '{$bundlesTable}'") === $bundlesTable) {
                $bSyncData = [
                    'name'           => $data['name'] ?? '',
                    'products'       => $meta['bundle_products'],
                    'bundle_type'    => $meta['bundle_type'] ?? 'fixed',
                    'discount_type'  => $meta['bundle_discount_type'] ?? 'percent',
                    'discount_value' => floatval($meta['bundle_discount_value'] ?? 0),
                    'min_products'   => intval($meta['bundle_min'] ?? 0),
                    'max_products'   => intval($meta['bundle_max'] ?? 0),
                    'is_active'      => ($data['status'] ?? 'draft') === 'published' ? 1 : 0,
                    'updated_at'     => current_time('mysql'),
                ];
                // Check if bundle already exists in legacy table by product id (store product_id in bundle table)
                $existingBundleId = DB::get_var(DB::prepare(
                    "SELECT id FROM {$bundlesTable} WHERE name = %s LIMIT 1",
                    $bSyncData['name']
                ));
                if ($existingBundleId) {
                    DB::update($bundlesTable, $bSyncData, ['id' => intval($existingBundleId)]);
                } else {
                    $bSyncData['created_at'] = current_time('mysql');
                    DB::insert($bundlesTable, $bSyncData);
                }
            }
        }

        // Handle categories
        if (isset($_POST['categories']) && is_array($_POST['categories'])) {
            $cat_table = DB::prefix() . 'instinctor_product_categories';
            // Remove existing
            DB::delete($cat_table, ['product_id' => $id]);
            // Add new
            foreach ($_POST['categories'] as $cat_id) {
                DB::insert($cat_table, [
                    'product_id' => $id,
                    'category_id' => intval($cat_id)
                ]);
            }
        }
        
        // Return updated product
        $product = DB::get_row(DB::prepare("SELECT * FROM $table WHERE id = %d", $id));
        
        // Get image URL
        $image_url = '';
        if ($product->featured_image) {
            $image_url = wp_get_attachment_image_url($product->featured_image, 'thumbnail');
        }
        
        wp_send_json_success([
            'id' => (int) $id,
            'product' => [
                'id' => (int) $product->id,
                'name' => $product->name,
                'slug' => $product->slug,
                'type' => $product->product_type,
                'subscription_interval' => '',
                'subscription_interval_count' => 1,
                'trial_days' => 0,
                'status' => $product->status,
                'price' => $product->price ? floatval($product->price) : null,
                'sale_price' => $product->sale_price ? floatval($product->sale_price) : null,
                'sku' => $product->sku ?: '',
                'stock' => $product->manage_stock ? (int) $product->stock_quantity : null,
                'stock_status' => $product->stock_status,
                'image' => $image_url
            ]
        ]);
    }
    
    /**
     * Delete product from Impulse workspace
     */
    public function ajaxImpulseDeleteProduct(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $id = isset($_POST['id']) ? intval($_POST['id']) : 0;
        
        if (!$id) {
            wp_send_json_error(['message' => 'Invalid product ID']);
        }
        
        // Delete product categories
        DB::delete(DB::prefix() . 'instinctor_product_categories', ['product_id' => $id]);
        
        // Delete product
        DB::delete(DB::prefix() . 'instinctor_products', ['id' => $id]);
        
        wp_send_json_success(['deleted' => $id]);
    }
    
    /**
     * Bulk actions for products from Impulse workspace
     */
    public function ajaxImpulseBulkProducts(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        

        $p = DB::prefix();
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $table = DB::prefix() . 'instinctor_products';
        
        $action = isset($_POST['bulk_action']) ? sanitize_text_field($_POST['bulk_action']) : '';
        $ids = isset($_POST['ids']) && is_array($_POST['ids']) ? array_map('intval', $_POST['ids']) : [];
        
        if (empty($ids)) {
            wp_send_json_error(['message' => 'No products selected']);
        }
        
        $idList = implode(',', $ids);
        
        switch ($action) {
            case 'publish':
                DB::query("UPDATE $table SET status = 'published' WHERE id IN ($idList)");
                break;
            case 'draft':
                DB::query("UPDATE $table SET status = 'draft' WHERE id IN ($idList)");
                break;
            case 'archive':
                DB::query("UPDATE $table SET status = 'archived' WHERE id IN ($idList)");
                break;
            case 'delete':
                DB::query("DELETE FROM {$p}instinctor_product_categories WHERE product_id IN ($idList)");
                DB::query("DELETE FROM $table WHERE id IN ($idList)");
                break;
            default:
                wp_send_json_error(['message' => 'Invalid action']);
        }
        
        wp_send_json_success(['affected' => count($ids)]);
    }
    
    /**
     * Reorder products from Impulse workspace
     */
    public function ajaxImpulseReorderProducts(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $table = DB::prefix() . 'instinctor_products';
        
        $ids = isset($_POST['product_ids']) && is_array($_POST['product_ids']) ? array_map('intval', $_POST['product_ids']) : [];
        
        if (empty($ids)) {
            wp_send_json_error(['message' => 'No products to reorder']);
        }
        
        // Update sort_order for each product
        foreach ($ids as $order => $id) {
            DB::update($table, ['sort_order' => $order], ['id' => $id]);
        }
        
        wp_send_json_success(['reordered' => count($ids)]);
    }
    
    /**
     * AJAX: Update product status (Draft/Live)
     */
    public function ajaxImpulseUpdateProductStatus(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $id = intval($_POST['id'] ?? 0);
        $status = sanitize_text_field($_POST['status'] ?? '');
        
        if (!$id || !in_array($status, ['published', 'draft'])) {
            wp_send_json_error(['message' => 'Invalid request']);
        }
        
        DB::update(DB::prefix() . 'instinctor_products', ['status' => $status], ['id' => $id]);
        
        wp_send_json_success(['message' => 'Status updated']);
    }
    
    /**
     * AJAX: Update product featured status
     */
    public function ajaxImpulseUpdateProductFeatured(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $id = intval($_POST['id'] ?? 0);
        $featured = intval($_POST['featured'] ?? 0);
        
        if (!$id) {
            wp_send_json_error(['message' => 'Invalid request']);
        }
        
        DB::update(DB::prefix() . 'instinctor_products', ['is_featured' => $featured], ['id' => $id]);
        
        wp_send_json_success(['message' => 'Featured updated']);
    }
    
    /**
     * AJAX: Trash a product (soft delete)
     */
    public function ajaxImpulseTrashProduct(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $id = intval($_POST['id'] ?? 0);
        
        if (!$id) {
            wp_send_json_error(['message' => 'Invalid request']);
        }
        
        DB::update(DB::prefix() . 'instinctor_products', ['status' => 'trash'], ['id' => $id]);
        
        wp_send_json_success(['message' => 'Product trashed']);
    }
    
    /**
     * AJAX: Restore a trashed product
     */
    public function ajaxImpulseRestoreProduct(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $id = intval($_POST['id'] ?? 0);
        
        if (!$id) {
            wp_send_json_error(['message' => 'Invalid request']);
        }
        
        DB::update(DB::prefix() . 'instinctor_products', ['status' => 'draft'], ['id' => $id]);
        
        wp_send_json_success(['message' => 'Product restored']);
    }
    
    /**
     * AJAX: Duplicate a product
     */
    public function ajaxImpulseDuplicateProduct(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $id = intval($_POST['id'] ?? 0);
        
        if (!$id) {
            wp_send_json_error(['message' => 'Invalid request']);
        }
        
        $table = DB::prefix() . 'instinctor_products';
        
        // Get the original product
        $product = DB::get_row(DB::prepare("SELECT * FROM $table WHERE id = %d", $id), ARRAY_A);
        
        if (!$product) {
            wp_send_json_error(['message' => 'Product not found']);
        }
        
        // Remove the ID and modify the name
        unset($product['id']);
        $product['name'] = $product['name'] . ' (Copy)';
        $product['sku'] = $product['sku'] ? $product['sku'] . '-copy' : '';
        $product['status'] = 'draft';
        $product['created_at'] = current_time('mysql');
        $product['updated_at'] = current_time('mysql');
        
        // Insert the new product
        DB::insert($table, $product);
        $new_id = DB::insert_id();
        
        if ($new_id) {
            wp_send_json_success(['message' => 'Product duplicated', 'id' => $new_id]);
        } else {
            wp_send_json_error(['message' => 'Error duplicating product']);
        }
    }
    
    /**
     * AJAX: "Set as front page" action from the Page Grid right-click menu.
     * Sets the global `instinctor_front_page_id`; / serves it. Pages are
     * force-published since a draft can't be served as a front page.
     */
    public function ajaxImpulseSetFrontpage(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Unauthorized');
        }

        $pageId = (int) ($_POST['page_id'] ?? 0);
        if (!$pageId) {
            wp_send_json_error('Invalid page ID');
        }

        // Force-publish the page — a draft cannot be served as any front page.
        \Instinctor\Services\PageService::update($pageId, ['status' => 'published']);

        // Set the global site front page.
        \Instinctor\Core\Config::set('instinctor_front_page_id', $pageId);
        if (function_exists('update_option')) {
            if (function_exists('wp_update_post')) {
                wp_update_post(['ID' => $pageId, 'post_status' => 'publish']);
            }
            update_option('show_on_front', 'page');
            update_option('page_on_front', $pageId);
        }
        wp_send_json_success(['scope' => 'site']);
    }

    /**
     * AJAX: Save Impulse settings
     */
    public function ajaxImpulseSaveSettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        // General settings
        if (!empty($_POST['store_name'])) {
            $store_name = sanitize_text_field($_POST['store_name']);
            Config::set('instinctor_store_name', $store_name);
            update_option('blogname', $store_name);
        }
        
        if (isset($_POST['store_description'])) {
            $store_description = sanitize_textarea_field($_POST['store_description']);
            Config::set('instinctor_store_description', $store_description);
            update_option('blogdescription', $store_description);
        }

        // Tagline — short one-liner used by the site footer and any other
        // "single sentence about the site" surface. Distinct from
        // store_description (which feeds Instagent + SEO meta and wants a
        // paragraph). Empty value is fine — the footer falls back to "©
        // {year} {site}. All rights reserved." with no em-dash clause.
        if (isset($_POST['store_tagline'])) {
            Config::set('instinctor_store_tagline', sanitize_text_field($_POST['store_tagline']));
        }

        if (isset($_POST['store_type']))  Config::set('instinctor_store_type',  sanitize_text_field($_POST['store_type']));
        // Salon mode: optional retail-products surface (off by default, booking-first).
        
        if (isset($_POST['store_email'])) Config::set('instinctor_store_email', sanitize_email($_POST['store_email']));
        if (isset($_POST['store_phone'])) Config::set('instinctor_store_phone', sanitize_text_field($_POST['store_phone']));
        if (isset($_POST['form_notify_sms'])) Config::set('instinctor_form_notify_sms', sanitize_text_field($_POST['form_notify_sms']));
        // Lead recipients: a comma-separated list, so sanitize_email (single address,
        // strips the commas) would silently collapse it to one broken value. Validate
        // per address and keep only the ones that survive, so a typo drops itself
        // instead of poisoning the whole list.
        if (isset($_POST['lead_notify_email'])) {
            $_lead_ok = [];
            foreach (preg_split('/[,;]+/', sanitize_text_field($_POST['lead_notify_email'])) ?: [] as $_lead_addr) {
                $_lead_addr = sanitize_email(trim($_lead_addr));
                if ($_lead_addr !== '' && is_email($_lead_addr)) $_lead_ok[strtolower($_lead_addr)] = $_lead_addr;
            }
            Config::set('instinctor_lead_notify_email', implode(', ', array_values($_lead_ok)));
        }
        // Reviews: Google review link + the on-the-spot review-request SMS template
        // (used by the /admin/review-request tool — QR + text-the-link).
        if (isset($_POST['google_review_url']))      Config::set('instinctor_google_review_url',      esc_url_raw($_POST['google_review_url']));
        if (isset($_POST['review_request_message'])) Config::set('instinctor_review_request_message', sanitize_textarea_field($_POST['review_request_message']));

        // Brand profile — consumed by Instagent (AI Section Generator) as the
        // store-level context fed into Claude. Stored raw; Generator formats for
        // the prompt.
        if (isset($_POST['store_industry']))    Config::set('instinctor_store_industry',    sanitize_text_field($_POST['store_industry']));
        if (isset($_POST['store_audience']))    Config::set('instinctor_store_audience',    sanitize_textarea_field($_POST['store_audience']));
        if (isset($_POST['store_tone']))        Config::set('instinctor_store_tone',        sanitize_text_field($_POST['store_tone']));
        if (isset($_POST['store_value_prop']))     Config::set('instinctor_store_value_prop',     sanitize_textarea_field($_POST['store_value_prop']));
        if (isset($_POST['store_primary_cta']))    Config::set('instinctor_store_primary_cta',    sanitize_text_field($_POST['store_primary_cta']));
        if (isset($_POST['store_primary_color']))  Config::set('instinctor_store_primary_color',  sanitize_hex_color($_POST['store_primary_color']) ?: '#1d2327');
        if (isset($_POST['store_accent_color']))   Config::set('instinctor_store_accent_color',   sanitize_hex_color($_POST['store_accent_color']) ?: '#1d2327');
        if (isset($_POST['store_voice_sample']))   Config::set('instinctor_store_voice_sample',   sanitize_textarea_field($_POST['store_voice_sample']));
        if (isset($_POST['store_locale']))         Config::set('instinctor_store_locale',         sanitize_text_field($_POST['store_locale']));

        // Site logo - standalone-only field (WP installs use the Customizer's
        // Site Identity panel instead). The favicon has no field of its own:
        // it is generated from the logo here and stored in
        // instinctor_favicon_url for the existing consumers (MarketingShell,
        // impulse-page.php, AdminShell, /favicon.ico route). The panel
        // autosaves on every general-field change, so only regenerate when
        // the logo actually changed (or no favicon exists yet) - manually
        // set favicons from before the merge survive until then.
        if (isset($_POST['site_logo_url'])) {
            $newLogo = esc_url_raw($_POST['site_logo_url']);
            $oldLogo = (string) Config::get('instinctor_site_logo_url', '');
            Config::set('instinctor_site_logo_url', $newLogo);
            if ($newLogo === '') {
                $this->deleteGeneratedFavicon();
                Config::set('instinctor_favicon_url', '');
            } elseif ($newLogo !== $oldLogo || (string) Config::get('instinctor_favicon_url', '') === '') {
                $fav = $this->generateFaviconFromLogo($newLogo);
                if ($fav !== '') {
                    $this->deleteGeneratedFavicon();
                    Config::set('instinctor_favicon_url', $fav);
                }
            }
        }

        // Store options (tax, stock) — saved from general left column
        if (isset($_POST['tax_enabled']))              Config::set('instinctor_tax_enabled',              (bool)$_POST['tax_enabled']);
        if (isset($_POST['stock_management']))         Config::set('instinctor_stock_management',         (bool)$_POST['stock_management']);
        if (isset($_POST['low_stock_notifications']))  Config::set('instinctor_low_stock_notifications',  (bool)$_POST['low_stock_notifications']);

        // Digital Downloads global defaults
        if (isset($_POST['dd_default_limit']))  Config::set('instinctor_dd_default_limit',  intval($_POST['dd_default_limit']));
        if (isset($_POST['dd_default_expiry'])) Config::set('instinctor_dd_default_expiry', intval($_POST['dd_default_expiry']));
        if (isset($_POST['dd_email_subject']))  Config::set('instinctor_dd_email_subject',  sanitize_text_field($_POST['dd_email_subject']));
        
        // Store address
        if (isset($_POST['store_address_1'])) Config::set('instinctor_store_address_1', sanitize_text_field($_POST['store_address_1']));
        if (isset($_POST['store_address_2'])) Config::set('instinctor_store_address_2', sanitize_text_field($_POST['store_address_2']));
        if (isset($_POST['store_city']))      Config::set('instinctor_store_city',      sanitize_text_field($_POST['store_city']));
        if (isset($_POST['store_state']))     Config::set('instinctor_store_state',     sanitize_text_field($_POST['store_state']));
        if (isset($_POST['store_postcode']))  Config::set('instinctor_store_postcode',  sanitize_text_field($_POST['store_postcode']));
        if (isset($_POST['store_country']))   Config::set('instinctor_country',         sanitize_text_field($_POST['store_country']));
        if (!empty($_POST['currency_code'])) {
            Config::set('instinctor_currency', sanitize_text_field($_POST['currency_code']));
        }
        if (isset($_POST['currency_symbol'])) {
            Config::set('instinctor_currency_symbol', sanitize_text_field($_POST['currency_symbol']));
        }
        
        // Stripe settings — test/live mode is auto-detected from key prefix, no explicit flag needed
        if (!empty($_POST['stripe_publishable_key'])) {
            Config::set('instinctor_stripe_publishable_key', sanitize_text_field($_POST['stripe_publishable_key']));
        }
        if (!empty($_POST['stripe_secret_key'])) {
            Config::set('instinctor_stripe_secret_key', sanitize_text_field($_POST['stripe_secret_key']));
        }
        if (isset($_POST['stripe_webhook_secret'])) {
            Config::set('instinctor_stripe_webhook_secret', sanitize_text_field($_POST['stripe_webhook_secret']));
        }

        // Domain reseller (NameSilo) — instinctor.com global secret only. Stored as
        // ONE array option (instinctor_namesilo), so read-merge-write to preserve
        // the other fields when the panel autosaves one input at a time. api_key is
        // secret: blank = keep existing. base/ns are only written when non-empty so
        // clearing them falls back to the DomainService defaults, not ''.
        if (\Instinctor\Core\Plugin::isInstinctorApp() && (
            isset($_POST['namesilo_api_key']) || isset($_POST['namesilo_base']) ||
            isset($_POST['namesilo_ns1']) || isset($_POST['namesilo_ns2']) ||
            isset($_POST['namesilo_markup_pct']) || isset($_POST['namesilo_markup_flat'])
        )) {
            $ns = (array) Config::get('instinctor_namesilo', []);
            if (!empty($_POST['namesilo_api_key'])) {
                $ns['api_key'] = sanitize_text_field($_POST['namesilo_api_key']);
            }
            foreach (['base', 'ns1', 'ns2'] as $nk) {
                if (isset($_POST["namesilo_$nk"]) && $_POST["namesilo_$nk"] !== '') {
                    $ns[$nk] = sanitize_text_field($_POST["namesilo_$nk"]);
                }
            }
            if (isset($_POST['namesilo_markup_pct']))  $ns['markup_pct']  = (float) $_POST['namesilo_markup_pct'];
            if (isset($_POST['namesilo_markup_flat'])) $ns['markup_flat'] = (float) $_POST['namesilo_markup_flat'];
            Config::set('instinctor_namesilo', $ns);
        }

        // PayPal settings — enabled when credentials present; sandbox flag from compact toggle
        if (isset($_POST['paypal_sandbox'])) {
            Config::set('instinctor_paypal_sandbox', (bool) $_POST['paypal_sandbox']);
        }
        if (!empty($_POST['paypal_client_id'])) {
            Config::set('instinctor_paypal_client_id', sanitize_text_field($_POST['paypal_client_id']));
        }
        if (!empty($_POST['paypal_secret'])) {
            Config::set('instinctor_paypal_secret', sanitize_text_field($_POST['paypal_secret']));
        }

        // Authorize.net settings
        if (isset($_POST['authorizenet_sandbox'])) {
            Config::set('instinctor_authorizenet_sandbox', (bool) $_POST['authorizenet_sandbox']);
        }
        if (!empty($_POST['authorizenet_api_login_id'])) {
            Config::set('instinctor_authorizenet_api_login_id', sanitize_text_field($_POST['authorizenet_api_login_id']));
        }
        if (!empty($_POST['authorizenet_transaction_key'])) {
            Config::set('instinctor_authorizenet_transaction_key', sanitize_text_field($_POST['authorizenet_transaction_key']));
        }
        if (!empty($_POST['authorizenet_public_client_key'])) {
            Config::set('instinctor_authorizenet_public_client_key', sanitize_text_field($_POST['authorizenet_public_client_key']));
        }

        // Offline payment methods — Cash on Delivery + Bank Transfer. No gateway:
        // the order is placed Pending and the owner marks it paid when the money
        // arrives (see the offline branch in CheckoutService). Multi-line fields
        // are sanitized per line so newlines survive without an unshimmed
        // sanitize_textarea_field on standalone.
        if (isset($_POST['cod_enabled'])) {
            Config::set('instinctor_cod_enabled', !empty($_POST['cod_enabled']));
        }
        if (isset($_POST['cod_instructions'])) {
            Config::set('instinctor_cod_instructions', implode("\n", array_map('sanitize_text_field', explode("\n", (string) wp_unslash($_POST['cod_instructions'])))));
        }
        if (isset($_POST['bank_transfer_enabled'])) {
            Config::set('instinctor_bank_transfer_enabled', !empty($_POST['bank_transfer_enabled']));
        }
        if (isset($_POST['bank_transfer_details'])) {
            Config::set('instinctor_bank_transfer_details', implode("\n", array_map('sanitize_text_field', explode("\n", (string) wp_unslash($_POST['bank_transfer_details'])))));
        }

        // Google Sign-In credentials are owned by Platform now; nothing to save here.

        // Return policy
        if (isset($_POST['return_days']))     Config::set('instinctor_return_days',       max(1, intval($_POST['return_days'])));
        if (isset($_POST['restocking_fee']))  Config::set('instinctor_restocking_fee',    max(0, floatval($_POST['restocking_fee'])));
        if (isset($_POST['rma_notify_admin'])) Config::set('instinctor_rma_notify_admin', (bool)$_POST['rma_notify_admin']);
        if (isset($_POST['rma_auto_approve'])) Config::set('instinctor_rma_auto_approve', (bool)$_POST['rma_auto_approve']);
        
        // Checkout options
        if (isset($_POST['guest_checkout']))  Config::set('instinctor_guest_checkout',  (bool)$_POST['guest_checkout']);
        if (isset($_POST['create_account'])) Config::set('instinctor_create_account', (bool)$_POST['create_account']);
        if (isset($_POST['show_coupon']))    Config::set('instinctor_show_coupon',    (bool)$_POST['show_coupon']);
        if (isset($_POST['show_gift_card'])) Config::set('instinctor_show_gift_card', (bool)$_POST['show_gift_card']);
        if (isset($_POST['email_first']))    Config::set('instinctor_email_first',    (bool)$_POST['email_first']);
        
        wp_send_json_success(['message' => 'Settings saved']);
    }
    
    /**
     * AJAX: Save global code snippets (Code & Tracking section).
     * Receives full array as JSON body; replaces entire stored list.
     */
    /**
     * Build a favicon from the site logo. SVG logos are used directly (they
     * scale perfectly at favicon sizes). Raster logos are trimmed of
     * transparent padding and contained, centered, in a 128px transparent
     * PNG square stored under instinctor-brand/. The filename is
     * content-addressed so a new logo always yields a new URL, which
     * invalidates the browser's notoriously sticky favicon cache.
     *
     * Returns the favicon URL, or '' when nothing usable could be produced.
     * Logos that cannot be resolved to a local storage file (external URL,
     * missing GD) fall back to the logo URL itself - browsers accept any
     * image as a favicon, it is just not optimised.
     */
    private function generateFaviconFromLogo(string $logoUrl): string
    {
        $urlPath = (string) wp_parse_url($logoUrl, PHP_URL_PATH);
        if (strtolower(pathinfo($urlPath, PATHINFO_EXTENSION)) === 'svg') {
            return $logoUrl;
        }

        $rel      = '';
        $basePath = rtrim((string) wp_parse_url(\Instinctor\Core\Storage::baseurl(), PHP_URL_PATH), '/');
        if ($basePath !== '' && strpos($urlPath, $basePath . '/') === 0) {
            $rel = ltrim(substr($urlPath, strlen($basePath)), '/');
        }
        if ($rel === '' || !\Instinctor\Core\Storage::exists($rel) || !function_exists('imagecreatefromstring')) {
            return $logoUrl;
        }

        $raw = \Instinctor\Core\Storage::get($rel);
        $src = $raw !== false ? @imagecreatefromstring($raw) : false;
        if (!$src) {
            return $logoUrl;
        }
        imagepalettetotruecolor($src);

        // Downscale to a working copy first so the trim scan stays cheap.
        $w = imagesx($src);
        $h = imagesy($src);
        $scale = min(1.0, 256 / max($w, $h));
        $sw = max(1, (int) round($w * $scale));
        $sh = max(1, (int) round($h * $scale));
        $work = imagecreatetruecolor($sw, $sh);
        imagealphablending($work, false);
        imagesavealpha($work, true);
        imagefill($work, 0, 0, imagecolorallocatealpha($work, 0, 0, 0, 127));
        imagecopyresampled($work, $src, 0, 0, 0, 0, $sw, $sh, $w, $h);
        imagedestroy($src);

        // Bounding box of visibly opaque pixels (GD alpha: 0 opaque, 127
        // transparent). Opaque formats like JPEG report alpha 0 everywhere,
        // so the trim is a no-op for them.
        $minX = $sw; $minY = $sh; $maxX = -1; $maxY = -1;
        for ($y = 0; $y < $sh; $y++) {
            for ($x = 0; $x < $sw; $x++) {
                if (((imagecolorat($work, $x, $y) >> 24) & 0x7F) < 102) {
                    if ($x < $minX) $minX = $x;
                    if ($x > $maxX) $maxX = $x;
                    if ($y < $minY) $minY = $y;
                    if ($y > $maxY) $maxY = $y;
                }
            }
        }
        if ($maxX < 0) {
            imagedestroy($work);
            return $logoUrl;
        }
        $bw = $maxX - $minX + 1;
        $bh = $maxY - $minY + 1;

        $size = 128;
        $fit  = min($size / $bw, $size / $bh);
        $fw   = max(1, (int) round($bw * $fit));
        $fh   = max(1, (int) round($bh * $fit));
        $fav  = imagecreatetruecolor($size, $size);
        imagealphablending($fav, false);
        imagesavealpha($fav, true);
        imagefill($fav, 0, 0, imagecolorallocatealpha($fav, 0, 0, 0, 127));
        imagecopyresampled($fav, $work, (int) (($size - $fw) / 2), (int) (($size - $fh) / 2), $minX, $minY, $fw, $fh, $bw, $bh);
        imagedestroy($work);

        ob_start();
        imagepng($fav);
        $png = (string) ob_get_clean();
        imagedestroy($fav);
        if ($png === '') {
            return $logoUrl;
        }

        $favRel = 'instinctor-brand/favicon-' . substr(md5($png), 0, 10) . '.png';
        if (!\Instinctor\Core\Storage::put($favRel, $png)) {
            return $logoUrl;
        }
        return \Instinctor\Core\Storage::url($favRel);
    }

    /**
     * Delete the previously generated favicon file, if the current favicon
     * is one of ours (under instinctor-brand/). Manually set legacy
     * favicons and logo-as-favicon URLs are left alone.
     */
    private function deleteGeneratedFavicon(): void
    {
        $old = (string) Config::get('instinctor_favicon_url', '');
        if ($old === '') return;
        $urlPath = (string) wp_parse_url($old, PHP_URL_PATH);
        if (strpos($urlPath, 'instinctor-brand/favicon-') === false) return;
        $basePath = rtrim((string) wp_parse_url(\Instinctor\Core\Storage::baseurl(), PHP_URL_PATH), '/');
        if ($basePath !== '' && strpos($urlPath, $basePath . '/') === 0) {
            \Instinctor\Core\Storage::delete(ltrim(substr($urlPath, strlen($basePath)), '/'));
        }
    }

    

    /**
     * AJAX: Save store options (from Store workspace)
     */
    public function ajaxImpulseSaveStoreOptions(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        Config::set('instinctor_tax_enabled', !empty($_POST['tax_enabled']));
        Config::set('instinctor_stock_management', !empty($_POST['stock_management']));
        Config::set('instinctor_low_stock_threshold', intval($_POST['low_stock_threshold'] ?? 5));
        
        wp_send_json_success(['message' => 'Store options saved']);
    }
    
    /**
     * AJAX: Toggle email template enabled/disabled
     */
    public function ajaxImpulseToggleEmailTemplate(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $key = sanitize_text_field($_POST['key'] ?? '');
        $enabled = !empty($_POST['enabled']);
        
        if (empty($key)) {
            wp_send_json_error(['message' => 'No template specified']);
        }
        
        $templates = Config::get('instinctor_email_templates', []);
        if (!isset($templates[$key])) {
            $templates[$key] = [];
        }
        $templates[$key]['enabled'] = $enabled;
        Config::set('instinctor_email_templates', $templates);
        
        wp_send_json_success(['message' => 'Template updated']);
    }
    
    /**
     * AJAX: Get email template for editing
     */
    public function ajaxImpulseGetEmailTemplate(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $key = sanitize_text_field($_POST['key'] ?? '');
        
        if (empty($key)) {
            wp_send_json_error(['message' => 'No template specified']);
        }
        
        $template = \Instinctor\Services\EmailService::getTemplate($key);
        $defaults = \Instinctor\Services\EmailService::getDefaultTemplates();
        
        $data = [
            'key' => $key,
            'name' => $template['name'] ?? $defaults[$key]['name'] ?? ucwords(str_replace('_', ' ', $key)),
            'subject' => $template['subject'] ?? $defaults[$key]['subject'] ?? '',
            'heading' => $template['heading'] ?? $defaults[$key]['heading'] ?? '',
            'body' => $template['body'] ?? $defaults[$key]['body'] ?? '',
            'enabled' => $template['enabled'] ?? $defaults[$key]['enabled'] ?? false,
            'include_footer' => $template['include_footer'] ?? \Instinctor\Services\EmailService::getDefaultIncludeFooter($key),
        ];
        
        wp_send_json_success($data);
    }
    
    /**
     * AJAX: Save email template
     */
    public function ajaxImpulseSaveEmailTemplate(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $key = sanitize_text_field($_POST['key'] ?? '');
        
        if (empty($key)) {
            wp_send_json_error(['message' => 'No template specified']);
        }
        
        // Get existing template data to merge with
        $existing = \Instinctor\Services\EmailService::getTemplate($key);
        $defaults = \Instinctor\Services\EmailService::getDefaultTemplates();
        $defaultTemplate = $defaults[$key] ?? [];
        
        // Build save data, using existing values for any fields not provided.
        // WP.org B1 — sanitize subject/heading (plain text), body (HTML
        // template — wp_kses_post strips scripts/iframes/event handlers).
        $saveData = [
            'subject' => isset($_POST['subject']) ? sanitize_text_field(wp_unslash((string) $_POST['subject'])) : ($existing['subject'] ?? $defaultTemplate['subject'] ?? ''),
            'heading' => isset($_POST['heading']) ? sanitize_text_field(wp_unslash((string) $_POST['heading'])) : ($existing['heading'] ?? $defaultTemplate['heading'] ?? ''),
            'body'    => isset($_POST['body'])    ? wp_kses_post(wp_unslash((string) $_POST['body']))         : ($existing['body']    ?? $defaultTemplate['body']    ?? ''),
            'enabled' => $existing['enabled'] ?? $defaultTemplate['enabled'] ?? true, // Preserve enabled state
            'include_footer' => isset($_POST['include_footer']) ? !empty($_POST['include_footer']) : ($existing['include_footer'] ?? true),
        ];
        
        $result = \Instinctor\Services\EmailService::saveTemplate($key, $saveData);
        
        if ($result) {
            wp_send_json_success(['message' => 'Template saved']);
        } else {
            wp_send_json_error(['message' => 'Failed to save template']);
        }
    }
    
    /**
     * AJAX: Send test email
     */
    public function ajaxImpulseSendTestEmail(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $key = sanitize_text_field($_POST['key'] ?? '');
        $adminEmail = Config::get('admin_email');
        
        if (empty($key)) {
            wp_send_json_error(['message' => 'No template specified']);
        }
        
        // Build test data
        $testData = [
            'customer_name' => 'Test Customer',
            'customer_email' => $adminEmail,
            'order_number' => '12345',
            'order_total' => Config::get('instinctor_currency_symbol', '$') . '99.99',
            'tracking_number' => 'TEST123456789',
            'tracking_url' => home_url(),
            'store_name' => get_bloginfo('name'),
            'rma_number' => 'RMA-0001',
            'points' => '500',
            'tier' => 'Gold',
            'new_tier' => 'Platinum',
        ];
        
        // Get template content from request (for preview of unsaved changes)
        $subject = sanitize_text_field($_POST['subject'] ?? '') ?: 'Test Email';
        $heading = sanitize_text_field($_POST['heading'] ?? '') ?: 'Test Email';
        $body = wp_kses_post(sanitize_text_field(wp_unslash($_POST['body'] ?? ''))) ?: 'This is a test email.';
        
        // Replace placeholders
        foreach ($testData as $placeholder => $value) {
            $subject = str_replace('{' . $placeholder . '}', $value, $subject);
            $heading = str_replace('{' . $placeholder . '}', $value, $heading);
            $body = str_replace('{' . $placeholder . '}', $value, $body);
        }
        
        // Build HTML email using the real template system
        $htmlEmail = \Instinctor\Services\EmailService::buildTestEmail($heading, $body);
        
        // Send via EmailService relay (same path as real transactional emails)
        $result = \Instinctor\Services\EmailService::sendDirectEmail($adminEmail, '[TEST] ' . $subject, $htmlEmail);
        
        if ($result) {
            wp_send_json_success(['message' => 'Test email sent', 'email' => $adminEmail]);
        } else {
            wp_send_json_error(['message' => 'Failed to send test email. Check your email settings.']);
        }
    }
    
    /**
     * AJAX: Get email footer settings
     */
    public function ajaxImpulseGetEmailFooterSettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $settings = \Instinctor\Services\EmailService::getFooterSettings();
        wp_send_json_success($settings);
    }
    
    /**
     * AJAX: Save email footer settings
     */
    public function ajaxImpulseSaveEmailFooterSettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        // Merge submitted fields with existing settings — preserves extended fields
        // (show_city_state_zip, store_name_size/weight, etc.) not present in the form
        $existing = \Instinctor\Services\EmailService::getFooterSettings();

        $settings = array_merge($existing, [
            'show_logo'       => !empty($_POST['show_logo']),
            'show_store_name' => !empty($_POST['show_store_name']),
            'show_address'    => !empty($_POST['show_address']),
            'show_email'      => !empty($_POST['show_email']),
            'show_phone'      => !empty($_POST['show_phone']),
            'text_align'      => sanitize_text_field($_POST['text_align'] ?? 'center'),
            'custom_text'     => sanitize_textarea_field($_POST['custom_text'] ?? ''),
        ]);

        // update_option returns false when value is unchanged — treat that as success
        \Instinctor\Services\EmailService::saveFooterSettings($settings);
        wp_send_json_success(['message' => 'Footer settings saved']);
    }
    
        
    
    /**
     * Activate/validate license key against ILM server
     */
    public function ajaxImpulseActivateLicense(): void
    {
        // WordPress.org distribution prohibits remote license validation.
        // Pro is a separate plugin downloaded from instinctor.com — the user
        // needs to install the Pro zip via Plugins -> Add New -> Upload Plugin.
        // This message gives them the direct path to that download.
        wp_send_json_error([
            'message'  => 'Pro features require a separate plugin. Download the Pro version at https://instinctor.com/upgrade, then upload it via Plugins -> Add New -> Upload Plugin to replace this free version.',
            'cta_url'  => 'https://instinctor.com/upgrade',
            'cta_text' => 'Get Pro',
        ]);
    }
    /**
     * Proxy checkout request to ILM server (avoids CORS issues)
     */
    public function ajaxImpulseProxyCheckout(): void
    {
        // WordPress.org build can't run in-plugin checkout; send the
        // 'Upgrade to Pro' button to the external purchase page (the admin
        // JS reads checkout_url and redirects there). The customer buys,
        // downloads the Pro plugin zip, and gets a license key.
        wp_send_json_success(['checkout_url' => 'https://instinctor.com/upgrade']);
    }
    /**
     * AJAX: Save AfterShip settings
     */
    public function ajaxSaveAfterShip(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $result = \Instinctor\Services\AfterShipService::saveSettings([
            'enabled' => !empty($_POST['enabled']),
            'api_key' => sanitize_text_field($_POST['api_key'] ?? ''),
            'update_frequency' => sanitize_text_field($_POST['update_frequency'] ?? 'hourly'),
            'auto_detect_carrier' => true,
        ]);
        
        if ($result) {
            wp_send_json_success([
                'message' => 'AfterShip settings saved',
                'settings' => \Instinctor\Services\AfterShipService::getSettings()
            ]);
        } else {
            wp_send_json_error(['message' => 'Failed to save settings']);
        }
    }
    
    /**
     * AJAX: Test AfterShip connection
     */
    public function ajaxTestAfterShip(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        // Temporarily set the API key for testing
        $apiKey = sanitize_text_field($_POST['api_key'] ?? '');
        
        if (empty($apiKey)) {
            wp_send_json_error(['message' => 'API key is required']);
        }
        
        // Save temporarily for the test
        $currentSettings = \Instinctor\Services\AfterShipService::getSettings();
        \Instinctor\Services\AfterShipService::saveSettings([
            'enabled' => true,
            'api_key' => $apiKey,
            'update_frequency' => $currentSettings['update_frequency'] ?? 'hourly',
        ]);
        
        $result = \Instinctor\Services\AfterShipService::testConnection();
        
        // Restore original settings if test was just for validation
        if (!$currentSettings['enabled']) {
            \Instinctor\Services\AfterShipService::saveSettings($currentSettings);
        }
        
        if ($result['success']) {
            wp_send_json_success($result);
        } else {
            wp_send_json_error($result);
        }
    }
    
    /**
     * Save workspace preferences (order + default) per user
     */
    public function ajaxSaveWorkspacePrefs(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('edit_posts')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $user_id = Auth::userId();
        $valid_workspaces = ['chat', 'store', 'impulse'];

        // Save order
        if (isset($_POST['order'])) {
            $order = array_map('sanitize_text_field', (array) $_POST['order']);
            $order = array_values(array_intersect($order, $valid_workspaces));
            if (count($order) === 3) {
                if (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress') {
                    update_user_meta($user_id, 'instinctor_workspace_order', $order);
                } else {
                    Config::set('instinctor_workspace_order_' . $user_id, $order);
                }
            }
        }

        // Save store section default
        $valid_store_sections = ['orders', 'products', 'bookings', 'settings'];
        if (isset($_POST['default_store_section'])) {
            $val = sanitize_text_field($_POST['default_store_section']);
            if (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress') {
                if (in_array($val, $valid_store_sections)) update_user_meta($user_id, 'instinctor_store_section_default', $val);
                else delete_user_meta($user_id, 'instinctor_store_section_default');
            } else {
                if (in_array($val, $valid_store_sections)) Config::set('instinctor_store_section_default_' . $user_id, $val);
                else Config::delete('instinctor_store_section_default_' . $user_id);
            }
        }

        // Save SEO tab default
        $valid_seo_tabs = ['pages', 'settings'];
        if (isset($_POST['default_seo_tab'])) {
            $val = sanitize_text_field($_POST['default_seo_tab']);
            if (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress') {
                if (in_array($val, $valid_seo_tabs)) update_user_meta($user_id, 'instinctor_seo_tab_default', $val);
                else delete_user_meta($user_id, 'instinctor_seo_tab_default');
            } else {
                if (in_array($val, $valid_seo_tabs)) Config::set('instinctor_seo_tab_default_' . $user_id, $val);
                else Config::delete('instinctor_seo_tab_default_' . $user_id);
            }
        }

        // Save device default
        $valid_devices = ['desktop', 'tablet', 'mobile'];
        if (isset($_POST['default_device'])) {
            $val = sanitize_text_field($_POST['default_device']);
            if (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress') {
                if (in_array($val, $valid_devices)) update_user_meta($user_id, 'instinctor_device_default', $val);
                else delete_user_meta($user_id, 'instinctor_device_default');
            } else {
                if (in_array($val, $valid_devices)) Config::set('instinctor_device_default_' . $user_id, $val);
                else Config::delete('instinctor_device_default_' . $user_id);
            }
        }
        
        wp_send_json_success();
    }
    
    /**
     * Dismiss an upsell prompt
     */
    public function ajaxDismissUpsell(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }
        
        $key = sanitize_text_field($_POST['key'] ?? '');
        if (empty($key)) {
            wp_send_json_error(['message' => 'No key specified']);
        }
        
        $dismissed = Config::get('instinctor_upsell_dismissed', []);
        $dismissed[$key] = current_time('timestamp');
        Config::set('instinctor_upsell_dismissed', $dismissed);
        
        wp_send_json_success();
    }

    /**
     * Get team members with Instinctor roles/capabilities.
     */
    public function ajaxGetTeam(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $rm        = \Instinctor\Core\RoleManager::instance();
        $users     = $rm->getTeamMembers(); // dual-mode: WP or standalone
        $roles     = $rm->getAssignableRoles();
        $available = [];

        // WP mode only: populate the "add existing WP user" dropdown
        $isWP = !defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress';
        if ($isWP && function_exists('get_users')) {
            $all_users = get_users(['fields' => ['ID', 'display_name', 'user_email']]);
            foreach ($all_users as $u) {
                $available[] = [
                    'id'    => $u->ID,
                    'name'  => $u->display_name,
                    'email' => $u->user_email,
                ];
            }
        }

        wp_send_json_success([
            'team'      => $users,
            'available' => $available,
            'roles'     => $roles,
        ]);
    }

    /**
     * Set a user's Instinctor role.
     */
    public function ajaxSetUserRole(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied']);
        }

        $user_id = intval($_POST['user_id'] ?? 0);
        $role = sanitize_text_field($_POST['role'] ?? '');
        
        if (!$user_id) {
            wp_send_json_error(['message' => 'Invalid user']);
        }

        $rm = \Instinctor\Core\RoleManager::instance();
        $result = $rm->setTeamMemberRole($user_id, $role); // dual-mode
        
        if (!$result) {
            wp_send_json_error(['message' => 'Cannot modify administrator roles']);
        }

        wp_send_json_success(['message' => 'Role updated']);
    }

    // =========================================================================
    // DIGITAL DOWNLOAD HANDLERS
    // =========================================================================

    public function ajaxGetProductDownloads(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }
        $productId = (int) ($_POST['product_id'] ?? 0);
        if (!$productId) { wp_send_json_error('Missing product_id'); return; }
        $service = \Instinctor\Services\DigitalDownloadService::instance();
        wp_send_json_success(['downloads' => $service->getProductDownloads($productId)]);
    }

    public function ajaxSaveProductDownload(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }
        $productId = (int) ($_POST['product_id'] ?? 0);
        if (!$productId) { wp_send_json_error('Missing product_id'); return; }
        $service = \Instinctor\Services\DigitalDownloadService::instance();
        $id = $service->saveDownload($productId, $_POST);
        if ($id === false) { wp_send_json_error('Failed to save download'); return; }
        wp_send_json_success(['id' => $id, 'downloads' => $service->getProductDownloads($productId)]);
    }

    public function ajaxDeleteProductDownload(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }
        $downloadId = (int) ($_POST['download_id'] ?? 0);
        $productId  = (int) ($_POST['product_id'] ?? 0);
        if (!$downloadId || !$productId) { wp_send_json_error('Missing params'); return; }
        $service = \Instinctor\Services\DigitalDownloadService::instance();
        $service->deleteDownload($downloadId, $productId);
        wp_send_json_success(['downloads' => $service->getProductDownloads($productId)]);
    }

    public function ajaxToggleProductDownloadable(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }
        $productId    = (int) ($_POST['product_id'] ?? 0);
        $downloadable = (sanitize_text_field(wp_unslash($_POST['downloadable'] ?? '0'))) === '1' ? 1 : 0;
        if (!$productId) { wp_send_json_error('Missing product_id'); return; }
        DB::update(DB::prefix() . 'instinctor_products', ['downloadable' => $downloadable], ['id' => $productId]);
        wp_send_json_success(['downloadable' => $downloadable]);
    }

    public function ajaxGetCustomerDownloads(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }
        $customerId = (int) ($_POST['customer_id'] ?? 0);
        if (!$customerId) { wp_send_json_error('Missing customer_id'); return; }
        $service = \Instinctor\Services\DigitalDownloadService::instance();
        wp_send_json_success(['downloads' => $service->getCustomerDownloads($customerId)]);
    }

    public function ajaxGetDownloadStats(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }
        $service = \Instinctor\Services\DigitalDownloadService::instance();
        wp_send_json_success($service->getDownloadStats());
    }

    public function ajaxSaveDDSettings(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }
        $limit   = isset($_POST['default_limit']) && $_POST['default_limit'] !== '' ? max(0, (int)$_POST['default_limit']) : '';
        $expiry  = isset($_POST['default_expiry']) && $_POST['default_expiry'] !== '' ? max(0, (int)$_POST['default_expiry']) : '';
        $subject = sanitize_text_field($_POST['email_subject'] ?? 'Your download is ready');
        Config::set('instinctor_dd_default_limit',   $limit);
        Config::set('instinctor_dd_default_expiry',  $expiry);
        Config::set('instinctor_dd_email_subject',   $subject);
        wp_send_json_success(['message' => 'Settings saved']);
    }

    // =========================================================================
    // BILLING DASHBOARD HANDLERS (M4)
    // =========================================================================

    public function ajaxGetBillingData(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');

        $p = DB::prefix();
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }

        $table = DB::prefix() . 'instinctor_licenses';

        // Licenses with customer email joined
        $licenses = DB::get_results(
            "SELECT l.id, l.license_key, l.addon, l.status, l.activation_limit, l.expires_at, l.created_at,
                    c.email AS customer_email,
                    (SELECT COUNT(*) FROM {$p}instinctor_license_activations a WHERE a.license_id = l.id) AS activation_count
             FROM {$table} l
             LEFT JOIN {$p}instinctor_customers c ON c.id = l.customer_id
             ORDER BY l.created_at DESC
             LIMIT 200",
            ARRAY_A
        ) ?: [];

        $activeCount = 0;
        foreach ($licenses as $l) {
            if ($l['status'] === 'active') $activeCount++;
        }

        // MRR: sum from active subscriptions
        $mrr = (float) DB::get_var(
            "SELECT SUM(CASE WHEN period = 'year' THEN price/12 ELSE price END)
             FROM {$p}instinctor_subscriptions
             WHERE status = 'active'"
        );

        wp_send_json_success([
            'licenses'     => $licenses,
            'active_count' => $activeCount,
            'mrr'          => number_format($mrr, 2),
        ]);
    }

    public function ajaxSaveStripePlans(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }

        $allowed = ['builder_monthly', 'builder_annual', 'business_monthly', 'business_annual'];
        $plans   = (array) ($_POST['plans'] ?? []);

        foreach ($allowed as $key) {
            if (isset($plans[$key])) {
                Config::set('instinctor_stripe_plan_' . $key, sanitize_text_field($plans[$key]));
            }
        }

        wp_send_json_success(['message' => 'Plans saved']);
    }

    public function ajaxSaveBillingUrls(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) { wp_send_json_error('Permission denied'); return; }

        $allowed = ['instinctor_pricing_page_url', 'instinctor_account_page_url', 'instinctor_support_url'];
        $urls    = (array) ($_POST['urls'] ?? []);

        foreach ($allowed as $key) {
            if (isset($urls[$key])) {
                update_option($key, esc_url_raw($urls[$key]));
            }
        }

        wp_send_json_success(['message' => 'URLs saved']);
    }

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

    public function ajaxGetBundles(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $bundles = \Instinctor\Services\ProductBundleService::getBundles();

        // Enrich with product names for display
        $products_table = DB::prefix() . 'instinctor_products';
        foreach ($bundles as &$bundle) {
            $bundle['products'] = json_decode($bundle['products'] ?? '[]', true) ?: [];
            if (!empty($bundle['products'])) {
                $ids = array_map('intval', $bundle['products']);
                $placeholders = implode(',', array_fill(0, count($ids), '%d'));
                $rows = DB::get_results(
                    DB::prepare("SELECT id, name, images, featured_image FROM {$products_table} WHERE id IN ({$placeholders})", ...$ids), // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
                    ARRAY_A
                ) ?: [];
                $bundle['product_names']  = array_column($rows, 'name', 'id');
                $bundle['product_images'] = [];
                foreach ($rows as $row) {
                    $img = '';
                    if (!empty($row['images'])) {
                        $imgs = json_decode($row['images'], true);
                        $img = is_array($imgs) && !empty($imgs[0]['url']) ? $imgs[0]['url'] : (is_array($imgs) && !empty($imgs[0]) && is_string($imgs[0]) ? $imgs[0] : '');
                    }
                    if (!$img && !empty($row['featured_image'])) {
                        $src = wp_get_attachment_image_src(intval($row['featured_image']), 'thumbnail');
                        $img = $src ? $src[0] : '';
                    }
                    if ($img) $bundle['product_images'][$row['id']] = $img;
                }
            } else {
                $bundle['product_names']  = [];
                $bundle['product_images'] = [];
            }
        }

        wp_send_json_success(['bundles' => $bundles]);
    }

    public function ajaxSaveBundle(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $data = [
            'id'             => intval($_POST['id'] ?? 0),
            'name'           => sanitize_text_field($_POST['name'] ?? ''),
            'description'    => sanitize_textarea_field($_POST['description'] ?? ''),
            'bundle_type'    => sanitize_text_field($_POST['bundle_type'] ?? 'fixed'),
            'discount_type'  => sanitize_text_field($_POST['discount_type'] ?? 'percent'),
            'discount_value' => floatval($_POST['discount_value'] ?? 0),
            'min_products'   => intval($_POST['min_products'] ?? 0),
            'max_products'   => intval($_POST['max_products'] ?? 0),
            'is_active'      => intval($_POST['is_active'] ?? 1),
            'products'       => array_map('intval', (array) ($_POST['products'] ?? [])),
        ];

        if (empty($data['name'])) {
            wp_send_json_error('Bundle name is required');
        }

        $id = \Instinctor\Services\ProductBundleService::saveBundle($data);
        if (!$id) {
            wp_send_json_error('Failed to save bundle');
        }

        wp_send_json_success(['id' => $id, 'message' => 'Bundle saved']);
    }

    public function ajaxDeleteBundle(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

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

        if (\Instinctor\Services\ProductBundleService::deleteBundle($id)) {
            wp_send_json_success(['message' => 'Bundle deleted']);
        } else {
            wp_send_json_error('Failed to delete bundle');
        }
    }

    // =========================================================================
    // PRODUCT ADD-ON HANDLERS
    // =========================================================================

    public function ajaxGetProductAddons(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $productId = intval($_GET['product_id'] ?? 0);

        if ($productId) {
            $addons = \Instinctor\Services\ProductAddonService::getAllAddons($productId);
        } else {
            // All global add-ons for the management list
            $table  = DB::prefix() . 'instinctor_product_addons';
            $addons = DB::get_results(
                "SELECT * FROM {$table} ORDER BY product_id IS NULL DESC, sort_order ASC",
                ARRAY_A
            ) ?: [];
            foreach ($addons as &$addon) {
                $addon['options'] = json_decode($addon['options'] ?? '[]', true) ?: [];
            }
        }

        wp_send_json_success(['addons' => $addons]);
    }

    public function ajaxSaveProductAddon(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $data = [
            'id'          => intval($_POST['id'] ?? 0),
            'product_id'  => !empty($_POST['product_id']) ? intval($_POST['product_id']) : null,
            'name'        => sanitize_text_field($_POST['name'] ?? ''),
            'description' => sanitize_textarea_field($_POST['description'] ?? ''),
            'type'        => sanitize_text_field($_POST['type'] ?? 'checkbox'),
            'price'       => floatval($_POST['price'] ?? 0),
            'price_type'  => sanitize_text_field($_POST['price_type'] ?? 'flat'),
            'options'     => sanitize_textarea_field($_POST['options'] ?? ''),
            'required'    => intval($_POST['required'] ?? 0),
            'max_length'  => intval($_POST['max_length'] ?? 0),
            'is_active'   => intval($_POST['is_active'] ?? 1),
            'sort_order'  => intval($_POST['sort_order'] ?? 0),
        ];

        if (empty($data['name'])) {
            wp_send_json_error('Add-on name is required');
        }

        $id = \Instinctor\Services\ProductAddonService::saveAddon($data);
        if (!$id) {
            wp_send_json_error('Failed to save add-on');
        }

        wp_send_json_success(['id' => $id, 'message' => 'Add-on saved']);
    }

    public function ajaxDeleteProductAddon(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $id = intval($_POST['id'] ?? 0);
        if (!$id) {
            wp_send_json_error('Invalid add-on ID');
        }

        if (\Instinctor\Services\ProductAddonService::deleteAddon($id)) {
            wp_send_json_success(['message' => 'Add-on deleted']);
        } else {
            wp_send_json_error('Failed to delete add-on');
        }
    }

    // =========================================================================
    // VOLUME DISCOUNT HANDLERS
    // =========================================================================

    public function ajaxGetVolumeTiers(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $productId  = !empty($_REQUEST['product_id'])  ? intval($_REQUEST['product_id'])  : null;
        $categoryId = !empty($_REQUEST['category_id']) ? intval($_REQUEST['category_id']) : null;

        if ($productId) {
            $tiers = \Instinctor\Services\VolumeDiscountService::getProductTiers($productId);
        } elseif ($categoryId) {
            $tiers = \Instinctor\Services\VolumeDiscountService::getCategoryTiers($categoryId);
        } else {
            // All tiers for management view
            $table = DB::prefix() . 'instinctor_volume_discounts';
            $tiers = DB::get_results(
                "SELECT * FROM {$table} ORDER BY product_id IS NOT NULL DESC, min_quantity ASC",
                ARRAY_A
            ) ?: [];
        }

        wp_send_json_success(['tiers' => $tiers]);
    }

    public function ajaxSaveVolumeTier(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $data = [
            'id'             => intval($_POST['id'] ?? 0),
            'product_id'     => !empty($_POST['product_id'])  ? intval($_POST['product_id'])  : null,
            'category_id'    => !empty($_POST['category_id']) ? intval($_POST['category_id']) : null,
            'min_quantity'   => intval($_POST['min_quantity'] ?? 2),
            'max_quantity'   => !empty($_POST['max_quantity']) ? intval($_POST['max_quantity']) : null,
            'discount_type'  => sanitize_text_field($_POST['discount_type'] ?? 'percent'),
            'discount_value' => floatval($_POST['discount_value'] ?? 0),
        ];

        if ($data['min_quantity'] < 2) {
            wp_send_json_error('Minimum quantity must be 2 or more');
        }
        if ($data['discount_value'] <= 0) {
            wp_send_json_error('Discount value must be greater than 0');
        }

        $id = \Instinctor\Services\VolumeDiscountService::saveTier($data);
        if (!$id) {
            wp_send_json_error('Failed to save tier');
        }

        wp_send_json_success(['id' => $id, 'message' => 'Tier saved']);
    }

    public function ajaxDeleteVolumeTier(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $id = intval($_POST['id'] ?? 0);
        if (!$id) {
            wp_send_json_error('Invalid tier ID');
        }

        if (\Instinctor\Services\VolumeDiscountService::deleteTier($id)) {
            wp_send_json_success(['message' => 'Tier deleted']);
        } else {
            wp_send_json_error('Failed to delete tier');
        }
    }

    // =========================================================================
    // ABANDONED CART SETTINGS
    // =========================================================================

    // =========================================================================
    // LOCALIZATION: LANGUAGE MANAGEMENT
    // =========================================================================


    // =========================================================================
    // LOCALIZATION: CURRENCY MANAGEMENT
    // =========================================================================

    public function ajaxGetCurrencies(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error('Permission denied');
        }

        $currencies    = \Instinctor\Services\CurrencyService::getEnabledCurrencies();
        $allCodes      = \Instinctor\Services\CurrencyService::getAvailableCurrencyCodes();
        $defaultCode   = '';
        $default       = \Instinctor\Services\CurrencyService::getDefaultCurrency();
        if ($default) {
            $defaultCode = $default->code;
        }

        wp_send_json_success([
            'currencies'  => $currencies,
            'available'   => array_keys($allCodes),
            'default'     => $defaultCode,
        ]);
    }

    /**
     * Show upgrade notice on WordPress admin pages (not on Instinctor page)
     */
    public function showUpgradeNotice(): void
    {
        // Only for admins
        if (!Auth::can('manage_options')) return;
        
        // Don't show on the Instinctor page (it has its own upsell UI)
        $screen = get_current_screen();
        if ($screen && strpos($screen->id, 'instinctor') !== false) return;
        
        // Only show if free tier
        $tier = Config::get('instinctor_license_tier', 'free');
        return; // PRO badges/upsell removed
    }

    // ── T10 — API Credentials Panel (ILM absorption) ─────────────────────────

    /**
     * AJAX: Get API credentials for the credentials settings panel.
     * Returns obfuscated values — secrets shown as masked strings.
     */
    public function ajaxGetCredentials(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied.']);
        }

        // Helper: mask a secret key — show first 4 + last 4, rest as *
        $mask = static function (string $val): string {
            if (strlen($val) <= 10) {
                return $val ? str_repeat('*', strlen($val)) : '';
            }
            return substr($val, 0, 4) . str_repeat('*', strlen($val) - 8) . substr($val, -4);
        };

        wp_send_json_success([
            // Email sender identity — FROM name/address only.
            // Delivery goes through Instinctor Dashboard relay (platform.instinctor.com).
            // API keys never live here.
            'email' => [
                'from_email' => Config::get('store_from_email', ''),
                'from_name'  => Config::get('store_from_name', ''),
            ],
            // SMS/WhatsApp/AI relay through platform — no credentials stored on store side.
            // Store owner enables the features; platform handles delivery.
            'stripe' => [
                'publishable_key' => Config::get('instinctor_stripe_publishable_key', ''),
                'secret_key'      => $mask(Config::get('instinctor_stripe_secret_key', '')),
                'webhook_secret'  => $mask(Config::get('instinctor_stripe_webhook_secret', '')),
                'mode'            => Config::get('instinctor_stripe_mode', 'test'),
            ],
            'license' => [
                'key'         => $mask(Config::get('instinctor_license_key', '')),
                'tier'        => Config::get('instinctor_license_tier', 'free'),
                'email'       => Config::get('instinctor_registered_email', ''),
                'site_mode'   => \Instinctor\Core\Plugin::siteMode(),
                'site_status' => Config::get('instinctor_site_status', null),
            ],
        ]);
    }

    /**
     * AJAX: Save API credentials.
     * Empty string means "no change" — existing secret preserved.
     * Explicitly passing a value overwrites it.
     */
    /**
     * The mobile control panel's Settings card.
     *
     * One action, five verbs, because they all read and write the same small
     * piece of state and splitting them would mean five nonce checks and five
     * round trips to render one card.
     *
     * Everything here is designer-only (manage_options). The owner never sees
     * this screen: they get a link and a phone.
     */
    public function ajaxPanelAccess(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied.']);
        }

        $access = '\\Instinctor\\Services\\OwnerAccess';
        $do     = sanitize_key(wp_unslash($_POST['do'] ?? ''));

        switch ($do) {
            case 'enable':
            case 'disable':
                $access::setEnabled($do === 'enable');
                break;

            case 'owner':
                $email = sanitize_email(wp_unslash($_POST['email'] ?? ''));
                $name  = sanitize_text_field(wp_unslash($_POST['name'] ?? ''));
                [$id, $err] = $access::assignOwner($email, $name);
                if ($err !== '') wp_send_json_error(['message' => $err]);
                break;

            case 'revoke':
                $access::revokeLinks();
                break;

            case 'send':
                $link = $access::mintLink();
                $to   = $access::ownerEmail();
                if ($link === '' || $to === '') {
                    wp_send_json_error(['message' => 'Name the owner first.']);
                }
                $site = (string) (Config::get('instinctor_store_name', '') ?: wp_parse_url((string) home_url(), PHP_URL_HOST));
                $sent = Mailer::send($to, 'Your ' . $site . ' business app', self::panelInviteEmail($site, $link));
                if (!$sent) wp_send_json_error(['message' => 'Could not send the email. Copy the link instead.']);
                break;

            case 'state':
                break;   // just report

            default:
                wp_send_json_error(['message' => 'Unknown action.']);
        }

        // Every verb answers with the whole card state, so the UI never has to
        // guess what changed. The link is minted fresh on each read: it is a
        // credential, so it is never stored anywhere we would have to protect.
        $ownerId = $access::ownerUserId();
        wp_send_json_success([
            'enabled' => $access::enabled(),
            'email'   => $access::ownerEmail(),
            'named'   => $ownerId > 0,
            'link'    => ($access::enabled() && $ownerId > 0) ? $access::mintLink() : '',
            'sent_at' => $access::lastSentAt(),
        ]);
    }

    /** The invite the owner receives. Plain, short, and it says what to do. */
    private static function panelInviteEmail(string $site, string $link): string
    {
        $e = static fn(string $x): string => htmlspecialchars($x, ENT_QUOTES);
        return '<div style="font-family:-apple-system,BlinkMacSystemFont,Roboto,sans-serif;font-size:15px;line-height:1.6;color:#1d2327;max-width:520px">'
            . '<p>Your website now has an app for your phone. It shows the day ahead, the people writing to you, and your prices, and it updates your website as you use it.</p>'
            . '<p style="margin:26px 0"><a href="' . $e($link) . '" style="background:#1d2327;color:#fff;text-decoration:none;padding:13px 22px;border-radius:8px;font-weight:600;display:inline-block">Open ' . $e($site) . '</a></p>'
            . '<p>Open it on your phone, then add it to your home screen: on iPhone tap Share and then Add to Home Screen, on Android tap the menu and then Install app.</p>'
            . '<p style="color:#555;font-size:13px">This link signs you in, so keep it to yourself. It stops working after two weeks, and you can always ask for a new one.</p>'
            . '</div>';
    }

    public function ajaxSaveCredentials(): void
    {
        check_ajax_referer('instinctor_admin', 'nonce');
        if (!Auth::can('manage_options')) {
            wp_send_json_error(['message' => 'Permission denied.']);
        }

        $group = sanitize_text_field(wp_unslash($_POST['group'] ?? ''));
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- structured array; validated as_array + key/value sanitize below
        $data  = isset($_POST['data']) ? wp_unslash($_POST['data']) : [];

        if (!is_array($data)) {
            wp_send_json_error(['message' => 'Invalid data.']);
        }

        // Only save non-empty values (empty = "no change" for secrets)
        $save = static function (string $key, mixed $value, bool $isSecret = false): void {
            if ($isSecret && empty($value)) {
                return; // blank field = keep existing
            }
            Config::set($key, sanitize_text_field((string) $value));
        };

        switch ($group) {
            case 'email':
                // Only sender identity — no API keys
                $save('store_from_email', $data['from_email'] ?? '');
                $save('store_from_name',  $data['from_name']  ?? '');
                break;

            case 'stripe':
                $save('instinctor_stripe_publishable_key', $data['publishable_key'] ?? '');
                $save('instinctor_stripe_secret_key',      $data['secret_key']      ?? '', true);
                $save('instinctor_stripe_webhook_secret',  $data['webhook_secret']  ?? '', true);
                $save('instinctor_stripe_mode',            $data['mode']            ?? 'test');
                break;

            default:
                wp_send_json_error(['message' => 'Unknown credentials group: ' . $group]);
        }

        wp_send_json_success(['message' => ucfirst($group) . ' credentials saved.']);
    }

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

    /**
     * Resolve an image ID to a URL — checks instinctor_images first, falls back to WP.
     */
    private function resolveImageUrl(int $id, string $imgTable): string
    {
        if ($id <= 0) return '';
        // Try instinctor_images table (standalone-compatible)
        $row = DB::get_row(
            DB::prepare("SELECT rel_path FROM {$imgTable} WHERE id = %d AND parent_id IS NULL LIMIT 1", $id),
            'ARRAY_A'
        );
        if ($row && !empty($row['rel_path'])) {
            $url = Storage::url($row['rel_path']);
            if ($url) return $url;
        }
        // Fallback to WP media (only in WP mode)
        if (function_exists('wp_get_attachment_image_url')) {
            $url = wp_get_attachment_image_url($id, 'large');
            if ($url) return $url;
        }
        return '';
    }

}
