<?php
/**
 * Chat Service
 * 
 * Live chat with SMS/WhatsApp notifications and cart visibility.
 * Requires 'chat' add-on to be active.
 * 
 * @package Instinctor
 * @since 1.4.2
 */

declare(strict_types=1);

namespace Instinctor\Services;

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

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

class ChatService
{
    private static ?self $instance = null;
    
    private string $table_conversations;
    private string $table_messages;
    private string $table_agents;
    private string $table_visitor_notes;
    private string $relay_url = 'https://platform.instinctor.com/api/relay';

    public static function instance(): self
    {
        if (self::$instance === null) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    private function __construct()
    {
        $this->table_conversations  = DB::prefix() . 'instinctor_chat_conversations';
        $this->table_messages       = DB::prefix() . 'instinctor_chat_messages';
        $this->table_agents         = DB::prefix() . 'instinctor_chat_agents';
        $this->table_visitor_notes  = DB::prefix() . 'instinctor_visitor_notes';
        
        // Basic chat is always enabled (free feature)
        // PRO features (SMS, WhatsApp, AI, screenshots, attachments) require addon
        
        // Admin menu
        // Chat admin menu disabled - now part of workspace system
        // add_action('admin_menu', [$this, 'addAdminMenu']);
        
        // Frontend widget - both wp_footer and shortcode for flexibility
        add_action('wp_footer', [$this, 'renderWidget']);
        add_action('wp_enqueue_scripts', [$this, 'enqueueFrontendAssets']);
        add_action('admin_enqueue_scripts', [$this, 'enqueueAdminAssets']);
        
        // Shortcode for Elementor/page builder pages where wp_footer doesn't work
        \add_shortcode('instinctor_chat', [$this, 'renderShortcode']);
        
        // AJAX handlers — visitor-facing (callable by logged-out users).
        // The `true` third arg flags the action as PUBLIC: on WordPress
        // it registers both wp_ajax_* and wp_ajax_nopriv_*; on standalone
        // it lands in AjaxDispatcher's public set so AdminRouter's auth
        // gate doesn't 403 the request. Pre-fix on standalone these
        // returned `{"success":false,"data":"Not authenticated"}` — chat
        // messages were dropped at the auth gate before reaching the
        // handler, so nothing was written to the database.
        $this->registerAjax('instinctor_chat_send',          [$this, 'ajaxSendMessage'],       true);
        $this->registerAjax('instinctor_chat_get_messages',  [$this, 'ajaxGetMessages'],       true);
        $this->registerAjax('instinctor_chat_poll_messages', [$this, 'ajaxPollMessages'],      true);
        $this->registerAjax('instinctor_chat_set_typing',    [$this, 'ajaxSetTyping'],         true);

        // PRO features — screenshots and file uploads (require addon).
        // Also visitor-facing; same public flag applies.
        $this->registerAjax('instinctor_chat_send_screenshot', [$this, 'ajaxSendScreenshot'], true);
        $this->registerAjax('instinctor_chat_send_file',       [$this, 'ajaxSendFile'],       true);
        
        // AJAX handlers - Admin
        $this->registerAjax('instinctor_chat_admin_reply',          [$this, 'ajaxAdminReply']);
        $this->registerAjax('instinctor_chat_get_visitor_note',     [$this, 'ajaxGetVisitorNote']);
        $this->registerAjax('instinctor_chat_save_visitor_note',    [$this, 'ajaxSaveVisitorNote']);
        $this->registerAjax('instinctor_chat_get_visitor_history',  [$this, 'ajaxGetVisitorHistory']);
        $this->registerAjax('instinctor_chat_get_conversation',     [$this, 'ajaxGetConversation']);
        $this->registerAjax('instinctor_chat_get_conversations',    [$this, 'ajaxGetConversations']);
        $this->registerAjax('instinctor_chat_mark_read', [$this, 'ajaxMarkRead']);
        $this->registerAjax('instinctor_chat_close', [$this, 'ajaxCloseConversation']);
        $this->registerAjax('instinctor_chat_close_all', [$this, 'ajaxCloseAllConversations']);
        $this->registerAjax('instinctor_chat_delete', [$this, 'ajaxDeleteConversation']);
        $this->registerAjax('instinctor_chat_delete_all', [$this, 'ajaxDeleteAllConversations']);
        $this->registerAjax('instinctor_chat_save_settings', [$this, 'ajaxSaveSettings']);
        $this->registerAjax('instinctor_chat_toggle_enabled', [$this, 'ajaxToggleEnabled']);
        $this->registerAjax('instinctor_chat_save_agent', [$this, 'ajaxSaveAgent']);
        $this->registerAjax('instinctor_chat_delete_agent', [$this, 'ajaxDeleteAgent']);
        $this->registerAjax('instinctor_chat_claim', [$this, 'ajaxClaimConversation']);
        $this->registerAjax('instinctor_chat_test_notification', [$this, 'ajaxTestNotification']);
        $this->registerAjax('instinctor_chat_get_usage', [$this, 'ajaxGetUsage']);
        
        // Billing AJAX handlers (PRO)
        $this->registerAjax('instinctor_chat_get_balance', [$this, 'ajaxGetBalance']);
        $this->registerAjax('instinctor_chat_get_payment_method', [$this, 'ajaxGetPaymentMethod']);
        $this->registerAjax('instinctor_chat_get_transactions', [$this, 'ajaxGetTransactions']);
        $this->registerAjax('instinctor_chat_add_funds', [$this, 'ajaxAddFunds']);
        $this->registerAjax('instinctor_chat_get_card_update_url', [$this, 'ajaxGetCardUpdateUrl']);
        $this->registerAjax('instinctor_chat_set_auto_refill', [$this, 'ajaxSetAutoRefill']);
        $this->registerAjax('instinctor_chat_save_quick_actions', [$this, 'ajaxSaveQuickActions']);
        $this->registerAjax('instinctor_chat_add_quick_action', [$this, 'ajaxAddQuickAction']);
        $this->registerAjax('instinctor_chat_update_quick_action', [$this, 'ajaxUpdateQuickAction']);
        $this->registerAjax('instinctor_chat_delete_quick_action', [$this, 'ajaxDeleteQuickAction']);
        $this->registerAjax('instinctor_chat_delete_quick_actions', [$this, 'ajaxDeleteQuickActions']);
        $this->registerAjax('instinctor_chat_get_agents', [$this, 'ajaxGetAgents']);
        $this->registerAjax('instinctor_chat_add_agent', [$this, 'ajaxAddAgent']);
        $this->registerAjax('instinctor_chat_update_agent', [$this, 'ajaxUpdateAgent']);
        $this->registerAjax('instinctor_chat_remove_agent', [$this, 'ajaxRemoveAgent']);
        $this->registerAjax('instinctor_chat_get_quick_actions', [$this, 'ajaxGetQuickActions']);
        $this->registerAjax('instinctor_chat_get_unread_count', [$this, 'ajaxGetUnreadCount']);
        $this->registerAjax('instinctor_chat_check_handoff', [$this, 'ajaxCheckHandoff'], true);
        
        // Per-page chat visibility toggle (admin only)
        $this->registerAjax('instinctor_chat_toggle_page', [$this, 'ajaxTogglePageChat']);
        
        // REST API for incoming messages and relay callbacks.
        // In WP mode, rest_api_init fires before dispatch. In Standalone mode,
        // that action never fires, so we register directly.
        add_action('rest_api_init', [$this, 'registerRestRoutes']);
        if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
            $this->registerRestRoutes();
        }
        
        // Auto-close stale conversations (for WhatsApp 24h session limit)
        add_action('instinctor_chat_auto_close', [$this, 'autoCloseStaleConversations']);
        if (!wp_next_scheduled('instinctor_chat_auto_close')) {
            wp_schedule_event(time(), 'hourly', 'instinctor_chat_auto_close');
        }
    }
    
    /**
     * Auto-close conversations inactive for specified time
     * WhatsApp has 24h session window, so we close early to prevent failed replies
     */
    public function autoCloseStaleConversations(): void
    {
        
        $timeout_minutes = (int) Config::get('instinctor_chat_auto_close_minutes', 60);
        
        if ($timeout_minutes <= 0) {
            return; // Disabled
        }
        
        // Use WordPress time (matches MySQL CURRENT_TIMESTAMP)
        $cutoff = gmdate('Y-m-d H:i:s', strtotime("-{$timeout_minutes} minutes", current_time('timestamp')));
        
        $closed = DB::query(DB::prepare(
            "UPDATE {$this->table_conversations} 
             SET status = 'closed' 
             WHERE status = 'active' 
             AND updated_at < %s",
            $cutoff
        ));
        
        if ($closed > 0) {
        }
    }

    /**
     * Check if chat addon is active (for PRO features)
     * On instinctor.com, still requires explicit activation but skips license validation
     */
    private function isAddonActive(): bool
    {
        // Check for tampering flag
        if (Config::get('instinctor_tamper_detected')) {
            return false;
        }
        
        $activeAddons = Config::get('instinctor_active_addons', []);
        return in_array('chat', $activeAddons) || in_array('all_access', $activeAddons);
    }
    
    /**
     * Check if PRO chat features are available (SMS, WhatsApp, screenshots, attachments, AI, avatar)
     */
    public function hasProFeatures(): bool
    {
        // Chat widget itself (colors, avatar, screenshots, attachments) is always available.
        // Only SMS and WhatsApp notifications are pro-gated — checked separately at call sites.
        return true;
    }
    
    /**
     * Check if SMS notifications are available for this license.
     * Always available in admin/backend context; gated on frontend for free tier.
     */
    private function canSendSms(): bool
    {
        return !\instinctor_locked('chat_pro');
    }

    /**
     * Check if WhatsApp notifications are available for this license.
     * Always available in admin/backend context; gated on frontend for free tier.
     */
    private function canSendWhatsApp(): bool
    {
        return !\instinctor_locked('chat_pro');
    }
    
    /**
     * Check if basic chat is enabled (always true - free feature)
     */
    private function isChatEnabled(): bool
    {
        return true; // Basic chat is always available
    }

    /**
     * Check if current user can manage chat conversations.
     * Allows both admins and users with instinctor_chat capability.
     */
    private function canAccessChat(): bool
    {
        return Auth::can('manage_options') || Auth::can('instinctor_chat');
    }

    /**
     * Get license key for relay
     */
    private function getLicenseKey(): string
    {
        // For instinctor.com, use special key
        if (strpos(home_url(), 'instinctor.com') !== false) {
            return 'INSTINCTOR_APP_INTERNAL';
        }
        
        // Primary: main license key (used by TierManager)
        $key = Config::get('instinctor_license_key', '');
        if (!empty($key)) {
            return $key;
        }

        // No license key: the relay authenticates by domain, so return a
        // non-empty marker and let callers proceed (getAIReply's empty-key
        // guard would otherwise suppress chat entirely). Managed client
        // installs store site_mode as 'license' (only some provisioning
        // flows set 'client_site'), so a keyless standalone install is
        // domain-authed either way — don't gate the marker on the exact
        // mode string, or Instagent silently never replies on those sites.
        if (\Instinctor\Core\Plugin::isStandalone()) {
            return 'CLIENT_SITE';
        }

        return '';
    }

    /**
     * Register REST routes for incoming messages
     */
    public function registerRestRoutes(): void
    {
        // Route for relay to forward SMS/WhatsApp replies
        Router::restRaw('/relay-reply', [
            'methods' => 'POST',
            'callback' => [$this, 'handleRelayReply'],
            'permission_callback' => '__return_true'
        ]);

        // Route for balance notifications from relay
        Router::restRaw('/balance-notification', [
            'methods' => 'POST',
            'callback' => [$this, 'handleBalanceNotification'],
            'permission_callback' => '__return_true'
        ]);

        // Relay callback: platform fetches available booking slots on behalf of AI
        Router::restRaw('/relay/slots', [
            'methods'             => 'GET',
            'callback'            => [$this, 'handleRelaySlots'],
            'permission_callback' => '__return_true',
        ]);

        // Relay callback: platform creates a booking on behalf of AI
        Router::restRaw('/relay/booking', [
            'methods'             => 'POST',
            'callback'            => [$this, 'handleRelayBooking'],
            'permission_callback' => '__return_true',
        ]);
    }

    /**
     * Relay: return live product stock / price for one or more products.
     * GET /relay/stock?query=NAME  or  ?product_id=N
     */
    public function handleRelayStock(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!$this->verifyPlatformToken($request)) {
            return new \WP_REST_Response(['error' => 'Unauthorized'], 403);
        }

        $query     = sanitize_text_field($request->get_param('query') ?? '');
        $productId = (int) ($request->get_param('product_id') ?? 0);
        $p         = DB::prefix();
        $symbol    = Config::get('instinctor_currency_symbol', '$');

        if ($productId > 0) {
            $products = DB::get_results(DB::prepare(
                "SELECT id, name, sku, price, sale_price, stock_status, stock_quantity, manage_stock
                   FROM {$p}instinctor_products WHERE id = %d AND status = 'published'",
                $productId
            )) ?: [];
        } elseif ($query !== '') {
            $like     = '%' . DB::esc_like($query) . '%';
            $products = DB::get_results(DB::prepare(
                "SELECT id, name, sku, price, sale_price, stock_status, stock_quantity, manage_stock
                   FROM {$p}instinctor_products
                  WHERE status = 'published' AND (name LIKE %s OR sku LIKE %s)
                  LIMIT 5",
                $like, $like
            )) ?: [];
        } else {
            return new \WP_REST_Response(['products' => [], 'message' => 'Provide query or product_id.']);
        }

        $result = [];
        foreach ($products as $pr) {
            $regular   = (float) ($pr->price      ?? 0);
            $sale      = (float) ($pr->sale_price ?? 0);
            $effective = ($sale > 0 && $sale < $regular) ? $sale : $regular;

            if ($pr->stock_status === 'outofstock') {
                $stock = 'out of stock';
            } elseif ($pr->manage_stock && $pr->stock_quantity !== null) {
                $stock = (int) $pr->stock_quantity . ' in stock';
            } else {
                $stock = 'in stock';
            }

            $result[] = [
                'id'    => (int) $pr->id,
                'name'  => $pr->name,
                'sku'   => $pr->sku ?: null,
                'price' => $symbol . number_format($effective, 2),
                'stock' => $stock,
            ];
        }

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

    /**
     * Relay: create a pending order from items collected by AI chat.
     * POST /relay/create-order
     * Body: { items:[{product_id,quantity}], customer_name, customer_email,
     *          customer_phone?, note? }
     * Returns: { success, order_number, checkout_url, total }
     */
    public function handleRelayCreateOrder(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!$this->verifyPlatformToken($request)) {
            return new \WP_REST_Response(['error' => 'Unauthorized'], 403);
        }

        $params = $request->get_json_params() ?: [];
        $items  = $params['items']          ?? [];
        $name   = sanitize_text_field($params['customer_name']  ?? '');
        $email  = sanitize_email($params['customer_email']       ?? '');
        $phone  = sanitize_text_field($params['customer_phone']  ?? '');
        $note   = sanitize_textarea_field($params['note']        ?? 'Placed via smart chat');

        if (empty($items) || !$name) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Missing items or customer name'], 400);
        }

        $p      = DB::prefix();
        $symbol = Config::get('instinctor_currency_symbol', '$');

        // Resolve products and check stock
        $resolvedItems = [];
        $subtotal      = 0.0;

        foreach ($items as $item) {
            $productId = (int) ($item['product_id'] ?? 0);
            $qty       = max(1, (int) ($item['quantity'] ?? 1));
            if ($productId <= 0) continue;

            $product = DB::get_row(DB::prepare(
                "SELECT id, name, price, sale_price, stock_status, stock_quantity, manage_stock
                   FROM {$p}instinctor_products WHERE id = %d AND status = 'published'",
                $productId
            ));

            if (!$product) continue;

            if ($product->stock_status === 'outofstock') {
                return new \WP_REST_Response(
                    ['success' => false, 'error' => $product->name . ' is currently out of stock'],
                    400
                );
            }

            $regular   = (float) ($product->price      ?? 0);
            $sale      = (float) ($product->sale_price ?? 0);
            $price     = ($sale > 0 && $sale < $regular) ? $sale : $regular;

            $resolvedItems[] = ['product_id' => (int) $product->id, 'name' => $product->name, 'quantity' => $qty, 'price' => $price];
            $subtotal += $price * $qty;
        }

        if (empty($resolvedItems)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'No valid products found'], 400);
        }

        // Create the pending order record
        $resumeToken  = bin2hex(random_bytes(16));
        $ordersTable  = $p . 'instinctor_orders';
        $itemsTable   = $p . 'instinctor_order_items';
        $billing      = ['name' => $name, 'email' => $email, 'phone' => $phone];

        DB::insert($ordersTable, [
            'order_number'     => 'CT-PENDING',
            'customer_id'      => 0,
            'status'           => 'pending',
            'currency'         => Config::get('instinctor_currency', 'USD'),
            'subtotal'         => $subtotal,
            'tax_total'        => 0,
            'shipping_total'   => 0,
            'discount_total'   => 0,
            'total'            => $subtotal,
            'payment_method'   => 'stripe',
            'payment_status'   => 'pending',
            'billing_address'  => json_encode($billing),
            'shipping_address' => json_encode($billing),
            'customer_note'    => $note,
            'ip_address'       => '',
            'user_agent'       => 'instinctor-ai-chat',
            'meta'             => json_encode(['resume_token' => $resumeToken, 'source' => 'ai_chat']),
        ]);

        $orderId = DB::insert_id();
        if (!$orderId) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Failed to create order'], 500);
        }

        $orderNumber = 'CT-' . str_pad((string) $orderId, 5, '0', STR_PAD_LEFT);
        DB::update($ordersTable, ['order_number' => $orderNumber], ['id' => $orderId]);

        foreach ($resolvedItems as $item) {
            DB::insert($itemsTable, [
                'order_id'   => $orderId,
                'product_id' => $item['product_id'],
                'name'       => $item['name'],
                'quantity'   => $item['quantity'],
                'price'      => $item['price'],
                'subtotal'   => $item['price'] * $item['quantity'],
                'total'      => $item['price'] * $item['quantity'],
                'meta'       => '{}',
            ]);
        }

        // Build checkout URL: the checkout page loads items from the pending order via the resume token
        $checkoutBase = \Instinctor\Services\PageService::resolveUrl('checkout');
        $checkoutUrl  = add_query_arg('resume', $resumeToken, $checkoutBase);

        return new \WP_REST_Response([
            'success'      => true,
            'order_number' => $orderNumber,
            'checkout_url' => $checkoutUrl,
            'total'        => $symbol . number_format($subtotal, 2),
        ]);
    }

    /**
     * Handle balance notification from relay
     * Types: low_balance, refill_success, refill_failed
     */
    public function handleBalanceNotification(\WP_REST_Request $request): \WP_REST_Response
    {
        $params = $request->get_json_params();
        $type = sanitize_text_field($params['type'] ?? '');
        $balance = floatval($params['balance'] ?? 0);
        $amount = floatval($params['amount'] ?? 0);
        $error = sanitize_text_field($params['error'] ?? '');
        
        $adminEmail = Config::get('admin_email', '');
        $siteName = get_bloginfo('name');
        
        switch ($type) {
            case 'low_balance':
                $subject = "[{$siteName}] Chat Balance Low";
                $message = "Your chat balance is \${$balance}.\n\n";
                $message .= "We'll automatically refill \$20 when it reaches \$5 to keep your SMS, WhatsApp, and smart chat features running.\n\n";
                $message .= "Manage your balance: " . admin_url('admin.php?page=instinctor-chat-settings');
                break;
                
            case 'refill_success':
                $subject = "[{$siteName}] Chat Balance Refilled";
                $message = "Your chat balance was automatically refilled.\n\n";
                $message .= "Amount added: \${$amount}\n";
                $message .= "New balance: \${$balance}\n\n";
                $message .= "Manage your balance: " . admin_url('admin.php?page=instinctor-chat-settings');
                break;
                
            case 'refill_failed':
                $subject = "[{$siteName}] Chat Payment Failed - Action Required";
                $message = "We couldn't process your automatic balance refill.\n\n";
                $message .= "Error: {$error}\n";
                $message .= "Current balance: \${$balance}\n\n";
                $message .= "Please update your payment method to continue using SMS, WhatsApp, and smart chat features:\n";
                $message .= admin_url('admin.php?page=instinctor-chat-settings');
                break;
                
            default:
                return new \WP_REST_Response(['success' => false, 'error' => 'Unknown type'], 400);
        }
        
        $this->sendEmailViaRelay($adminEmail, $subject, $message);

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

    /**
     * Relay callback: return available booking slots for a service + date.
     * Auth: X-Platform-Token header must match INSTINCTOR_PLATFORM_TOKEN.
     */
    public function handleRelaySlots(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!$this->verifyPlatformToken($request)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Unauthorized'], 403);
        }

        $bs         = \Instinctor\Services\BookingService::instance();
        $p          = DB::prefix();
        $serviceId  = absint($request->get_param('service_id') ?? 0);
        $date       = sanitize_text_field($request->get_param('date') ?? '');

        // Fetch active services (filter to requested service if specified)
        $where    = $serviceId ? DB::prepare('AND id = %d', $serviceId) : '';
        $services = DB::get_results(
            "SELECT id, name, duration FROM {$p}instinctor_services WHERE status = 'active' {$where} LIMIT 10",
            ARRAY_A
        );

        if (empty($services)) {
            return new \WP_REST_Response(['services' => [], 'message' => 'No bookable services.'], 200);
        }

        $days   = $date ? [$date] : array_map(fn ($d) => gmdate('Y-m-d', strtotime("+{$d} days")), range(1, 5));
        $result = [];

        foreach ($services as $svc) {
            $svcData = ['id' => (int) $svc['id'], 'name' => $svc['name'], 'duration' => (int) $svc['duration'], 'availability' => []];
            foreach ($days as $d) {
                $slots = $bs->getSlotsForDate((int) $svc['id'], $d);
                $avail = array_values(array_filter($slots, fn ($s) => $s['available']));
                if ($avail) {
                    $svcData['availability'][] = [
                        'date'  => $d,
                        'label' => gmdate('l Y-m-d', strtotime($d)),
                        'slots' => array_map(fn ($s) => $s['display'], array_slice($avail, 0, 10)),
                    ];
                }
            }
            $result[] = $svcData;
        }

        return new \WP_REST_Response(['services' => $result], 200);
    }

    /**
     * Relay callback: create a booking from the AI chat.
     * Auth: X-Platform-Token header must match INSTINCTOR_PLATFORM_TOKEN.
     */
    public function handleRelayBooking(\WP_REST_Request $request): \WP_REST_Response
    {
        if (!$this->verifyPlatformToken($request)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Unauthorized'], 403);
        }

        $params = $request->get_json_params();
        if (!is_array($params)) $params = [];

        $bookResult = $this->createBookingFromChat($params);
        if ($bookResult['success']) {
            return new \WP_REST_Response([
                'success'    => true,
                'booking_id' => $bookResult['booking_id'] ?? 0,
                'service'    => $bookResult['service']    ?? '',
                'date'       => $bookResult['day']        ?? '',
                'time'       => $bookResult['time']       ?? '',
                'message'    => 'Booking confirmed. Confirmation sent.',
            ], 200);
        }

        return new \WP_REST_Response([
            'success' => false,
            'error'   => $bookResult['error'] ?? 'Booking failed.',
        ], 422);
    }

    /**
     * Verify X-Platform-Token header against the shared INSTINCTOR_PLATFORM_TOKEN.
     */
    private function verifyPlatformToken(\WP_REST_Request $request): bool
    {
        $given    = (string) ($request->get_header('x-platform-token') ?? '');
        $expected = defined('INSTINCTOR_PLATFORM_TOKEN')
                    ? INSTINCTOR_PLATFORM_TOKEN
                    : (string) (Config::get('instinctor_platform_token', '') ?: '');
        return $given !== '' && hash_equals($expected, $given);
    }

    /**
     * Add admin menu
     */
    public function addAdminMenu(): void
    {
        $unread = $this->getUnreadCount();
        $badge = $unread > 0 ? ' <span class="awaiting-mod">' . $unread . '</span>' : '';
        
        add_submenu_page(
            'instinctor',
            'Chat',
            'Chat' . $badge,
            'manage_options',
            'instinctor-chat',
            [$this, 'renderInbox']
        );
        
        // Hidden page (no menu item) - accessed via link from Chat page
        add_submenu_page(
            null,
            'Chat Settings',
            'Chat Settings',
            'manage_options',
            'instinctor-chat-settings',
            [$this, 'renderSettings']
        );
        
        // Fix menu URL (WordPress sometimes generates incorrect URLs)
        add_action('admin_menu', [$this, 'fixChatMenuUrl'], 999);
        
        // Keep Chat menu highlighted when on Chat Settings page
        add_filter('parent_file', [$this, 'highlightChatMenu']);
        add_filter('submenu_file', [$this, 'highlightChatSubmenu']);
    }
    
    /**
     * Keep Instinctor menu open on Chat Settings page
     */
    public function highlightChatMenu($parent_file): string
    {
        global $current_screen;
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Menu highlighting doesn't modify data
        if (isset($_GET['page']) && $_GET['page'] === 'instinctor-chat-settings') {
            return 'instinctor';
        }
        return $parent_file;
    }
    
    /**
     * Keep Chat submenu highlighted on Chat Settings page
     */
    public function highlightChatSubmenu($submenu_file): string
    {
        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Menu highlighting doesn't modify data
        if (isset($_GET['page']) && $_GET['page'] === 'instinctor-chat-settings') {
            return 'instinctor-chat';
        }
        return $submenu_file ?? '';
    }
    
    /**
     * Fix chat menu URL
     */
    public function fixChatMenuUrl(): void
    {
        global $submenu;
        if (isset($submenu['instinctor'])) {
            foreach ($submenu['instinctor'] as $key => $item) {
                if ($item[2] === 'instinctor-chat') {
                    $submenu['instinctor'][$key][2] = 'admin.php?page=instinctor-chat'; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- intentional admin submenu URL fix
                }
            }
        }
    }

    /**
     * Add admin bar icon
     */

    /**
     * Add notification polling script to admin
     */

    /**
     * Enqueue frontend assets.
     * Widget HTML and CSS are output inline by renderShortcode() / renderWidget().
     */
    public function enqueueFrontendAssets(): void
    {
    }

    /**
     * Enqueue admin assets
     * Note: Chat is now part of the workspace system, this is kept for backward compatibility
     */
    public function enqueueAdminAssets($hook): void
    {
        // Chat admin page no longer exists - now part of workspace system
        return;
    }

    /**
     * Get widget settings
     */
    private function getWidgetSettings(): array
    {
        return [
            'enabled' => Config::get('instinctor_chat_enabled', '1'),
            'title' => Config::get('instinctor_chat_title', 'Chat with us'),
            'greeting' => Config::get('instinctor_chat_greeting', 'Hi! How can we help you today?'),
            'position' => Config::get('instinctor_chat_position', 'right'),
            'avatar' => Config::get('instinctor_chat_avatar', ''),
            'online_text' => Config::get('instinctor_chat_online_text', 'Online now'),
            'show_online' => Config::get('instinctor_chat_show_online', '1'),
            'sound_enabled' => Config::get('instinctor_chat_sound_enabled', '1'),
            'screenshots' => Config::get('instinctor_chat_screenshots', '0'),
            'attachments' => Config::get('instinctor_chat_attachments', '0'),
            'widget_height' => Config::get('instinctor_chat_widget_height', '480'),
                'bubble_opacity' => Config::get('instinctor_chat_bubble_opacity', '20'),
            // Colors
            'color_button' => Config::get('instinctor_chat_color_button', '#000000'),
                            'color_input_border' => Config::get('instinctor_chat_color_input_border', '#e0e0e0'),
                    'input_border_width' => Config::get('instinctor_chat_input_border_width', '1'),
            // Pre-chat
            'collect_name'   => Config::get('instinctor_chat_collect_name', '0'),
            'collect_email'  => Config::get('instinctor_chat_collect_email', '0'),
            'collect_phone'  => Config::get('instinctor_chat_collect_phone', '0'),
            // CTA greeting
            'chat_greeting_text'  => Config::get('instinctor_chat_greeting_text', ''),
            'chat_greeting_color' => Config::get('instinctor_chat_greeting_color', '#ffffff'),
        ];
    }

    /**
     * Resolve the id of the page currently being rendered.
     *
     * Standalone has no WordPress query loop, so get_queried_object_id() /
     * get_the_ID() / url_to_postid() are hard stubs that always return 0/false
     * (see bootstrap/wp-shims.php). With no id, the per-page chat gates below
     * could never load $page_settings and the widget rendered even when the
     * designer set chat off. impulse-page.php stashes the rendering page row in
     * $GLOBALS['impulse_current_page'] before output, so read its real id
     * first; genuine WordPress (non-Impulse) pages fall through to the query
     * helpers exactly as before. Sergei 2026-06-02.
     */
    private static function resolveCurrentPageId(): int
    {
        $icp = $GLOBALS['impulse_current_page'] ?? null;
        if (is_array($icp) && !empty($icp['id'])) {
            return (int) $icp['id'];
        }
        $pid = (int) get_queried_object_id();
        if (!$pid) $pid = (int) (get_the_ID() ?: 0);
        if (!$pid && !empty($GLOBALS['post'])) $pid = (int) $GLOBALS['post']->ID;
        if (!$pid && function_exists('url_to_postid')) {
            $pid = (int) url_to_postid(home_url(add_query_arg([], esc_url_raw(wp_unslash($_SERVER['REQUEST_URI'] ?? '')))));
        }
        return $pid;
    }

    /**
     * Render chat widget on frontend
     */
    public function renderWidget(): void
    {
        static $rendered = false;

        if ($rendered || is_admin()) {
            return;
        }

        $settings = $this->getWidgetSettings();

        // Global toggle = system master switch only.
        // If the system is off, never render anywhere.
        if ($settings['enabled'] !== '1') {
            return;
        }

        // Pro feature gate — 'chat_pro' bundles SMS + WhatsApp + Instagent.
        // Standalone always passes (payment collected before frontend renders).
        // WordPress unlicensed sites skip the widget entirely.
        // Inert until Step 8 (license-backed enforcement activated).
        if (\instinctor_locked('chat_pro')) {
            return;
        }

        // Per-page visibility — fully determined by Impulse page settings.
        // No admin bypass: if the designer hid chat on this page, it's hidden for everyone.
        $post_id = self::resolveCurrentPageId();

        if ($post_id) {
            $page_settings = \Instinctor\Services\PageService::getSettings($post_id);
            if (is_array($page_settings) && !empty($page_settings)) {
                $device     = (function_exists('wp_is_mobile') && wp_is_mobile()) ? 'mobile' : 'desktop';
                $device_key = 'chat_visible_' . $device;
                if (isset($page_settings[$device_key])) {
                    $chat_visible = $page_settings[$device_key];
                } elseif (isset($page_settings['chat_visible'])) {
                    $chat_visible = $page_settings['chat_visible'];
                } else {
                    $chat_visible = 0; // default: hidden unless the page explicitly enables chat
                }
                if ($chat_visible === 0 || $chat_visible === '0' || $chat_visible === false) {
                    return; // Impulse says: no chat on this page
                }
                // Legacy hide_chat flag
                $hide = $page_settings['hide_chat'] ?? null;
                if ($hide === null && (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress')) {
                    $hide = get_post_meta($post_id, '_instinctor_hide_chat', true);
                }
                if ($hide === '1' || $hide === true) {
                    return;
                }
            }
        }

        $rendered = true;

        // renderShortcode() returns the plugin's own static, self-composed chat
        // widget markup: inline SVG icons, onclick/onchange handlers and inline
        // styles. It carries no un-encoded dynamic data — every runtime value is
        // JSON-encoded (wp_json_encode) and handed to JS via wp_add_inline_script
        // inside renderShortcode(); the only PHP interpolations in the buffer are
        // boolean-selected literal class strings. wp_kses_post() would strip the
        // SVG and event handlers and break the widget, so this trusted markup is
        // echoed directly.
        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- trusted static self-composed chat-widget markup (see comment above); runtime values pass via wp_json_encode + wp_add_inline_script, not raw output.
        echo $this->renderShortcode(false);
    }
    /**
     * Get visitor ID (from cookie or generate new)
     */
    private function getVisitorId(): string
    {
        if (isset($_COOKIE['instinctor_visitor_id'])) {
            return sanitize_text_field(wp_unslash((string)$_COOKIE['instinctor_visitor_id']));
        }
        
        $visitorId = 'v_' . bin2hex(random_bytes(16));
        setcookie('instinctor_visitor_id', $visitorId, time() + (86400 * 365), '/');
        return $visitorId;
    }

    /**
     * Get current cart data
     */
    private function getCartData(): array
    {
        try {
            if (!class_exists('\\Instinctor\\Services\\CartService')) {
                return ['items' => [], 'total' => 0, 'total_formatted' => '$0.00', 'count' => 0];
            }
            
            $cartService = CartService::instance();
            $cart = $cartService->getCart();
            
            $items = [];
            foreach ($cart['items'] ?? [] as $item) {
                $items[] = [
                    'id' => $item['product_id'] ?? 0,
                    'name' => $item['name'] ?? '',
                    'qty' => $item['quantity'] ?? 1,
                    'price' => $item['price'] ?? 0,
                    'total' => $item['subtotal'] ?? 0
                ];
            }
            
            $total = $cart['total'] ?? 0;
            $formatted = class_exists('\\Instinctor\\Services\\CurrencyService') 
                ? CurrencyService::formatPrice($total) 
                : '$' . number_format($total, 2);
            
            return [
                'items' => $items,
                'total' => $total,
                'total_formatted' => $formatted,
                'count' => count($items)
            ];
        } catch (\Throwable $e) {
            return ['items' => [], 'total' => 0, 'total_formatted' => '$0.00', 'count' => 0];
        }
    }

    /**
     * AJAX: Send message from visitor
     */
    public function ajaxSendMessage(): void
    {
        try {
            check_ajax_referer('instinctor_chat', 'nonce');
            
            $visitorId = isset($_POST['visitor_id']) ? sanitize_text_field(wp_unslash($_POST['visitor_id'])) : '';
            $message = isset($_POST['message']) ? sanitize_textarea_field(wp_unslash($_POST['message'])) : '';
            // Widget POSTs `visitor_name`/`visitor_email`/`visitor_phone`
            // (the JS object's own property names). Accept the legacy `name`/
            // `email`/`phone` field names too in case any older caller still
            // uses them — the widget's actual fields win when both are set.
            $name  = isset($_POST['visitor_name'])  ? sanitize_text_field(wp_unslash($_POST['visitor_name']))
                  : (isset($_POST['name'])          ? sanitize_text_field(wp_unslash($_POST['name']))  : '');
            $email = isset($_POST['visitor_email']) ? sanitize_email(wp_unslash($_POST['visitor_email']))
                  : (isset($_POST['email'])         ? sanitize_email(wp_unslash($_POST['email']))      : '');
            $phone = isset($_POST['visitor_phone']) ? sanitize_text_field(wp_unslash($_POST['visitor_phone']))
                  : (isset($_POST['phone'])         ? sanitize_text_field(wp_unslash($_POST['phone'])) : '');
            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Cart data sanitized in sanitizeCartData method
            $cart = isset($_POST['cart']) ? $this->sanitizeCartData(wp_unslash($_POST['cart'])) : null;
            $pageUrl = isset($_POST['page_url']) ? esc_url_raw(wp_unslash($_POST['page_url'])) : '';
            
            if (empty($visitorId) || empty($message)) {
                wp_send_json_error('Missing required fields');
                return;
            }
            
            // Get or create conversation
            $conversation = $this->getConversationByVisitor($visitorId);
            
            if (!$conversation) {
                $convId = $this->createConversation([
                    'visitor_id' => $visitorId,
                    'visitor_name' => $name,
                    'visitor_email' => $email,
                    'visitor_phone' => $phone,
                    'cart_data' => $cart ? wp_json_encode($cart) : null,
                    'page_url' => $pageUrl,
                    'ip_address' => isset($_SERVER['REMOTE_ADDR']) ? sanitize_text_field(wp_unslash($_SERVER['REMOTE_ADDR'])) : ''
                ]);
                $isNewConversation = true;
            } else {
                $convId = (int) $conversation->id;
                $isNewConversation = false;
                
                // Update visitor info if provided
                $updateData = ['cart_data' => $cart ? wp_json_encode($cart) : $conversation->cart_data];
                if ($name && !$conversation->visitor_name) $updateData['visitor_name'] = $name;
                if ($email && !$conversation->visitor_email) $updateData['visitor_email'] = $email;
                if ($phone && !$conversation->visitor_phone) $updateData['visitor_phone'] = $phone;
                $this->updateConversation($convId, $updateData);
            }
            
            // Add message
            $messageId = $this->addMessage($convId, 'visitor', $message);

            // Generate the AI reply SYNCHRONOUSLY and return it in the same
            // response. The previous fastcgi_finish_request defer was
            // unreliable on this fpm setup (worker recycled before the
            // deferred code ran), so Instagent never fired. The widget's
            // frontend renders resp.data.ai_reply directly.
            $aiReply = null;
            $aiMessageId = null;
            $needsHandoff = false;

            try {
                // Skip AI once a human (or prior AI) has taken over.
                $humanResponded = $this->hasHumanResponded($convId);

                // Noise filter: skip 1-char and letterless (digit/punct-only)
                // messages. Use the Unicode letter property so non-Latin
                // scripts (Cyrillic, CJK, accented) count as letters — the
                // old [0-9\s\W] test flagged every non-ASCII message as noise
                // because \w is ASCII-only, so e.g. a Russian "ау" never got
                // a reply. Noise = no letter in any script.
                $trimmed = trim((string) $message);
                $isNoise = (mb_strlen($trimmed) < 2)
                         || !preg_match('/\p{L}/u', $trimmed);

                // Rapid-fire guard: if the visitor already sent a newer
                // message, let that one's request produce the reply so we
                // don't double-answer.
                $newer = (int) DB::get_var(DB::prepare(
                    "SELECT id FROM {$this->table_messages} "
                    . "WHERE conversation_id = %d AND sender = 'visitor' AND id > %d "
                    . "LIMIT 1",
                    $convId, $messageId
                ));

                if (!$humanResponded && !$isNoise && $newer === 0) {
                    $aiResult = $this->getAIReply($convId, $message, $name, $cart);
                    if ($aiResult) {
                        $aiReply      = (string) ($aiResult['reply'] ?? '');
                        $needsHandoff = (bool)   ($aiResult['needs_handoff'] ?? false);
                        if ($needsHandoff) {
                            $this->handleHandoff($convId, $name, $message);
                        }
                        if ($aiReply !== '') {
                            $aiMessageId = $this->addMessage($convId, 'admin', $aiReply);
                        }
                    }
                }
            } catch (\Throwable $e) {
                // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- server-side error capture, not debug output.
                error_log('[ChatService] AI reply failed: ' . $e->getMessage());
            }

            // Notify agents AFTER AI reply so the SMS includes what Instagent said.
            // Owner can then decide whether to step in or let AI continue.
            try {
                $notifyEvery = Config::get('instinctor_chat_notify_every_message', '0');
                if ($isNewConversation || $notifyEvery === '1') {
                    $this->notifyAgents($convId, $message, $name, $cart, (string) ($aiReply ?? ''));
                }
            } catch (\Throwable $e) {
            }

            wp_send_json_success([
                'conversation_id' => $convId,
                'message_id'      => $messageId,
                'ai_reply'        => $aiReply,
                'ai_message_id'   => $aiMessageId,
                'ai_delay_ms'     => 0,
                'needs_handoff'   => $needsHandoff,
            ]);
        } catch (\Throwable $e) {
            wp_send_json_error('Server error: ' . $e->getMessage());
        }
    }

    /**
     * AJAX: Get messages for visitor
     */
    public function ajaxGetMessages(): void
    {
        try {
            check_ajax_referer('instinctor_chat', 'nonce');
            
            $visitorId = isset($_POST['visitor_id']) ? sanitize_text_field(wp_unslash($_POST['visitor_id'])) : '';
            
            if (empty($visitorId)) {
                wp_send_json_success(['messages' => []]);
                return;
            }
            
            $conversation = $this->getConversationByVisitor($visitorId);
            
            if (!$conversation) {
                wp_send_json_success(['messages' => []]);
                return;
            }
            
            $messages = $this->getMessages($conversation->id);
            
            // Mark admin messages as read
            $this->markMessagesRead($conversation->id, 'admin');
            
            wp_send_json_success(['messages' => $messages]);
        } catch (\Throwable $e) {
            wp_send_json_success(['messages' => [], 'debug_error' => $e->getMessage()]);
        }
    }

    /**
     * AJAX: Visitor typing indicator (nopriv)
     * Sets visitor_typing_at timestamp; admin poll reads it and shows dots if < 6s old.
     */
    public function ajaxSetTyping(): void
    {
        // WP.org B1 — public state-changing handler; verify the frontend nonce.
        // No cap check — typing indicators are anonymous-visitor-scoped.
        if (!check_ajax_referer('instinctor_frontend', 'nonce', false)) {
            wp_send_json_error(['message' => 'bad_nonce'], 403);
            return;
        }
        $visitorId = isset($_POST['visitor_id']) ? sanitize_text_field(wp_unslash($_POST['visitor_id'])) : '';
        if (empty($visitorId)) { wp_send_json_success(); return; }

        $conversation = $this->getConversationByVisitor($visitorId);
        if (!$conversation) { wp_send_json_success(); return; }

        DB::update(
            $this->table_conversations,
            ['visitor_typing_at' => current_time('mysql')],
            ['id' => $conversation->id]
        );
        wp_send_json_success();
    }

    /**
     * AJAX: Admin reply
     */
    public function ajaxAdminReply(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');

        if (!$this->canAccessChat()) {
            wp_send_json_error('Permission denied');
            return;
        }

        $convId  = isset($_POST['conversation_id']) ? intval($_POST['conversation_id']) : 0;
        $message = isset($_POST['message']) ? sanitize_textarea_field(wp_unslash($_POST['message'])) : '';

        if (empty($convId) || empty($message)) {
            wp_send_json_error('Missing required fields');
            return;
        }

        $currentUserId = Auth::userId();

        $messageId = $this->addMessage($convId, 'admin', $message, $currentUserId);

        // Clear handoff status and assign agent when human responds
        DB::update(
            $this->table_conversations,
            [
                'handoff_status'    => 'resolved',
                'assigned_agent_id' => $currentUserId
            ],
            ['id' => $convId]
        );

        wp_send_json_success([
            'message_id'        => $messageId,
            'assigned_agent_id' => $currentUserId
        ]);
    }

    /**
     * AJAX: Get visitor history (previous conversations by same email/phone)
     */
    public function ajaxGetVisitorHistory(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        if (!$this->canAccessChat()) { wp_send_json_error('Permission denied'); return; }

        $convId = isset($_POST['conversation_id']) ? intval($_POST['conversation_id']) : 0;
        if (!$convId) { wp_send_json_error('Missing conversation_id'); return; }

        $conv = $this->getConversation($convId);
        if (!$conv) { wp_send_json_error('Not found'); return; }

        $email = trim($conv->visitor_email ?? '');
        $phone = trim($conv->visitor_phone ?? '');

        if (empty($email) && empty($phone)) {
            wp_send_json_success(['conversations' => [], 'total' => 0]);
            return;
        }

        $where  = [];
        $values = [];

        if ($email) {
            $where[]  = 'c.visitor_email = %s';
            $values[] = $email;
        }
        if ($phone) {
            $where[]  = 'c.visitor_phone = %s';
            $values[] = $phone;
        }

        $whereClause = '(' . implode(' OR ', $where) . ') AND c.id != %d';
        $values[]    = $convId;

        $sql = "SELECT c.id, c.visitor_name, c.status, c.created_at,
                    (SELECT message FROM {$this->table_messages} m
                     WHERE m.conversation_id = c.id AND m.is_note = 0
                     ORDER BY m.created_at DESC LIMIT 1) AS last_message,
                    (SELECT COUNT(*) FROM {$this->table_messages} m2
                     WHERE m2.conversation_id = c.id AND m2.is_note = 0) AS message_count
                FROM {$this->table_conversations} c
                WHERE {$whereClause}
                ORDER BY c.created_at DESC
                LIMIT 20";

        $rows = DB::get_results(DB::prepare($sql, ...$values)) ?: [];

        wp_send_json_success([
            'conversations' => $rows,
            'total'         => count($rows),
            'email'         => $email,
            'phone'         => $phone,
        ]);
    }

    /**
     * Build a compact product catalog context string for the AI prompt.
     * Pulls published products with price, stock, category, and short description.
     * Capped at 50 products (featured/bestsellers first) to keep prompt size sane.
     */
    private function getProductCatalogContext(): string
    {
        $p     = DB::prefix();
        $limit = 50;

        try {
            $total = (int) DB::get_var(
                "SELECT COUNT(*) FROM {$p}instinctor_products WHERE status = 'published'"
            );

            if ($total === 0) {
                return '';
            }

            $products = DB::get_results(
                "SELECT p.id, p.name, p.sku, p.price, p.sale_price, p.stock_status,
                        p.stock_quantity, p.manage_stock, p.short_description,
                        p.is_featured, p.is_bestseller, p.product_type
                 FROM {$p}instinctor_products p
                 WHERE p.status = 'published'
                 ORDER BY p.is_featured DESC, p.is_bestseller DESC, p.sort_order ASC, p.id ASC
                 LIMIT {$limit}"
            ) ?: [];

            if (empty($products)) {
                return '';
            }

            // Fetch category names for all returned products in one query
            $ids     = implode(',', array_map(fn($r) => (int)$r->id, $products));
            $catRows = DB::get_results(
                "SELECT pc.product_id, c.name
                 FROM {$p}instinctor_product_categories pc
                 JOIN {$p}instinctor_categories c ON c.id = pc.category_id
                 WHERE pc.product_id IN ({$ids})"
            ) ?: [];

            $catMap = [];
            foreach ($catRows as $row) {
                $catMap[$row->product_id][] = $row->name;
            }

            $lines  = [];
            $symbol = Config::get('instinctor_currency_symbol', '$');

            foreach ($products as $pr) {
                $regularPrice   = (float)($pr->price ?? 0);
                $salePrice      = (float)($pr->sale_price ?? 0);
                $effectivePrice = ($salePrice > 0 && $salePrice < $regularPrice) ? $salePrice : $regularPrice;

                $priceStr = $effectivePrice > 0
                    ? $symbol . number_format($effectivePrice, 2)
                    : 'price not set';
                if ($salePrice > 0 && $salePrice < $regularPrice) {
                    $priceStr .= ' (sale, was ' . $symbol . number_format($regularPrice, 2) . ')';
                }

                if ($pr->stock_status === 'outofstock') {
                    $stockStr = 'out of stock';
                } elseif ($pr->manage_stock && $pr->stock_quantity !== null) {
                    $stockStr = (int)$pr->stock_quantity . ' in stock';
                } else {
                    $stockStr = 'in stock';
                }

                $line = '- ' . $pr->name;
                if ($pr->sku)                          $line .= ' [SKU: ' . $pr->sku . ']';
                $line .= ' | ' . $priceStr;
                $line .= ' | ' . $stockStr;
                if (!empty($catMap[$pr->id]))           $line .= ' | ' . implode(', ', $catMap[$pr->id]);
                $desc = trim($pr->short_description ?? '');
                if ($desc)                             $line .= ' | ' . $desc;

                $lines[] = $line;
            }

            $header = "\n\nPRODUCT CATALOG (" . count($products) . ' of ' . $total . ' published products)';
            if ($total > $limit) {
                $header .= " [showing top {$limit}, featured and bestsellers first]";
            }
            $header .= ':';

            return $header . "\n" . implode("\n", $lines);

        } catch (\Throwable $e) {
            return '';
        }
    }

    /**
     * Fetch live store context for AI replies using Instinctor's own Sales and Clients data.
     * Looks up the visitor by email in instinctor_customers, pulls their order history
     * and order line items — exactly the same data shown in Store > Sales and Store > Clients.
     */
    private function getLiveStoreContext(int $convId, string $visitorEmail): string
    {
        if (empty($visitorEmail)) {
            return '';
        }

        // Caller checks instinctor_chat_ai_enabled — when we reach this point
        // the agent is on and we always include store + customer context.
        $p     = DB::prefix();
        $lines = [];

        try {
            // ── Client profile (Store > Clients) ─────────────────
            $customer = DB::get_row(DB::prepare(
                "SELECT c.id, c.first_name, c.last_name, 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 {$p}instinctor_customers c
                 LEFT JOIN {$p}instinctor_orders o ON o.customer_id = c.id
                 WHERE c.email = %s
                 GROUP BY c.id",
                $visitorEmail
            ));

            if ($customer) {
                $name = trim(($customer->first_name ?? '') . ' ' . ($customer->last_name ?? ''));
                $lines[] = 'VISITOR PROFILE:';
                if ($name)                    $lines[] = '  Name: ' . $name;
                if ($customer->phone)         $lines[] = '  Phone: ' . $customer->phone;
                $lines[] = '  Orders placed: ' . (int)$customer->order_count;
                $lines[] = '  Total spent: $' . number_format((float)$customer->total_spent, 2);
                if ($customer->last_order_date) {
                    $lines[] = '  Last order: ' . gmdate('Y-m-d', strtotime($customer->last_order_date));
                }
            }

            // ── Order history (Store > Sales) ─────────────────────
            $orders = DB::get_results(DB::prepare(
                "SELECT o.id, o.order_number, o.status, o.total, o.created_at
                 FROM {$p}instinctor_orders o
                 JOIN {$p}instinctor_customers c ON o.customer_id = c.id
                 WHERE c.email = %s
                 ORDER BY o.created_at DESC
                 LIMIT 5",
                $visitorEmail
            )) ?: [];

            if (!empty($orders)) {
                $lines[] = '';
                $lines[] = 'RECENT ORDERS:';
                foreach ($orders as $order) {
                    $items = DB::get_results(DB::prepare(
                        "SELECT name, quantity, total FROM {$p}instinctor_order_items WHERE order_id = %d",
                        $order->id
                    )) ?: [];
                    $itemList = implode(', ', array_map(
                        fn($i) => $i->name . ' ×' . (int)$i->quantity,
                        $items
                    ));
                    $lines[] = '  Order #' . ($order->order_number ?: $order->id)
                        . ' (' . $order->status . ')'
                        . ', $' . number_format((float)$order->total, 2)
                        . ($itemList ? ', ' . $itemList : '')
                        . ', ' . substr($order->created_at, 0, 10);
                }
            }
        } catch (\Throwable $e) {}

        // ── Admin note on this visitor ────────────────────────────
        try {
            $noteRow = $this->getVisitorNoteByContact($visitorEmail, '');
            if ($noteRow && !empty(trim($noteRow->note))) {
                $lines[] = '';
                $lines[] = 'ADMIN NOTE ON THIS VISITOR:';
                $lines[] = '  ' . trim($noteRow->note);
            }
        } catch (\Throwable $e) {}

        return empty($lines) ? '' : "\n\nLIVE STORE DATA:\n" . implode("\n", $lines);
    }

    /**
     * Create a booking from a chat conversation.
     * Handles find-or-create customer and inserts the booking record.
     *
     * @param  array $data  Keys: service_id, date (Y-m-d), time (HH:mm),
     *                            name, email (optional), phone (optional), notes (optional)
     * @return array        ['success'=>bool, 'booking_id'=>int, 'service'=>string,
     *                       'day'=>string, 'time'=>string] or ['success'=>false, 'error'=>string]
     */
    private function createBookingFromChat(array $data): array
    {
        $p = DB::prefix();

        $serviceId = intval($data['service_id'] ?? 0);
        $date      = sanitize_text_field($data['date']  ?? '');
        $time      = sanitize_text_field($data['time']  ?? '');
        $name      = sanitize_text_field($data['name']  ?? '');
        $email     = sanitize_email($data['email']      ?? '');
        $phone     = sanitize_text_field($data['phone'] ?? '');
        $notes     = sanitize_textarea_field($data['notes'] ?? '');

        if (!$serviceId || !$date || !$time || !$name) {
            return ['success' => false, 'error' => 'Missing required fields'];
        }

        $service = DB::get_row(DB::prepare(
            "SELECT id, name, duration, buffer_before, buffer_after, capacity
             FROM {$p}instinctor_services WHERE id = %d AND status = 'active'",
            $serviceId
        ));
        if (!$service) {
            return ['success' => false, 'error' => 'Service not found'];
        }

        $duration    = intval($service->duration ?: 60);
        $startTime   = $date . ' ' . $time . ':00';
        $endDateTime = new \DateTime($startTime);
        $endDateTime->modify('+' . $duration . ' minutes');
        $endTime     = $endDateTime->format('Y-m-d H:i:s');

        // Conflict check with buffers
        $bufBefore = intval($service->buffer_before) * 60;
        $bufAfter  = intval($service->buffer_after) * 60;
        $startBuf  = gmdate('Y-m-d H:i:s', strtotime($startTime) - $bufBefore);
        $endBuf    = gmdate('Y-m-d H:i:s', strtotime($endTime) + $bufAfter);

        $conflict = (int) DB::get_var(DB::prepare(
            "SELECT COUNT(*) FROM {$p}instinctor_bookings
             WHERE product_id = %d AND status NOT IN ('cancelled')
             AND (start_time < %s AND end_time > %s)",
            $serviceId, $endBuf, $startBuf
        ));
        if ($conflict > 0 && $conflict >= intval($service->capacity ?: 1)) {
            return ['success' => false, 'error' => 'That slot is no longer available'];
        }

        // Find or create customer
        $customerId = 0;
        if ($email) {
            $customer = DB::get_row(DB::prepare(
                "SELECT id FROM {$p}instinctor_customers WHERE email = %s LIMIT 1",
                $email
            ));
            if ($customer) {
                $customerId = (int) $customer->id;
            } else {
                $nameParts = explode(' ', $name, 2);
                DB::insert($p . 'instinctor_customers', [
                    'first_name' => $nameParts[0],
                    'last_name'  => $nameParts[1] ?? '',
                    'email'      => $email,
                    'phone'      => $phone,
                    'created_at' => current_time('mysql'),
                ]);
                $customerId = (int) DB::insert_id();
            }
        }

        $bs = \Instinctor\Services\BookingService::instance();

        DB::insert($p . 'instinctor_bookings', [
            'product_id'    => $serviceId,
            'customer_id'   => $customerId,
            'order_id'      => 0,
            'status'        => 'pending',
            'start_time'    => $startTime,
            'end_time'      => $endTime,
            'timezone'      => $bs->getSettings()['timezone'] ?? 'UTC',
            'customer_note' => $notes,
            'meta'          => wp_json_encode([
                'duration'     => $duration,
                'created_via'  => 'chat',
                'service_name' => $service->name,
                'phone'        => $phone,
            ]),
            'created_at'    => current_time('mysql'),
        ]);

        $bookingId = (int) DB::insert_id();
        if (!$bookingId) {
            return ['success' => false, 'error' => 'Failed to save booking'];
        }

        try {
            $bs->sendBookingConfirmation($bookingId);
        } catch (\Throwable $e) {
            // Non-fatal — booking exists, email failed
        }

        return [
            'success'    => true,
            'booking_id' => $bookingId,
            'service'    => $service->name,
            'day'        => gmdate('l, F j', strtotime($date)),
            'time'       => gmdate('g:i A', strtotime($startTime)),
        ];
    }

    /**
     * AJAX: Get visitor note by conversation (resolves email/phone from conversation)
     */
    public function ajaxGetVisitorNote(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        if (!$this->canAccessChat()) { wp_send_json_error('Permission denied'); return; }

        $convId = intval($_POST['conversation_id'] ?? 0);
        if (!$convId) { wp_send_json_error('Missing conversation_id'); return; }

        $conv = $this->getConversation($convId);
        if (!$conv) { wp_send_json_error('Not found'); return; }

        $note = $this->getVisitorNoteByContact(
            trim($conv->visitor_email ?? ''),
            trim($conv->visitor_phone ?? '')
        );

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

    /**
     * AJAX: Upsert visitor note by conversation
     */
    public function ajaxSaveVisitorNote(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        if (!$this->canAccessChat()) { wp_send_json_error('Permission denied'); return; }

        $convId = intval($_POST['conversation_id'] ?? 0);
        $text   = isset($_POST['note']) ? sanitize_textarea_field(wp_unslash($_POST['note'])) : '';

        if (!$convId) { wp_send_json_error('Missing conversation_id'); return; }

        $conv = $this->getConversation($convId);
        if (!$conv) { wp_send_json_error('Not found'); return; }

        $email = trim($conv->visitor_email ?? '');
        $phone = trim($conv->visitor_phone ?? '');

        if (empty($email) && empty($phone)) {
            wp_send_json_error('Visitor has no contact info, cannot attach note');
            return;
        }

        $this->upsertVisitorNote($email, $phone, $text, Auth::userId());
        wp_send_json_success(['note' => $text]);
    }

    /**
     * Get visitor note row by email/phone
     */
    private function getVisitorNoteByContact(string $email, string $phone): ?object
    {
        $tableExists = DB::get_var("SHOW TABLES LIKE '{$this->table_visitor_notes}'");
        if (!$tableExists) return null;

        if ($email) {
            $row = DB::get_row(DB::prepare(
                "SELECT * FROM {$this->table_visitor_notes} WHERE email = %s",
                $email
            ));
            if ($row) return $row;
        }
        if ($phone) {
            return DB::get_row(DB::prepare(
                "SELECT * FROM {$this->table_visitor_notes} WHERE phone = %s",
                $phone
            ));
        }
        return null;
    }

    /**
     * Upsert visitor note — one row per visitor keyed by email (phone fallback)
     */
    private function upsertVisitorNote(string $email, string $phone, string $note, int $adminId): void
    {
        $tableExists = DB::get_var("SHOW TABLES LIKE '{$this->table_visitor_notes}'");
        if (!$tableExists) $this->createTables();

        $existing = $this->getVisitorNoteByContact($email, $phone);
        $now = current_time('mysql');

        if ($existing) {
            DB::update(
                $this->table_visitor_notes,
                ['note' => $note, 'updated_at' => $now, 'updated_by' => $adminId],
                ['id' => $existing->id]
            );
        } else {
            DB::insert($this->table_visitor_notes, [
                'email'      => $email,
                'phone'      => $phone ?: '',
                'note'       => $note,
                'updated_at' => $now,
                'updated_by' => $adminId,
            ]);
        }
    }

    /**
     * AJAX: Get conversation for admin
     */
    public function ajaxGetConversation(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!$this->canAccessChat()) {
            wp_send_json_error('Permission denied');
        }
        
        $convId = isset($_POST['conversation_id']) ? intval($_POST['conversation_id']) : 0;
        
        $conversation = $this->getConversation($convId);
        $messages = $this->getMessages($convId);
        
        // Mark visitor messages as read
        $this->markMessagesRead($convId, 'visitor');
        
        // Check if visitor typed in the last 6 seconds
        $visitorIsTyping = false;
        if ($conversation && !empty($conversation->visitor_typing_at)) {
            $visitorIsTyping = (time() - strtotime($conversation->visitor_typing_at)) < 6;
        }
        
        wp_send_json_success([
            'conversation'      => $conversation,
            'messages'          => $messages,
            'visitor_is_typing' => $visitorIsTyping,
        ]);
    }

    /**
     * AJAX: Get all conversations
     */
    public function ajaxGetConversations(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!$this->canAccessChat()) {
            wp_send_json_error('Permission denied');
        }
        
        // Auto-close stale conversations before fetching
        $this->autoCloseStaleConversations();
        
        // Get status filter (default to active only)
        $status = isset($_POST['status']) ? sanitize_text_field(wp_unslash($_POST['status'])) : 'active';
        
        $conversations = $this->getConversations($status);
        wp_send_json_success(['conversations' => $conversations]);
    }

    /**
     * AJAX: Mark messages as read
     */
    public function ajaxMarkRead(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!$this->canAccessChat()) {
            wp_send_json_error('Permission denied');
        }
        
        $convId = isset($_POST['conversation_id']) ? intval($_POST['conversation_id']) : 0;
        $this->markMessagesRead($convId, 'visitor');
        wp_send_json_success();
    }

    /**
     * AJAX: Close conversation
     */
    public function ajaxCloseConversation(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!$this->canAccessChat()) {
            wp_send_json_error('Permission denied');
        }
        
        $convId = isset($_POST['conversation_id']) ? intval($_POST['conversation_id']) : 0;
        $this->updateConversation($convId, ['status' => 'closed']);
        wp_send_json_success();
    }

    /**
     * AJAX: Close all conversations
     */
    public function ajaxCloseAllConversations(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $table = DB::prefix() . 'instinctor_chat_conversations';
        
        DB::update(
            $table,
            ['status' => 'closed'],
            ['status' => 'active'],
            ['%s'],
            ['%s']
        );
        
        wp_send_json_success(['message' => 'All conversations closed']);
    }

    /**
     * AJAX: Claim, release, or take-over a conversation.
     * Claiming stops AI replies — hasHumanResponded() returns true when assigned_agent_id is set.
     */
    public function ajaxClaimConversation(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        if (!$this->canAccessChat()) { wp_send_json_error('Permission denied'); return; }

        $convId      = isset($_POST['conversation_id']) ? intval($_POST['conversation_id'])                        : 0;
        $claimAction = isset($_POST['claim_action'])    ? sanitize_text_field(wp_unslash($_POST['claim_action'])) : 'claim';
        if (!$convId) { wp_send_json_error('Missing conversation_id'); return; }

        $currentUserId = Auth::userId();

        if ($claimAction === 'release') {
            DB::update($this->table_conversations, ['assigned_agent_id' => null], ['id' => $convId]);
            wp_send_json_success(['claimed' => false, 'assigned_agent_id' => null, 'assigned_agent_name' => null]);
            return;
        }

        // Claim or take-over: assign to this user, clear handoff state
        DB::update(
            $this->table_conversations,
            ['assigned_agent_id' => $currentUserId, 'handoff_status' => 'resolved'],
            ['id' => $convId]
        );
        $agentName = '';
        if ($currentUserId) {
            $u = get_userdata($currentUserId);
            $agentName = $u ? ($u->display_name ?: $u->user_login) : '';
        }
        wp_send_json_success(['claimed' => true, 'assigned_agent_id' => $currentUserId, 'assigned_agent_name' => $agentName]);
    }

    /**
     * AJAX: Delete single conversation
     */
    public function ajaxDeleteConversation(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $convId = isset($_POST['conversation_id']) ? intval($_POST['conversation_id']) : 0;
        if (!$convId) {
            wp_send_json_error('Invalid conversation');
        }
        
        
        // Delete messages first
        DB::delete(
            DB::prefix() . 'instinctor_chat_messages',
            ['conversation_id' => $convId],
            ['%d']
        );
        
        // Delete conversation
        DB::delete(
            DB::prefix() . 'instinctor_chat_conversations',
            ['id' => $convId],
            ['%d']
        );
        
        wp_send_json_success(['message' => 'Conversation deleted']);
    }

    /**
     * AJAX: Delete all conversations
     */
    public function ajaxDeleteAllConversations(): void
    {
        $p = DB::prefix();
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        
        // Delete all messages
        DB::query("TRUNCATE TABLE {$p}instinctor_chat_messages");
        
        // Delete all conversations
        DB::query("TRUNCATE TABLE {$p}instinctor_chat_conversations");
        
        wp_send_json_success(['message' => 'All conversations deleted']);
    }

    /**
     * AJAX: Save settings
     */
    public function ajaxToggleEnabled(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');

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

        $enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1' ? '1' : '0';
        Config::set('instinctor_chat_enabled', $enabled);

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

    public function ajaxSaveSettings(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');

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

        // PARTIAL SAVE — only update fields explicitly present in $_POST.
        // Full-replace was disabling chat globally on partial Settings-panel saves
        // (e.g. dragging a chat color swatch posts only color_button, which would
        // reset instinctor_chat_enabled to '0' and make the widget disappear).
        $settings = [];

        // Boolean toggles
        $bool_map = [
            'enabled'                => 'instinctor_chat_enabled',
            'show_online'            => 'instinctor_chat_show_online',
            'sound_enabled'          => 'instinctor_chat_sound_enabled',
            'screenshots'            => 'instinctor_chat_screenshots',
            'attachments'            => 'instinctor_chat_attachments',
            'collect_name'           => 'instinctor_chat_collect_name',
            'collect_email'          => 'instinctor_chat_collect_email',
            'collect_phone'          => 'instinctor_chat_collect_phone',
            'email_include_cart'     => 'instinctor_chat_email_include_cart',
            'sms_include_cart'       => 'instinctor_chat_sms_include_cart',
            'whatsapp_include_cart'  => 'instinctor_chat_whatsapp_include_cart',
            'ai_enabled'             => 'instinctor_chat_ai_enabled',
        ];
        foreach ($bool_map as $k => $optKey) {
            if (isset($_POST[$k])) $settings[$optKey] = $_POST[$k] === '1' ? '1' : '0';
        }

        // Plain text fields (sanitize_text_field)
        $text_map = [
            'title'              => 'instinctor_chat_title',
            'online_text'        => 'instinctor_chat_online_text',
            'sms_number'         => 'instinctor_chat_sms_number',
            'whatsapp_number'    => 'instinctor_chat_whatsapp_number',
            'chat_greeting_text' => 'instinctor_chat_greeting_text',
        ];
        foreach ($text_map as $k => $optKey) {
            if (isset($_POST[$k])) $settings[$optKey] = sanitize_text_field(wp_unslash($_POST[$k]));
        }

        // Multi-line text fields (sanitize_textarea_field)
        $textarea_map = [
            'greeting'         => 'instinctor_chat_greeting',
            'ai_store_info'    => 'instinctor_chat_ai_store_info',
            'handoff_message'  => 'instinctor_chat_handoff_message',
        ];
        foreach ($textarea_map as $k => $optKey) {
            if (isset($_POST[$k])) $settings[$optKey] = sanitize_textarea_field(wp_unslash($_POST[$k]));
        }

        // Hex colors
        $color_map = [
            'color_button'        => 'instinctor_chat_color_button',
            'color_input_border'  => 'instinctor_chat_color_input_border',
            'chat_greeting_color' => 'instinctor_chat_greeting_color',
        ];
        foreach ($color_map as $k => $optKey) {
            if (isset($_POST[$k])) {
                $hex = sanitize_hex_color(wp_unslash($_POST[$k]));
                if ($hex) $settings[$optKey] = $hex;
            }
        }

        // Position (enum)
        if (isset($_POST['position'])) {
            $position = sanitize_text_field(wp_unslash($_POST['position']));
            $settings['instinctor_chat_position'] = in_array($position, ['left', 'right'], true) ? $position : 'right';
        }

        // Avatar (URL)
        if (isset($_POST['avatar'])) {
            $settings['instinctor_chat_avatar'] = esc_url_raw(wp_unslash($_POST['avatar']));
        }

        // Email address
        if (isset($_POST['email_address'])) {
            $settings['instinctor_chat_email_address'] = sanitize_email(wp_unslash($_POST['email_address']));
        }

        // Bounded integers
        if (isset($_POST['widget_height'])) {
            $settings['instinctor_chat_widget_height'] = max(400, min(700, intval($_POST['widget_height'])));
        }
        if (isset($_POST['bubble_opacity'])) {
            $settings['instinctor_chat_bubble_opacity'] = max(0, min(100, intval($_POST['bubble_opacity'])));
        }
        if (isset($_POST['input_border_width'])) {
            $settings['instinctor_chat_input_border_width'] = max(0, min(5, intval($_POST['input_border_width'])));
        }
        if (isset($_POST['handoff_timeout'])) {
            $settings['instinctor_chat_handoff_timeout'] = max(1, min(60, intval($_POST['handoff_timeout'])));
        }
        if (isset($_POST['auto_close_minutes'])) {
            $settings['instinctor_chat_auto_close_minutes'] = max(0, min(1440, intval($_POST['auto_close_minutes'])));
        }

        // AI tone (enum)
        if (isset($_POST['ai_tone'])) {
            $tone = sanitize_key(wp_unslash($_POST['ai_tone']));
            $settings['instinctor_chat_ai_tone'] = in_array($tone, ['friendly', 'professional', 'concise'], true) ? $tone : 'friendly';
        }

        foreach ($settings as $key => $value) {
            Config::set($key, $value);
        }

        wp_send_json_success();
    }

    /**
     * AJAX: Test notification
     */
    public function ajaxTestNotification(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $channel = isset($_POST['channel']) ? sanitize_text_field(wp_unslash($_POST['channel'])) : '';
        $to = isset($_POST['to']) ? sanitize_text_field(wp_unslash($_POST['to'])) : '';
        
        
        if (empty($to)) {
            wp_send_json_error('Please enter a recipient');
        }
        
        $siteName = get_bloginfo('name');
        $testMessage = "This is a test message from {$siteName} Chat.";
        $errorMessage = '';
        $sent = false;
        
        switch ($channel) {
            case 'email':
                // Check if email is valid
                if (!is_email($to)) {
                    wp_send_json_error('Invalid email address');
                    return;
                }
                $licenseKey = $this->getLicenseKey();
                $result = $this->callRelay('email', [
                    'license_key' => $licenseKey,
                    'site_url'    => home_url(),
                    'to'          => $to,
                    'subject'     => "[{$siteName}] Test Chat Notification",
                    'html'        => nl2br(esc_html($testMessage)),
                ]);
                $sent = !empty($result['success']);
                if (!$sent) {
                    $errorMessage = $result['error'] ?? $result['message'] ?? 'Email delivery failed. Check relay server.';
                }
                break;
                
            case 'sms':
            case 'whatsapp':
                $licenseKey = $this->getLicenseKey();
                
                if (empty($licenseKey)) {
                    wp_send_json_error('No license key found. Please activate the Chat addon first.');
                    return;
                }
                
                $requestData = [
                    'license_key' => $licenseKey,
                    'site_url'    => home_url(),
                    'to'          => $to,
                    'message'     => $testMessage,
                    'channel'     => $channel,
                ];
                
                $result = $this->callRelay('sms', $requestData);
                
                
                $sent = !empty($result['success']);
                if (!$sent) {
                    $errorMessage = $result['error'] ?? 'Relay server error. Check your balance and phone number format (+1234567890).';
                }
                break;
                
            default:
                wp_send_json_error('Invalid channel: ' . $channel);
                return;
        }
        
        if ($sent) {
            wp_send_json_success([
                'message' => ucfirst($channel) . ' sent successfully to ' . $to,
                'method' => $result['method'] ?? 'relay',
                'http_code' => $result['http_code'] ?? null
            ]);
        } else {
            wp_send_json_error($errorMessage ?: 'Failed to send');
        }
    }

    /**
     * AJAX: Get usage stats
     */
    public function ajaxGetUsage(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $result = $this->callRelay('quota', [
            'license_key' => $this->getLicenseKey()
        ]);
        
        if (!empty($result['success'])) {
            wp_send_json_success([
                'plan' => $result['plan'] ?? 'Free',
                'usage' => $result['usage'] ?? 0,
                'quota' => $result['quota'] ?? 0,
                'remaining' => $result['remaining'] ?? 0,
                'overage' => $result['overage'] ?? 0
            ]);
        } else {
            wp_send_json_error('Could not fetch usage');
        }
    }

    /**
     * AJAX: Get balance
     */
    public function ajaxGetBalance(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $result = $this->callRelay('get_balance', [
            'license_key' => $this->getLicenseKey()
        ]);
        
        if (!empty($result['success'])) {
            wp_send_json_success([
                'balance' => $result['balance'] ?? 0
            ]);
        } else {
            wp_send_json_error('Could not fetch balance');
        }
    }

    /**
     * AJAX: Get payment method
     */
    public function ajaxGetPaymentMethod(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $result = $this->callRelay('get_payment_method', [
            'license_key' => $this->getLicenseKey()
        ]);
        
        if (!empty($result['success']) && !empty($result['card'])) {
            wp_send_json_success([
                'card' => $result['card']
            ]);
        } else {
            wp_send_json_success(['card' => null]);
        }
    }

    /**
     * AJAX: Get transactions
     */
    public function ajaxGetTransactions(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $result = $this->callRelay('get_transactions', [
            'license_key' => $this->getLicenseKey(),
            'limit' => 20
        ]);
        
        if (!empty($result['success'])) {
            wp_send_json_success([
                'transactions' => $result['transactions'] ?? []
            ]);
        } else {
            wp_send_json_success(['transactions' => []]);
        }
    }

    /**
     * AJAX: Add funds (creates Stripe checkout session)
     */
    public function ajaxAddFunds(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $amount = floatval($_POST['amount'] ?? 20);
        if ($amount < 5) {
            $amount = 20;
        }
        
        $result = $this->callRelay('create_checkout', [
            'license_key' => $this->getLicenseKey(),
            'amount' => $amount,
            'return_url' => admin_url('admin.php?page=instinctor-chat-settings&payment=success'),
            'cancel_url' => admin_url('admin.php?page=instinctor-chat-settings&payment=cancelled'),
            'store_name' => get_bloginfo('name'),
            'store_email' => Config::get('admin_email', '')
        ]);
        
        if (!empty($result['success']) && !empty($result['checkout_url'])) {
            wp_send_json_success([
                'checkout_url' => $result['checkout_url']
            ]);
        } else {
            wp_send_json_error($result['error'] ?? 'Could not create checkout session');
        }
    }

    /**
     * AJAX: Get card update URL
     */
    public function ajaxGetCardUpdateUrl(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $result = $this->callRelay('get_card_update_url', [
            'license_key' => $this->getLicenseKey(),
            'return_url' => admin_url('admin.php?page=instinctor-chat-settings')
        ]);
        
        if (!empty($result['success']) && !empty($result['url'])) {
            wp_send_json_success([
                'url' => $result['url']
            ]);
        } else {
            wp_send_json_error($result['error'] ?? 'Could not get update link');
        }
    }

    /**
     * AJAX: Set auto-refill
     */
    public function ajaxSetAutoRefill(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $enabled = isset($_POST['enabled']) && $_POST['enabled'] === '1' ? '1' : '0';
        
        // Save locally
        Config::set('instinctor_chat_auto_refill', $enabled);
        
        // Notify relay
        $this->callRelay('set_auto_refill', [
            'license_key' => $this->getLicenseKey(),
            'enabled' => $enabled
        ]);
        
        wp_send_json_success();
    }

    /**
     * AJAX: Save quick actions
     */
    public function ajaxSaveQuickActions(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!$this->canAccessChat()) {
            wp_send_json_error('Permission denied');
        }
        
        // Accepts array of strings OR array of {text, cat} objects (from drag-sort)
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- array of quick-action rows; each field is sanitized below.
        $raw = isset($_POST['quick_actions']) ? (array) wp_unslash($_POST['quick_actions']) : [];
        $quickActions = array_slice(array_map([$this, 'normalizeQuickAction'], $raw), 0, 30);
        
        Config::set('instinctor_chat_quick_actions', $quickActions);
        
        wp_send_json_success(['quick_actions' => $quickActions]);
    }

    /**
     * AJAX: Get quick actions
     */
    public function ajaxGetQuickActions(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $raw = Config::get('instinctor_chat_quick_actions', $this->getDefaultQuickActions());
        $quickActions = array_map([$this, 'normalizeQuickAction'], (array) $raw);
        
        wp_send_json_success(['quick_actions' => $quickActions]);
    }

    /** Normalize a quick action to always be {text, cat} array */
    private function normalizeQuickAction($item): array
    {
        if (is_string($item)) {
            return ['text' => sanitize_text_field($item), 'cat' => ''];
        }
        $item = (array) $item;
        return [
            'text' => sanitize_text_field($item['text'] ?? ''),
            'cat'  => sanitize_text_field($item['cat']  ?? ''),
        ];
    }

    /**
     * AJAX: Add quick action
     */
    private function getDefaultQuickActions(): array
    {
        return [
            'Thanks for reaching out! How can I help you today?',
            'Let me check on that for you, one moment!',
            'Could you provide your order number so I can look that up?',
            'I\'d be happy to help with that.',
            'I\'m sorry to hear that! Let me make this right for you.',
            'Your order has been shipped and should arrive in 3–5 business days.',
            'Our return policy allows returns within 30 days of purchase.',
            'We\'re open Monday–Friday, 9am–6pm EST.',
            'Feel free to reach out anytime, we typically respond within a few hours.',
            'Is there anything else I can help you with today?',
        ];
    }

    public function ajaxAddQuickAction(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $message  = isset($_POST['message'])  ? sanitize_text_field(wp_unslash($_POST['message']))  : '';
        $category = isset($_POST['category']) ? sanitize_text_field(wp_unslash($_POST['category'])) : '';
        
        if (empty($message)) {
            wp_send_json_error('Message is required');
        }
        
        $raw = Config::get('instinctor_chat_quick_actions', $this->getDefaultQuickActions());
        $quickActions = array_map([$this, 'normalizeQuickAction'], (array) $raw);
        $quickActions[] = ['text' => $message, 'cat' => $category];
        Config::set('instinctor_chat_quick_actions', $quickActions);
        
        wp_send_json_success(['quick_actions' => $quickActions]);
    }

    /**
     * AJAX: Delete quick action
     */
    public function ajaxDeleteQuickAction(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $index = isset($_POST['index']) ? intval($_POST['index']) : -1;
        
        // Default to the built-in quick actions, NOT an empty array: until the
        // owner edits them the config key is UNSET and the panel shows the
        // defaults. Reading [] here made delete/update operate on nothing and
        // return an empty list, so removing ONE preset blanked them ALL in the
        // UI. Mirroring the GET handler's default keeps the untouched defaults
        // intact and only the one item is removed. Sergei 2026-07-02.
        $raw = Config::get('instinctor_chat_quick_actions', $this->getDefaultQuickActions());
        $quickActions = array_map([$this, 'normalizeQuickAction'], (array) $raw);
        
        if ($index >= 0 && $index < count($quickActions)) {
            array_splice($quickActions, $index, 1);
            Config::set('instinctor_chat_quick_actions', array_values($quickActions));
        }
        
        wp_send_json_success(['quick_actions' => $quickActions]);
    }

    /**
     * AJAX: Delete multiple quick actions
     */
    public function ajaxDeleteQuickActions(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $indices = isset($_POST['indices']) ? array_map('intval', (array) $_POST['indices']) : [];
        
        // Default to the built-in quick actions, NOT an empty array: until the
        // owner edits them the config key is UNSET and the panel shows the
        // defaults. Reading [] here made delete/update operate on nothing and
        // return an empty list, so removing ONE preset blanked them ALL in the
        // UI. Mirroring the GET handler's default keeps the untouched defaults
        // intact and only the one item is removed. Sergei 2026-07-02.
        $raw = Config::get('instinctor_chat_quick_actions', $this->getDefaultQuickActions());
        $quickActions = array_map([$this, 'normalizeQuickAction'], (array) $raw);
        
        // Sort indices in reverse order to delete from end first (preserves earlier indices)
        rsort($indices);
        
        foreach ($indices as $index) {
            if ($index >= 0 && $index < count($quickActions)) {
                array_splice($quickActions, $index, 1);
            }
        }
        
        Config::set('instinctor_chat_quick_actions', array_values($quickActions));
        
        wp_send_json_success(['quick_actions' => $quickActions]);
    }

    /**
     * AJAX: Update quick action
     */
    public function ajaxUpdateQuickAction(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $index    = isset($_POST['index'])    ? intval($_POST['index'])                                              : -1;
        $message  = isset($_POST['message'])  ? sanitize_text_field(wp_unslash($_POST['message']))                  : '';
        $category = isset($_POST['category']) ? sanitize_text_field(wp_unslash($_POST['category']))                 : null;
        
        if (empty($message)) {
            wp_send_json_error('Message is required');
        }
        
        // Default to the built-in quick actions, NOT an empty array: until the
        // owner edits them the config key is UNSET and the panel shows the
        // defaults. Reading [] here made delete/update operate on nothing and
        // return an empty list, so removing ONE preset blanked them ALL in the
        // UI. Mirroring the GET handler's default keeps the untouched defaults
        // intact and only the one item is removed. Sergei 2026-07-02.
        $raw = Config::get('instinctor_chat_quick_actions', $this->getDefaultQuickActions());
        $quickActions = array_map([$this, 'normalizeQuickAction'], (array) $raw);
        
        if ($index >= 0 && $index < count($quickActions)) {
            $existing = $quickActions[$index];
            $quickActions[$index] = [
                'text' => $message,
                'cat'  => $category !== null ? $category : $existing['cat'],
            ];
            Config::set('instinctor_chat_quick_actions', $quickActions);
        }
        
        wp_send_json_success(['quick_actions' => $quickActions]);
    }

    /**
     * AJAX: Get agents
     */
    public function ajaxGetAgents(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');

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

        $tableExists = DB::get_var("SHOW TABLES LIKE '{$this->table_agents}'");
        if (!$tableExists) {
            $this->createTables();
        }

        $agents = DB::get_results("SELECT * FROM {$this->table_agents} ORDER BY created_at DESC");

        wp_send_json_success(['agents' => $agents ?: []]);
    }
    
    /**
     * AJAX: Add agent
     */
    public function ajaxAddAgent(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');

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

        $userId      = intval($_POST['user_id'] ?? 0);
        $displayName = isset($_POST['display_name']) ? sanitize_text_field(wp_unslash($_POST['display_name'])) : '';
        $email       = isset($_POST['email'])        ? sanitize_email(wp_unslash($_POST['email']))             : '';
        $avatar      = isset($_POST['avatar'])       ? esc_url_raw(wp_unslash($_POST['avatar']))               : '';

        // WP-mode only: enrich missing fields from WP user record
        if ($userId > 0 && (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress')) {
            if (function_exists('get_userdata')) {
                $wpUser = get_userdata($userId);
                if ($wpUser) {
                    if (!$displayName) $displayName = $wpUser->display_name;
                    if (!$email)       $email       = $wpUser->user_email;
                    if (!$avatar && function_exists('get_avatar_url')) {
                        $avatar = get_avatar_url($userId, ['size' => 48]);
                    }
                }
            }
        }

        if (empty($displayName)) {
            wp_send_json_error('Name is required');
            return;
        }

        // Ensure table exists (may not on a fresh standalone install)
        $tableExists = DB::get_var("SHOW TABLES LIKE '{$this->table_agents}'");
        if (!$tableExists) {
            $this->createTables();
        }

        // Prevent duplicate (only meaningful when user_id > 0)
        if ($userId > 0) {
            $existing = DB::get_var(DB::prepare(
                "SELECT id FROM {$this->table_agents} WHERE user_id = %d",
                $userId
            ));
            if ($existing) {
                wp_send_json_error('This team member is already an agent');
                return;
            }
        }

        $result = DB::insert(
            $this->table_agents,
            [
                'user_id'         => $userId,
                'display_name'    => $displayName,
                'email'           => $email,
                'sms_number'      => '',
                'whatsapp_number' => '',
                'avatar'          => $avatar,
                'is_active'       => 1,
            ]
        );

        if ($result) {
            wp_send_json_success(['id' => DB::insert_id()]);
        } else {
            wp_send_json_error('Failed to add agent');
        }
    }
    
    /**
     * AJAX: Update agent
     */
    public function ajaxUpdateAgent(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $id = intval($_POST['id'] ?? 0);
        $isActive = intval($_POST['is_active'] ?? 1);
        
        if (!$id) {
            wp_send_json_error('Invalid agent');
            return;
        }
        
        DB::update(
            $this->table_agents,
            ['is_active' => $isActive],
            ['id' => $id]
        );
        
        wp_send_json_success();
    }
    
    /**
     * AJAX: Remove agent
     */
    public function ajaxRemoveAgent(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $id = intval($_POST['id'] ?? 0);
        
        if (!$id) {
            wp_send_json_error('Invalid agent');
            return;
        }
        
        DB::delete($this->table_agents, ['id' => $id]);
        
        wp_send_json_success();
    }

    /**
     * AJAX: Get unread message count
     */
    public function ajaxGetUnreadCount(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $count = $this->getUnreadCount();
        wp_send_json_success(['count' => $count]);
    }

    /**
     * AJAX: Save agent
     */
    public function ajaxSaveAgent(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $userId = isset($_POST['user_id']) ? intval($_POST['user_id']) : 0;
        $smsNumber = isset($_POST['sms_number']) ? sanitize_text_field(wp_unslash($_POST['sms_number'])) : '';
        $whatsappNumber = isset($_POST['whatsapp_number']) ? sanitize_text_field(wp_unslash($_POST['whatsapp_number'])) : '';
        
        if (empty($userId)) {
            wp_send_json_error('User ID required');
        }
        
        $this->saveAgent($userId, [
            'sms_number' => $smsNumber,
            'whatsapp_number' => $whatsappNumber,
            'is_active' => 1
        ]);
        
        wp_send_json_success();
    }

    /**
     * AJAX: Delete agent
     */
    public function ajaxDeleteAgent(): void
    {
        check_ajax_referer('instinctor_chat_admin', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings') && !Auth::can('instinctor_chat')) {
            wp_send_json_error('Permission denied');
        }
        
        $userId = isset($_POST['user_id']) ? intval($_POST['user_id']) : 0;
        $this->deleteAgent($userId);
        wp_send_json_success();
    }

    /**
     * Handle incoming SMS/WhatsApp reply from relay
     * Agent replies via SMS/WhatsApp get forwarded here
     */
    public function handleRelayReply(\WP_REST_Request $request): \WP_REST_Response
    {
        $params = $request->get_json_params();
        $convId = intval($params['conversation_id'] ?? 0);
        $message = sanitize_textarea_field($params['message'] ?? '');
        $agentPhone = sanitize_text_field($params['agent_phone'] ?? '');
        
        if (empty($convId) || empty($message)) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Missing params'], 400);
        }
        
        // Verify conversation exists
        $conversation = $this->getConversation($convId);
        if (!$conversation) {
            return new \WP_REST_Response(['success' => false, 'error' => 'Not found'], 404);
        }
        
        // "#" releases the conversation back to Instagent without saving a message.
        // Any other reply claims the conversation: AI goes silent, owner is in control.
        if (trim($message) === '#') {
            DB::update(
                $this->table_conversations,
                ['assigned_agent_id' => null, 'handoff_status' => 'none'],
                ['id' => $convId]
            );
            return new \WP_REST_Response(['success' => true, 'released' => true]);
        }

        // Add message with a sentinel admin_user_id (0 = relay/SMS agent, not a WP user)
        // This ensures hasHumanResponded() returns true and AI goes silent
        $messageId = $this->addMessage($convId, 'admin', $message, 0);

        // Claim the conversation so AI doesn't resume on the next visitor message
        if (!$conversation->assigned_agent_id) {
            DB::update(
                $this->table_conversations,
                ['assigned_agent_id' => 0, 'handoff_status' => 'resolved'],
                ['id' => $convId]
            );
        }

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

    /**
     * Get agent by phone number
     */
    private function getAgentByPhone(string $phone): ?object
    {
        
        // Normalize phone (remove non-digits except +)
        $normalized = preg_replace('/[^0-9+]/', '', $phone);
        
        // Try SMS number first
        $agent = DB::get_row(DB::prepare(
            "SELECT * FROM {$this->table_agents} WHERE REPLACE(REPLACE(sms_number, '-', ''), ' ', '') LIKE %s",
            '%' . $normalized . '%'
        ));
        
        if ($agent) {
            return $agent;
        }
        
        // Try WhatsApp number
        return DB::get_row(DB::prepare(
            "SELECT * FROM {$this->table_agents} WHERE REPLACE(REPLACE(whatsapp_number, '-', ''), ' ', '') LIKE %s",
            '%' . $normalized . '%'
        ));
    }

    /**
     * Notify agents of new message
     */
    private function notifyAgents(int $convId, string $message, string $visitorName, $cartData, string $aiReply = ''): void
    {
        $licenseKey = $this->getLicenseKey();
        $siteUrl = home_url();
        $siteName = get_bloginfo('name');

        // Get conversation to retrieve visitor email
        $conversation = $this->getConversation($convId);
        $visitorEmail = $conversation->visitor_email ?? '';

        // Only send email on first visitor message in conversation (prevents spam)
        $visitorMessageCount = DB::get_var(DB::prepare(
            "SELECT COUNT(*) FROM {$this->table_messages} WHERE conversation_id = %d AND sender = 'visitor'",
            $convId
        ));
        // If more than 1 visitor message (the current one already saved), skip email
        $shouldSendEmail = ($visitorMessageCount <= 1);

        $visitorPhone   = $conversation->visitor_phone ?? '';
        $smsIncludeCart = Config::get('instinctor_chat_sms_include_cart', '0') === '1';

        // SMS body: visitor identity, optional cart, visitor message, then what
        // Instagent replied (so owner knows if the visitor was handled). Ends with
        // a short hint: reply normally to step in (AI goes silent), reply # to
        // leave Instagent in charge. GSM-safe separators; capped at 400 chars.
        $buildSmsBody = function (bool $includeCart) use ($message, $visitorName, $visitorEmail, $visitorPhone, $cartData, $aiReply) {
            $lines = [];
            $id = [];
            foreach ([$visitorName, $visitorEmail, $visitorPhone] as $f) {
                $f = trim((string) $f);
                if ($f !== '' && strcasecmp($f, 'Visitor') !== 0) $id[] = $f;
            }
            if ($id) $lines[] = implode(' | ', $id);
            if ($includeCart && !empty($cartData)) {
                $cart = is_string($cartData) ? json_decode($cartData, true) : $cartData;
                if (!empty($cart['items']) && is_array($cart['items'])) {
                    $items = [];
                    foreach ($cart['items'] as $it) {
                        $items[] = ($it['name'] ?? 'Item') . ' x' . ($it['quantity'] ?? $it['qty'] ?? 1);
                    }
                    $lines[] = 'Cart: ' . implode(', ', array_slice($items, 0, 3)) . (count($items) > 3 ? '...' : '');
                }
            }
            $lines[] = trim((string) $message);
            if ($aiReply !== '') {
                $lines[] = 'Instagent: ' . mb_substr(trim($aiReply), 0, 120);
            }
            $lines[] = 'Reply to step in. Reply # to leave it to Instagent.';
            $lines = array_filter($lines, static function ($l) { return $l !== ''; });
            return mb_substr(trim(implode("\n", $lines)), 0, 400);
        };

        // Get all active agents from database
        $agents = DB::get_results("SELECT * FROM {$this->table_agents} WHERE is_active = 1");
        
        $emailsSent = [];
        $smsSent = [];
        $whatsappSent = [];
        
        // Notify each agent
        foreach ($agents as $agent) {
            // Email (respect frequency setting)
            if ($shouldSendEmail && !empty($agent->email) && !in_array($agent->email, $emailsSent)) {
                $subject = substr(trim($message), 0, 60) ?: 'New chat message';
                
                $this->sendEmailViaRelay($agent->email, $subject, $message, [
                    'visitor_name' => $visitorName ?: 'Visitor',
                    'visitor_email' => $visitorEmail,
                    'conversation_url' => admin_url('admin.php?page=instinctor-chat&conv=' . $convId)
                ]);
                $emailsSent[] = $agent->email;
            }
            
            // SMS
            if (!empty($agent->sms_number) && !in_array($agent->sms_number, $smsSent)) {
                $smsBody = $buildSmsBody($smsIncludeCart);
                $this->callRelay('sms', [
                    'license_key'     => $licenseKey,
                    'site_url'        => $siteUrl,
                    'to'              => $agent->sms_number,
                    'message'         => $smsBody,
                    'channel'         => 'sms',
                    'conversation_id' => $convId,
                ]);
                $smsSent[] = $agent->sms_number;
            }
            
            // WhatsApp
            if (!empty($agent->whatsapp_number) && !in_array($agent->whatsapp_number, $whatsappSent)) {
                $waBody = substr($message, 0, 300);
                $this->callRelay('sms', [
                    'license_key'     => $licenseKey,
                    'site_url'        => $siteUrl,
                    'to'              => $agent->whatsapp_number,
                    'message'         => $waBody,
                    'channel'         => 'whatsapp',
                    'conversation_id' => $convId,
                ]);
                $whatsappSent[] = $agent->whatsapp_number;
            }
        }
        
        // Fallback: Use settings-based notifications if no agents configured
        if (empty($agents)) {
            // Email notification (respect frequency setting)
            // Empty address = channel off (no separate toggle since 2.1.1501)
            $emailAddress = trim((string) Config::get('instinctor_chat_email_address', ''));
            $notifyEmail  = $emailAddress !== '';
            if ($notifyEmail && $shouldSendEmail) {
                $includeCart = Config::get('instinctor_chat_email_include_cart', '1') === '1';
                
                $subject = substr(trim($message), 0, 60) ?: 'New chat message';
                
                $this->sendEmailViaRelay($emailAddress, $subject, $message, [
                    'visitor_name' => $visitorName ?: 'Visitor',
                    'visitor_email' => $visitorEmail,
                    'cart_data' => $includeCart && $cartData ? wp_json_encode($cartData) : '',
                    'conversation_url' => admin_url('admin.php?page=instinctor-chat&conv=' . $convId)
                ]);
            }
            
            // SMS notification (from settings) - PRO feature
            $smsNumber = trim((string) Config::get('instinctor_chat_sms_number', ''));
            $notifySms = $smsNumber !== '';
            if ($notifySms && !empty($smsNumber) && $this->canSendSms()) {
                $includeCart = Config::get('instinctor_chat_sms_include_cart', '0') === '1';
                $this->callRelay('sms', [
                    'license_key'     => $licenseKey,
                    'site_url'        => $siteUrl,
                    'to'              => $smsNumber,
                    'message'         => $buildSmsBody($includeCart),
                    'channel'         => 'sms',
                    'conversation_id' => $convId,
                ]);
            }
            
            // WhatsApp notification (from settings) - PRO feature
            $whatsappNumber = trim((string) Config::get('instinctor_chat_whatsapp_number', ''));
            $notifyWhatsApp = $whatsappNumber !== '';
            if ($notifyWhatsApp && !empty($whatsappNumber) && $this->canSendWhatsApp()) {
                $includeCart = Config::get('instinctor_chat_whatsapp_include_cart', '0') === '1';
                $this->callRelay('sms', [
                    'license_key'     => $licenseKey,
                    'site_url'        => $siteUrl,
                    'to'              => $whatsappNumber,
                    'message'         => $message,
                    'channel'         => 'whatsapp',
                    'conversation_id' => $convId,
                ]);
            }
        }
    }

    /**
     * Call relay service
     */
    private function callRelay(string $action, array $data): array
    {
        // Map relay action names to platform /api/relay/* endpoints
        $endpointMap = [
            'sms'        => '/sms',
            'email'      => '/email',
            'ai_reply'   => '/ai-reply',
            // Legacy aliases — kept so old call sites don't 404 until fully migrated
            'send' => '/sms',
            'send_reply' => '/send_reply',
            'send_email' => '/email',
            'get_balance' => '/balance',
            'create_checkout' => '/create_checkout',
            'get_transactions' => '/get_transactions',
            'get_payment_method' => '/get_payment_method',
            'get_card_update_url' => '/get_card_update_url',
            'set_auto_refill' => '/set_auto_refill',
            'quota' => '/balance',
            'validate_pro_license' => '/validate',
            'create_pro_checkout' => '/checkout',
            'deactivate_license_site' => '/deactivate_license_site',
            'get_license_info' => '/get_license_info',
            'analytics_ping' => '/analytics_ping',
        ];
        
        $path = $endpointMap[$action] ?? '/' . $action;
        $url = $this->relay_url . $path;

        // WP.org guideline 7/9 — chat relay carries license_key + site_url
        // (+ analytics_ping data). Gated on opt-in.
        if (!\Instinctor\Core\PlatformConsent::granted()) {
            return ['success' => false, 'error' => 'platform_consent_required'];
        }

        // WordPress: use wp_remote_post. Standalone: use curl.
        $response = wp_remote_post($url, [
            'body'    => $data,
            'timeout' => 15,
        ]);
        if (is_wp_error($response)) {
            return ['success' => false, 'error' => 'Connection failed: ' . $response->get_error_message()];
        }
        $httpCode = wp_remote_retrieve_response_code($response);
        $body     = wp_remote_retrieve_body($response);
        
        $decoded = json_decode($body, true);
        if ($decoded === null) {
            return ['success' => false, 'error' => "Relay returned HTTP {$httpCode} with non-JSON response: " . substr($body, 0, 200)];
        }
        
        if ($httpCode >= 400 && empty($decoded['error'])) {
            $decoded['error'] = $decoded['message'] ?? "Relay returned HTTP {$httpCode}";
        }
        
        return $decoded;
    }

    /**
     * Send email via relay server (uses SendGrid for reliable delivery)
     */
    private function sendEmailViaRelay(string $toEmail, string $subject, string $message, array $options = []): bool
    {
        $licenseKey = $this->getLicenseKey();
        $storeName = get_bloginfo('name');
        $storeUrl = home_url();
        
        $result = $this->callRelay('email', [
            'license_key' => $licenseKey,
            'site_url'    => $storeUrl,
            'to'          => $toEmail,
            'subject'     => $subject,
            'html'        => nl2br(esc_html($message)),
        ]);
        
        return !empty($result['success']);
    }
    
    /**
     * Public method for sending emails via relay (used by EmailService)
     */
    public function sendEmail(string $toEmail, string $subject, string $message): bool
    {
        return $this->sendEmailViaRelay($toEmail, $subject, $message);
    }

    // ==================== DATABASE METHODS ====================

    public function getConversations(string $status = 'all', int $limit = 50): array
    {
        $p = DB::prefix();
        $usersTable = $this->usersTable();

        $tableExists = DB::get_var("SHOW TABLES LIKE '{$this->table_conversations}'");
        if (!$tableExists) {
            return [];
        }

        $where = $status !== 'all' ? DB::prepare("WHERE c.status = %s", $status) : "";
        $nt = $this->table_visitor_notes;
        $ntExists = DB::get_var("SHOW TABLES LIKE '$nt'") === $nt;
        $noteJoin   = $ntExists ? "LEFT JOIN $nt vn ON (c.visitor_email != '' AND vn.email = c.visitor_email)" : '';
        $noteSelect = $ntExists ? ", vn.note AS visitor_note" : ", NULL AS visitor_note";

        $sql = "SELECT c.*$noteSelect,
                (SELECT COUNT(*) FROM {$this->table_messages} m WHERE m.conversation_id = c.id AND m.is_read = 0 AND m.sender = 'visitor') as unread_count,
                (SELECT message FROM {$this->table_messages} m2 WHERE m2.conversation_id = c.id ORDER BY m2.created_at DESC LIMIT 1) as last_message,
                (SELECT sender FROM {$this->table_messages} m3 WHERE m3.conversation_id = c.id ORDER BY m3.created_at DESC LIMIT 1) as last_sender,
                u.display_name as assigned_agent_name
                FROM {$this->table_conversations} c
                LEFT JOIN {$usersTable} u ON c.assigned_agent_id = u.ID
                $noteJoin
                $where
                ORDER BY c.updated_at DESC
                LIMIT $limit";
        
        // phpcs:ignore PluginCheck.Security.DirectDB.UnescapedDBParameter -- $where uses $wpdb->prepare, $limit is intval'd above
        return DB::get_results($sql) ?: [];
    }

    public function getConversation(int $id): ?object
    {
        $usersTable = $this->usersTable();
        return DB::get_row(DB::prepare(
            "SELECT c.*, u.display_name as assigned_agent_name
             FROM {$this->table_conversations} c
             LEFT JOIN {$usersTable} u ON c.assigned_agent_id = u.ID
             WHERE c.id = %d",
            $id
        ));
    }

    /**
     * Pick the right users-table name for the current environment.
     * Standalone keeps user rows in `{prefix}instinctor_users`;
     * WordPress lives in `{prefix}users` (wp_users / etc.). The
     * conversation queries LEFT JOIN this for `assigned_agent_name` —
     * before this helper they hard-coded the WP convention and 1146
     * blew up the whole query on standalone, returning [] to the
     * admin Chat workspace (which then rendered an empty inbox).
     */
    private function usersTable(): string
    {
        if (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone') {
            return DB::prefix() . 'instinctor_users';
        }
        return DB::prefix() . 'users';
    }

    /**
     * Ghost chat visual styles — was a CSS echo, now a no-op.
     *
     * The full widget stylesheet lives in assets/css/chat-widget.css and is
     * linked once per page by renderWidget(). Kept as a no-op so any external
     * caller that used to rely on this method for styles doesn't break; the
     * widget still renders correctly because the stylesheet is loaded upstream.
     *
     * @deprecated Styles moved to assets/css/chat-widget.css (2026-04-22).
     */
    public function ghostStyles(array $settings, string $context = 'frontend', array $layout = []): void
    {
        // Intentionally empty — see docblock.
        return;
    }



    public function getConversationByVisitor(string $visitorId): ?object
    {
        // Check if table exists
        $tableExists = DB::get_var("SHOW TABLES LIKE '{$this->table_conversations}'");
        if (!$tableExists) {
            return null;
        }

        // Find most recent conversation for this visitor regardless of status
        $conv = DB::get_row(DB::prepare(
            "SELECT * FROM {$this->table_conversations} WHERE visitor_id = %s ORDER BY created_at DESC LIMIT 1",
            $visitorId
        ));

        if (!$conv) {
            return null;
        }

        // If closed, reactivate it so the visitor continues in the same thread
        if ($conv->status === 'closed') {
            $this->updateConversation((int) $conv->id, ['status' => 'active']);
            $conv->status = 'active';
        }

        return $conv;
    }

    public function createConversation(array $data): int
    {
        
        // Ensure table exists
        $tableExists = DB::get_var("SHOW TABLES LIKE '{$this->table_conversations}'");
        if (!$tableExists) {
            $this->createTables();
        }
        
        DB::insert($this->table_conversations, [
            'visitor_id' => $data['visitor_id'],
            'visitor_name' => $data['visitor_name'] ?? '',
            'visitor_email' => $data['visitor_email'] ?? '',
            'visitor_phone' => $data['visitor_phone'] ?? '',
            'cart_data' => $data['cart_data'] ?? null,
            'page_url' => $data['page_url'] ?? null,
            'ip_address' => $data['ip_address'] ?? null,
            'status' => 'active'
        ]);
        
        return DB::insert_id();
    }
    
    /**
     * Create chat tables if they don't exist
     */
    public function createTables(): void
    {
        $charset_collate = DB::get_charset_collate();
        
        require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
        
        $sql1 = "CREATE TABLE IF NOT EXISTS {$this->table_conversations} (
            id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            visitor_id VARCHAR(100) NOT NULL,
            visitor_name VARCHAR(255) DEFAULT '',
            visitor_email VARCHAR(255) DEFAULT '',
            visitor_phone VARCHAR(50) DEFAULT '',
            cart_data TEXT,
            page_url TEXT,
            ip_address VARCHAR(45),
            status ENUM('active','closed') DEFAULT 'active',
            assigned_agent_id BIGINT UNSIGNED DEFAULT NULL,
            handoff_at DATETIME DEFAULT NULL,
            handoff_status VARCHAR(20) DEFAULT 'none',
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            INDEX idx_visitor (visitor_id),
            INDEX idx_status (status)
        ) $charset_collate;";
        
        $sql2 = "CREATE TABLE IF NOT EXISTS {$this->table_messages} (
            id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            conversation_id BIGINT UNSIGNED NOT NULL,
            sender ENUM('visitor','admin') NOT NULL,
            admin_user_id BIGINT UNSIGNED DEFAULT NULL,
            message TEXT NOT NULL,
            is_read TINYINT(1) DEFAULT 0,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX idx_conversation (conversation_id),
            INDEX idx_read (is_read)
        ) $charset_collate;";
        
        dbDelta($sql1);
        dbDelta($sql2);
        
        // Upgrade columns. NEVER use ADD COLUMN IF NOT EXISTS here: that is
        // MariaDB-only syntax and a silent parse error on MySQL 8, which left
        // upgraded installs without these columns for years (is_note broke
        // the widget poll + inbox read on all production sites, 2026-06-12).
        $this->ensureColumn($this->table_conversations, 'handoff_at', 'DATETIME DEFAULT NULL');
        $this->ensureColumn($this->table_conversations, 'handoff_status', "VARCHAR(20) DEFAULT 'none'");
        $this->ensureColumn($this->table_conversations, 'assigned_agent_id', 'BIGINT UNSIGNED DEFAULT NULL');
        $this->ensureColumn($this->table_conversations, 'visitor_typing_at', 'DATETIME DEFAULT NULL');
        
        // Track who sent each message + internal notes (never sent to visitor)
        $this->ensureColumn($this->table_messages, 'admin_user_id', 'BIGINT UNSIGNED DEFAULT NULL');
        $this->ensureColumn($this->table_messages, 'is_note', 'TINYINT(1) DEFAULT 0');
        
        // Create agents table
        $sql3 = "CREATE TABLE IF NOT EXISTS {$this->table_agents} (
            id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            user_id BIGINT UNSIGNED DEFAULT 0,
            display_name VARCHAR(100) DEFAULT '',
            avatar VARCHAR(500) DEFAULT '',
            email VARCHAR(200) DEFAULT '',
            sms_number VARCHAR(30) DEFAULT '',
            whatsapp_number VARCHAR(30) DEFAULT '',
            is_active TINYINT(1) DEFAULT 1,
            created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
            INDEX idx_active (is_active)
        ) $charset_collate;";
        
        dbDelta($sql3);

        // Agents upgrade columns (same MySQL-8 rule as above)
        $this->ensureColumn($this->table_agents, 'display_name', "VARCHAR(100) DEFAULT ''");
        $this->ensureColumn($this->table_agents, 'avatar', "VARCHAR(500) DEFAULT ''");
        $this->ensureColumn($this->table_agents, 'email', "VARCHAR(200) DEFAULT ''");

        // Visitor notes — one note per visitor, keyed by email/phone, shared across conversations
        $sql4 = "CREATE TABLE IF NOT EXISTS {$this->table_visitor_notes} (
            id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
            email VARCHAR(255) DEFAULT '',
            phone VARCHAR(50) DEFAULT '',
            note TEXT NOT NULL DEFAULT '',
            updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
            updated_by BIGINT UNSIGNED DEFAULT NULL,
            INDEX idx_email (email),
            INDEX idx_phone (phone)
        ) $charset_collate;";
        dbDelta($sql4);
    }

    public function updateConversation(int $id, array $data): bool
    {
        return DB::update($this->table_conversations, $data, ['id' => $id]) !== false;
    }

    /**
     * Check if a human agent has responded in this conversation
     * Human messages have admin_user_id set, AI messages don't
     */
    private function hasHumanResponded(int $conversationId): bool
    {
        $conversation = $this->getConversation($conversationId);

        // "#" release: owner explicitly handed back to Instagent. The conversation
        // has no assigned agent and status 'none', so AI is in charge regardless of
        // any prior admin messages in the thread.
        if ($conversation
            && ($conversation->assigned_agent_id === null || $conversation->assigned_agent_id === '')
            && ($conversation->handoff_status === 'none' || $conversation->handoff_status === null || $conversation->handoff_status === '')
        ) {
            return false;
        }

        // Stop AI when an agent has claimed this conversation
        $claimedBy = $conversation->assigned_agent_id ?? null;
        if ($claimedBy !== null && $claimedBy !== '') {
            return true;
        }

        // Also check handoff_status for backwards compatibility
        if ($conversation && $conversation->handoff_status === 'resolved') {
            return true;
        }

        // Check if there's any message with admin_user_id set (human stepped in)
        $humanMessageCount = DB::get_var(DB::prepare(
            "SELECT COUNT(*) FROM {$this->table_messages}
             WHERE conversation_id = %d
             AND sender = 'admin'
             AND admin_user_id IS NOT NULL",
            $conversationId
        ));

        return $humanMessageCount > 0;
    }

    public function getMessages(int $conversationId): array
    {
        
        // Check if table exists
        $tableExists = DB::get_var("SHOW TABLES LIKE '{$this->table_messages}'");
        if (!$tableExists) {
            return [];
        }
        
        return DB::get_results(DB::prepare(
            "SELECT id, conversation_id, sender AS sender_type, message, is_read, is_note, created_at FROM {$this->table_messages} WHERE conversation_id = %d ORDER BY created_at ASC",
            $conversationId
        )) ?: [];
    }

    public function addMessage(int $conversationId, string $sender, string $message, ?int $adminUserId = null): int
    {
        
        // Ensure table exists
        $tableExists = DB::get_var("SHOW TABLES LIKE '{$this->table_messages}'");
        if (!$tableExists) {
            $this->createTables();
        }
        
        $data = [
            'conversation_id' => $conversationId,
            'sender' => $sender,
            'message' => $message
        ];
        
        // Track which admin sent the message (null for AI or visitor)
        if ($sender === 'admin' && $adminUserId) {
            $data['admin_user_id'] = $adminUserId;
        }
        
        $result = DB::insert($this->table_messages, $data);
        
        if ($result === false) {
            return 0;
        }
        
        // Update conversation timestamp
        DB::update(
            $this->table_conversations,
            ['updated_at' => current_time('mysql')],
            ['id' => $conversationId]
        );
        
        return DB::insert_id();
    }

    public function markMessagesRead(int $conversationId, string $sender): void
    {
        DB::update(
            $this->table_messages,
            ['is_read' => 1],
            ['conversation_id' => $conversationId, 'sender' => $sender, 'is_read' => 0]
        );
    }

    public function getUnreadCount(): int
    {
        
        $tableExists = DB::get_var("SHOW TABLES LIKE '{$this->table_conversations}'");
        if (!$tableExists) {
            return 0;
        }
        
        return (int) DB::get_var("
            SELECT COUNT(DISTINCT c.id) 
            FROM {$this->table_conversations} c 
            INNER JOIN {$this->table_messages} m ON m.conversation_id = c.id 
            WHERE m.sender = 'visitor' AND m.is_read = 0
        ");
    }

    public function getAgents(): array
    {
        
        $tableExists = DB::get_var("SHOW TABLES LIKE '{$this->table_agents}'");
        if (!$tableExists) {
            return [];
        }
        
        return DB::get_results("SELECT * FROM {$this->table_agents} WHERE is_active = 1") ?: [];
    }

    public function saveAgent(int $userId, array $data): bool
    {
        
        $existing = DB::get_row(DB::prepare(
            "SELECT id FROM {$this->table_agents} WHERE user_id = %d",
            $userId
        ));
        
        if ($existing) {
            return DB::update($this->table_agents, $data, ['user_id' => $userId]) !== false;
        } else {
            $data['user_id'] = $userId;
            return DB::insert($this->table_agents, $data) !== false;
        }
    }

    public function deleteAgent(int $userId): bool
    {
        return DB::delete($this->table_agents, ['user_id' => $userId]) !== false;
    }

    /**
     * Render admin inbox
     */
    public function renderInbox(): void
    {
        $conversations = $this->getConversations();
        $agents = $this->getAgents();
        include INSTINCTOR_PLUGIN_DIR . 'admin/views/chat.php';
    }

    /**
     * Render settings page
     */
    public function renderSettings(): void
    {
        $agents = $this->getAgents();
        $admins = get_users(['role' => 'administrator']);
        include INSTINCTOR_PLUGIN_DIR . 'admin/views/chat-settings.php';
    }

    /**
     * Render chat widget via shortcode
     * Use [instinctor_chat] in Elementor or page builders where wp_footer doesn't work
     */
    public function renderShortcode($atts_or_hidden = false): string
    {
        // When called from renderWidget, receives boolean; from shortcode, receives array/string
        $chat_hidden = is_bool($atts_or_hidden) ? $atts_or_hidden : false;
        static $rendered = false;

        // is_admin() blocks chat in admin routes — but the editor's preview
        // iframe (impulse-preview.php) defines INSTINCTOR_CHAT_PREVIEW so the
        // designer can see and test the chat widget while editing. Without
        // honoring that flag here, the bypass set up upstream is a no-op.
        $_chatAllowAdmin = defined('INSTINCTOR_CHAT_PREVIEW') && INSTINCTOR_CHAT_PREVIEW;
        if ($rendered || (is_admin() && !$_chatAllowAdmin)) {
            return '';
        }

        $settings = $this->getWidgetSettings();

        if ($settings['enabled'] !== '1') {
            return '';
        }

        // Per-page visibility — enforced here so shortcode calls respect Impulse settings.
        // EDITOR PREVIEW EXCEPTION: when rendering inside the admin iframe we
        // ALWAYS emit the widget DOM (with the .chat-hidden class when the
        // page is set to hide chat) so the visibility toggle in the panel can
        // flip the class live instead of requiring a hard refresh to materialise
        // the widget DOM. The live frontend still respects the per-page setting
        // at render time — no extra bytes shipped to actual visitors.
        $post_id = self::resolveCurrentPageId();
        // $chat_hidden is already initialised from the function parameter at
        // line ~2970 — don't re-zero it here, that would clobber the caller's
        // explicit "render this widget collapsed" intent.
        if ($post_id) {
            $page_settings = \Instinctor\Services\PageService::getSettings($post_id);
            if (is_array($page_settings) && !empty($page_settings)) {
                $device     = (function_exists('wp_is_mobile') && wp_is_mobile()) ? 'mobile' : 'desktop';
                $device_key = 'chat_visible_' . $device;
                if (isset($page_settings[$device_key])) {
                    $chat_visible = $page_settings[$device_key];
                } elseif (isset($page_settings['chat_visible'])) {
                    $chat_visible = $page_settings['chat_visible'];
                } else {
                    $chat_visible = 0; // default-off: pages opt in to chat
                }
                if ($chat_visible === 0 || $chat_visible === '0' || $chat_visible === false) {
                    // In editor preview: emit DOM with .chat-hidden so the
                    // panel toggle can show it without reloading the iframe.
                    if (defined('INSTINCTOR_CHAT_PREVIEW') && INSTINCTOR_CHAT_PREVIEW) {
                        $chat_hidden = true;
                    } else {
                        return '';
                    }
                }
            }
        }

        $rendered = true;
        
        $visitorId = $this->getVisitorId();
        $nonce = Session::token('instinctor_chat');
        $ajax_url = admin_url('admin-ajax.php');
        
        ob_start();
        $fieldColor  = '#1d2327'; // always light theme
        $borderWidth = intval($settings['input_border_width']);
        
        $chatRight = '20px'; // 20px air on right edge
        
        $brandHex = ltrim($settings['color_button'] ?: '#000000', '#');
        if (strlen($brandHex) === 3) $brandHex = $brandHex[0].$brandHex[0].$brandHex[1].$brandHex[1].$brandHex[2].$brandHex[2];
        $brandR = hexdec(substr($brandHex, 0, 2));
        $brandG = hexdec(substr($brandHex, 2, 2));
        $brandB = hexdec(substr($brandHex, 4, 2));
        $bubbleOpacity = intval($settings['bubble_opacity'] ?? 20) / 100;
        ?>
        <?php
        // Chat widget CSS lives in a single cacheable stylesheet — loaded once
        // per visitor and reused across every page, cutting ~16 KB of inline
        // CSS from each page's HTML. The widget no longer takes a per-site
        // brand colour — it uses frosted glass + white icons throughout, so
        // there is nothing dynamic to inject.
        \Instinctor\Core\Assets::enqueue('instinctor-chat-widget');
        ?>

        <div id="instinctor-chat"<?php echo $chat_hidden ? ' class="chat-hidden"' : ''; ?> data-position="right">
        <!-- Ghost pill, clip-path notch on right cradles the trigger -->
        <div id="instinctor-ghost-pill-wrap" style="position:fixed!important;bottom:21px!important;right:62px!important;display:flex!important;flex-direction:column!important;align-items:stretch!important;gap:4px!important;z-index:999997!important;pointer-events:none!important">
        <div class="instinctor-ghost-pill<?php echo ($isRtl ?? false) ? ' ghost-rtl' : ''; ?>" id="instinctor-ghost-pill" style="position:static!important">
            <div class="instinctor-ghost-pill-inner">
                <span id="instinctor-text-mirror" style="position:absolute;visibility:hidden;white-space:pre;font-size:14px;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;padding:9px 14px;pointer-events:none;left:-9999px;top:-9999px"></span>
                <textarea class="instinctor-ghost-field" id="instinctor-ghost-input" placeholder="Message..." rows="1" name="instinctor_chat_message" autocomplete="off" autocorrect="off" autocapitalize="none" spellcheck="false" inputmode="text" enterkeyhint="send" data-1p-ignore="true" data-lpignore="true" data-form-type="other"></textarea>
            </div>
        </div>
        </div><!-- end pill-wrap -->
        <div id="instinctor-ghost-preview" style="position:fixed;bottom:76px;right:14px;z-index:999999;display:none;background:rgba(255,255,255,.95);border:1px solid rgba(0,0,0,.12);border-radius:6px;padding:8px;max-width:200px;"></div>
        <div class="instinctor-ghost-bubbles<?php echo ($isRtl ?? false) ? ' ghost-rtl' : ''; ?>" id="instinctor-ghost-bubbles"></div>
        <div id="instinctor-ghost-ring" style="display:none"></div>
        </div>
        </div><!-- end #instinctor-chat -->
        <?php
        // Chat widget JS lives in a single cacheable file (assets/js/instinctor-chat.js).
        // Every value that used to be a PHP interpolation inside the old inline
        // <script> is passed via window.InstinctorChatConfig, injected with
        // wp_add_inline_script(..., 'before'). Satisfies WP.org Guideline 11
        // (no logic in inline <script> tags). InstinctorChat stays a window global
        // from the file, so the widget HTML's onclick="InstinctorChat.x()" still resolves.
        \Instinctor\Core\Assets::enqueue('instinctor-chat');
        $icfg = [
            'visitorId'      => $visitorId,
            'collectName'    => $settings['collect_name']  === '1',
            'collectEmail'   => $settings['collect_email'] === '1',
            'collectPhone'   => $settings['collect_phone'] === '1',
            'ajaxurl'        => $ajax_url,
            'html2canvasUrl' => INSTINCTOR_PLUGIN_URL . 'assets/js/vendor/html2canvas.min.js',
            'brandColor'     => $settings['color_button'] ?: '#1d2327',
            'nonce'          => $nonce,
            'isStandalone'   => (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone'),
        ];
        wp_add_inline_script(
            'instinctor-chat',
            'window.InstinctorChatConfig=' . wp_json_encode($icfg) . ';',
            'before'
        );
        ?>
        <!-- ── Vertical bar (rendered after script so InstinctorChat is defined) ── -->
        <?php if (!$chat_hidden): ?>
        <div id="instinctor-chat-bar" onclick="InstinctorChat.ghostTrigger ? InstinctorChat.ghostTrigger() : null">
            <div class="instinctor-bar-actions-col">
                <?php if ($settings['attachments'] === '1') : ?>
                <button class="instinctor-bar-act" onclick="event.stopPropagation();document.getElementById('instinctor-bar-attach').click()" data-tip="Attach">
                    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.44 11.05l-9.19 9.19a6 6 0 0 1-8.49-8.49l9.19-9.19a4 4 0 0 1 5.66 5.66l-9.2 9.19a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>
                </button>
                <input type="file" id="instinctor-bar-attach" accept="image/*,.pdf,.doc,.docx,.txt" onchange="InstinctorChat.handleFile(this)" style="display:none">
                <?php endif; ?>
                <?php if ($settings['screenshots'] === '1') : ?>
                <button class="instinctor-bar-act" onclick="event.stopPropagation();InstinctorChat.screenshot()" data-tip="Screenshot">
                    <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z"/><circle cx="12" cy="13" r="4"/></svg>
                </button>
                <?php endif; ?>
            </div>
            <div class="instinctor-bar-bottom">
                <!-- Chat trigger - dark frosted disc (CSS on .instinctor-bar-bottom)
                     with the Lucide message-circle in white. The disc carries its
                     own background, so the icon reads as a button on any page;
                     the old bare gradient-filled bubble read as an odd blob. -->
                <svg class="instinctor-bar-icon-chat" viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
                    <path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>
                </svg>
                <svg class="instinctor-bar-icon-close" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="#fff" stroke-width="2.5" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
                <!-- Send icon — appears when the input has text or an attachment.
                     Same trigger handler (ghostTrigger) fires ghostSend() in that
                     state, so morphing the icon is purely a visual cue for the user
                     that the bar IS the send button right now. Visibility driven by
                     .ghost-has-text on the parent #instinctor-chat (see CSS). -->
                <svg class="instinctor-bar-icon-send" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>
            </div>
        </div>
        <?php
        // Bar + textarea wiring (CSP-safe addEventListener) attached to the chat
        // file via wp_add_inline_script(..., 'after') instead of an inline
        // <script>. No PHP interpolation in this body — it's static JS.
        wp_add_inline_script('instinctor-chat', <<<'JS'
        (function(){
            var bar = document.getElementById('instinctor-chat-bar');
            if (bar) {
                InstinctorChat._barExpand   = function() { bar.classList.add('bar-open'); };
                InstinctorChat._barCollapse = function() { bar.classList.remove('bar-open'); };
            }

            // Keyboard-aware lift: when iOS / Android opens the on-screen
            // keyboard for the input pill, position:fixed anchors at
            // bottom:Npx end up covered by the keyboard. window.visualViewport
            // tracks the real visible viewport (shrinks when the keyboard
            // appears), so we set --instinctor-keyboard-h to the keyboard
            // height and the chat-bar / bubbles bottom: calc()s above it.
            if (window.visualViewport) {
                var _kbUpdate = function() {
                    var vv = window.visualViewport;
                    var kbH = Math.max(0, window.innerHeight - vv.height - vv.offsetTop);
                    // Tiny noise -> ignore; only react when keyboard actually
                    // takes meaningful space.
                    if (kbH < 60) kbH = 0;
                    document.documentElement.style.setProperty('--instinctor-keyboard-h', kbH + 'px');
                };
                window.visualViewport.addEventListener('resize', _kbUpdate);
                window.visualViewport.addEventListener('scroll', _kbUpdate);
                _kbUpdate();
            }
            // Textarea input + keydown
            var ta = document.getElementById('instinctor-ghost-input');
            if (ta) {
                ta.addEventListener('input', function() { InstinctorChat.ghostFieldInput(ta); });
                ta.addEventListener('keydown', function(e) {
                    if (e.key === 'Enter' && !e.shiftKey) {
                        e.preventDefault();
                        InstinctorChat.ghostTrigger();
                    }
                });
            }
            // Cursor-following surface glow, update CSS vars on the pill-inner
            var _pill = document.getElementById('instinctor-ghost-pill');
            var _pillInner = _pill ? _pill.querySelector('.instinctor-ghost-pill-inner') : null;
            if (_pillInner) {
                _pill.addEventListener('mousemove', function(e) {
                    var r = _pillInner.getBoundingClientRect();
                    var x = ((e.clientX - r.left) / r.width  * 100).toFixed(1) + '%';
                    var y = ((e.clientY - r.top)  / r.height * 100).toFixed(1) + '%';
                    _pillInner.style.setProperty('--ghost-lx', x);
                    _pillInner.style.setProperty('--ghost-ly', y);
                });
                _pill.addEventListener('mouseleave', function() {
                    _pillInner.style.setProperty('--ghost-lx', '50%');
                    _pillInner.style.setProperty('--ghost-ly', '50%');
                });
            }
        })();
        JS, 'after');
        ?>
        <?php endif; ?>
        <?php
        return ob_get_clean();
    }

    /**
     * AJAX: Poll for new messages
     */
    public function ajaxPollMessages(): void
    {
        check_ajax_referer('instinctor_chat', 'nonce');
        
        $visitorId = isset($_POST['visitor_id']) ? sanitize_text_field(wp_unslash($_POST['visitor_id'])) : '';
        $lastId = isset($_POST['last_id']) ? intval($_POST['last_id']) : 0;
        
        
        $conversation = DB::get_row(DB::prepare(
            "SELECT * FROM {$this->table_conversations} WHERE visitor_id = %s AND status = 'active' ORDER BY id DESC LIMIT 1",
            $visitorId
        ));
        
        if (!$conversation) {
            wp_send_json_success(['messages' => []]);
            return;
        }
        
        $messages = DB::get_results(DB::prepare(
            "SELECT id, conversation_id, sender AS sender_type, message, is_read, is_note, created_at FROM {$this->table_messages} WHERE conversation_id = %d AND id > %d ORDER BY created_at ASC",
            $conversation->id,
            $lastId
        ));
        
        wp_send_json_success(['messages' => $messages]);
    }

    /**
     * AJAX: Send screenshot
     */
    public function ajaxSendScreenshot(): void
    {
        check_ajax_referer('instinctor_chat', 'nonce');
        
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Base64 image data validated below
        $imageData = isset($_POST['image']) ? wp_unslash($_POST['image']) : '';
        $visitorId = isset($_POST['visitor_id']) ? sanitize_text_field(wp_unslash($_POST['visitor_id'])) : '';
        $visitorName = isset($_POST['visitor_name']) ? sanitize_text_field(wp_unslash($_POST['visitor_name'])) : '';
        
        if (empty($imageData) || empty($visitorId)) {
            wp_send_json_error('Invalid data');
        }
        
        if (strpos($imageData, 'data:image/') !== 0) {
            wp_send_json_error('Invalid image format');
        }
        
        $parts = explode(',', $imageData);
        if (count($parts) !== 2) {
            wp_send_json_error('Invalid image data');
        }
        
        $decoded = base64_decode($parts[1]);
        if ($decoded === false || strlen($decoded) > 5 * 1024 * 1024) {
            wp_send_json_error('Image too large or invalid');
        }
        
        $rel      = 'instinctor-chat/screenshot-' . time() . '-' . wp_generate_password(6, false) . '.png';
        Storage::put($rel, $decoded);

        $imageUrl = Storage::url($rel);
        $message  = '[image]' . $imageUrl . '[/image]';
        
        
        $conversation = DB::get_row(DB::prepare(
            "SELECT * FROM {$this->table_conversations} WHERE visitor_id = %s ORDER BY id DESC LIMIT 1",
            $visitorId
        ));
        
        if (!$conversation) {
            DB::insert($this->table_conversations, [
                'visitor_id' => $visitorId,
                'visitor_name' => $visitorName,
                'status' => 'active',
                'created_at' => current_time('mysql'),
                'updated_at' => current_time('mysql')
            ]);
            $convId = DB::insert_id();
        } else {
            $convId = $conversation->id;
            DB::update($this->table_conversations, ['updated_at' => current_time('mysql')], ['id' => $convId]);
        }
        
        DB::insert($this->table_messages, [
            'conversation_id' => $convId,
            'sender' => 'visitor',
            'message' => $message,
            'created_at' => current_time('mysql')
        ]);
        
        $messageId = DB::insert_id();
        
        // Notify agents about screenshot
        $isNewConversation = !$conversation;
        $notifyEvery = Config::get('instinctor_chat_notify_every_message', '0');
        if ($isNewConversation || $notifyEvery === '1') {
            $this->notifyAgents($convId, 'Sent a screenshot: ' . $imageUrl, $visitorName, null);
        }
        
        wp_send_json_success([
            'message_id' => $messageId,
            'conversation_id' => $convId,
            'image_url' => $imageUrl
        ]);
    }

    /**
     * AJAX: Send file
     */
    public function ajaxSendFile(): void
    {
        check_ajax_referer('instinctor_chat', 'nonce');
        
        // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Base64 file data validated below
        $fileData = isset($_POST['file']) ? wp_unslash($_POST['file']) : '';
        $filename = isset($_POST['filename']) ? sanitize_file_name(wp_unslash($_POST['filename'])) : 'file';
        $visitorId = isset($_POST['visitor_id']) ? sanitize_text_field(wp_unslash($_POST['visitor_id'])) : '';
        $visitorName = isset($_POST['visitor_name']) ? sanitize_text_field(wp_unslash($_POST['visitor_name'])) : '';
        
        if (empty($fileData) || empty($visitorId)) {
            wp_send_json_error('Invalid data');
        }
        
        $parts = explode(',', $fileData);
        if (count($parts) !== 2) {
            wp_send_json_error('Invalid file data');
        }
        
        $decoded = base64_decode($parts[1]);
        if ($decoded === false || strlen($decoded) > 5 * 1024 * 1024) {
            wp_send_json_error('File too large or invalid');
        }
        
        // Validate against an explicit allow-list of safe, non-executable types,
        // detected from the decoded bytes (never trust the visitor's filename or the
        // data-URI header). This is a public nopriv endpoint, so an unrestricted
        // extension would let a visitor write .html/.svg into a web-served directory
        // (stored XSS). Reject anything not on the list.
        $allowedMime = [
            'image/jpeg'      => 'jpg',
            'image/png'       => 'png',
            'image/gif'       => 'gif',
            'image/webp'      => 'webp',
            'application/pdf' => 'pdf',
        ];
        $realMime = '';
        if (function_exists('finfo_open')) {
            $finfo    = finfo_open(FILEINFO_MIME_TYPE);
            $realMime = (string) finfo_buffer($finfo, $decoded);
            finfo_close($finfo);
        }
        if (!isset($allowedMime[$realMime])) {
            wp_send_json_error('Unsupported file type. Allowed: JPG, PNG, GIF, WebP, PDF.');
        }
        $ext          = $allowedMime[$realMime];
        $safeFilename = time() . '-' . wp_generate_password(6, false) . '.' . $ext;
        $rel         = 'instinctor-chat/' . $safeFilename;
        Storage::put($rel, $decoded);

        $fileUrl = Storage::url($rel);
        $message = '[file]' . $fileUrl . '|' . $filename . '[/file]';
        
        
        $conversation = DB::get_row(DB::prepare(
            "SELECT * FROM {$this->table_conversations} WHERE visitor_id = %s ORDER BY id DESC LIMIT 1",
            $visitorId
        ));
        
        if (!$conversation) {
            DB::insert($this->table_conversations, [
                'visitor_id' => $visitorId,
                'visitor_name' => $visitorName,
                'status' => 'active',
                'created_at' => current_time('mysql'),
                'updated_at' => current_time('mysql')
            ]);
            $convId = DB::insert_id();
        } else {
            $convId = $conversation->id;
            DB::update($this->table_conversations, ['updated_at' => current_time('mysql')], ['id' => $convId]);
        }
        
        DB::insert($this->table_messages, [
            'conversation_id' => $convId,
            'sender' => 'visitor',
            'message' => $message,
            'created_at' => current_time('mysql')
        ]);
        
        $messageId = DB::insert_id();
        
        // Notify agents about file
        $isNewConversation = !$conversation;
        $notifyEvery = Config::get('instinctor_chat_notify_every_message', '0');
        if ($isNewConversation || $notifyEvery === '1') {
            $this->notifyAgents($convId, 'Sent a file: ' . $filename, $visitorName, null);
        }
        
        wp_send_json_success([
            'message_id' => $messageId,
            'conversation_id' => $convId,
            'file_url' => $fileUrl
        ]);
    }

    /**
     * Get AI reply from relay
     */
    /**
     * Handle AI handoff - notify agents and mark conversation
     */
    private function handleHandoff(int $convId, string $visitorName, string $lastMessage): void
    {
        
        // Mark conversation as needing handoff
        DB::update(
            $this->table_conversations,
            [
                'handoff_at' => current_time('mysql'),
                'handoff_status' => 'pending'
            ],
            ['id' => $convId]
        );
        
        // Send urgent notification to agents (always, regardless of notify settings)
        $this->notifyAgentsUrgent($convId, $visitorName, $lastMessage);
    }
    
    /**
     * Send urgent notification to agents for handoff
     */
    private function notifyAgentsUrgent(int $convId, string $visitorName, string $lastMessage): void
    {
        $storeName = get_bloginfo('name');
        $chatUrl = admin_url('admin.php?page=instinctor-chat&conv=' . $convId);
        
        // Get visitor email from conversation
        $conversation = $this->getConversation($convId);
        $visitorEmail = $conversation->visitor_email ?? '';
        
        // Email notification
        $adminEmail = Config::get('instinctor_chat_email_address', Config::get('admin_email', ''));
        $subject = '[URGENT] ' . $storeName . ' - Customer needs human assistance';
        $urgentMessage = "A customer needs immediate assistance!\n\n" . substr($lastMessage, 0, 200);
        
        $this->sendEmailViaRelay($adminEmail, $subject, $urgentMessage, [
            'visitor_name' => $visitorName ?: 'Visitor',
            'visitor_email' => $visitorEmail,
            'conversation_url' => $chatUrl
        ]);
        
        // SMS notification (if enabled) - PRO feature
        if ($this->canSendSms()) { // number presence checked below
            $smsNumber = Config::get('instinctor_chat_sms_number', '');
            if ($smsNumber) {
                $smsBody = substr($lastMessage, 0, 160);
                $this->callRelay('sms', [
                    'license_key' => $this->getLicenseKey(),
                    'channel' => 'sms',
                    'to' => $smsNumber,
                    'message' => $smsBody,
                    'site_url' => home_url()
                ]);
            }
        }
        
        // WhatsApp notification (if enabled) - PRO feature
        if ($this->canSendWhatsApp()) { // number presence checked below
            $waNumber = Config::get('instinctor_chat_whatsapp_number', '');
            if ($waNumber) {
                $waBody = substr($lastMessage, 0, 300);
                $this->callRelay('sms', [
                    'license_key' => $this->getLicenseKey(),
                    'channel' => 'whatsapp',
                    'to' => $waNumber,
                    'message' => $waBody,
                    'site_url' => home_url()
                ]);
            }
        }
    }
    
    /**
     * AJAX: Check handoff status and send follow-up if needed
     */
    public function ajaxCheckHandoff(): void
    {
        check_ajax_referer('instinctor_chat', 'nonce');
        
        $convId = isset($_POST['conversation_id']) ? intval($_POST['conversation_id']) : 0;
        
        if (!$convId) {
            wp_send_json_error('Invalid conversation');
            return;
        }
        
        
        $conversation = DB::get_row(DB::prepare(
            "SELECT * FROM {$this->table_conversations} WHERE id = %d",
            $convId
        ));
        
        if (!$conversation || empty($conversation->handoff_at) || $conversation->handoff_status !== 'pending') {
            wp_send_json_success(['needs_followup' => false]);
            return;
        }
        
        // Check if human has responded since handoff
        $humanResponse = DB::get_var(DB::prepare(
            "SELECT COUNT(*) FROM {$this->table_messages} 
             WHERE conversation_id = %d 
             AND sender = 'admin' 
             AND created_at > %s
             AND message NOT LIKE '%%[AI]%%'",
            $convId,
            $conversation->handoff_at
        ));
        
        if ($humanResponse > 0) {
            // Human responded, clear handoff
            DB::update(
                $this->table_conversations,
                ['handoff_status' => 'resolved'],
                ['id' => $convId]
            );
            wp_send_json_success(['needs_followup' => false, 'human_responded' => true]);
            return;
        }
        
        // Check timeout — stored in minutes, compare against elapsed seconds
        $timeoutMinutes = intval(Config::get('instinctor_chat_handoff_timeout', '5'));
        $handoffTime = strtotime($conversation->handoff_at);
        $elapsed = time() - $handoffTime;
        
        if ($elapsed >= ($timeoutMinutes * 60) && $conversation->handoff_status === 'pending') {
            // Mark as followup sent
            DB::update(
                $this->table_conversations,
                ['handoff_status' => 'followup_sent'],
                ['id' => $convId]
            );
            
            // Send follow-up message asking for contact info
            $followupMsg = Config::get('instinctor_chat_handoff_message', 
                "Our team members are currently assisting other customers. Please leave your email or phone number and we'll get back to you as soon as possible!");
            
            $this->addMessage($convId, 'admin', $followupMsg);
            
            wp_send_json_success([
                'needs_followup' => true,
                'followup_message' => $followupMsg
            ]);
            return;
        }
        
        // Still waiting
        wp_send_json_success([
            'needs_followup' => false,
            'seconds_remaining' => max(0, $timeout - $elapsed)
        ]);
    }

    private function getAIReply(int $convId, string $message, string $visitorName, $cartData): ?array
    {
        if (Config::get('instinctor_chat_ai_enabled', '0') !== '1') {
            return null;
        }

        // Defense-in-depth: degraded sites have instagent off at platform level,
        // but gate here too so AI never fires even if relay somehow allows it.
        if (\instinctor_locked('chat_pro')) {
            return null;
        }

        $licenseKey = $this->getLicenseKey();
        if (empty($licenseKey)) {
            return null;
        }
        
        $storeName = get_bloginfo('name');
        $storeInfo = Config::get('instinctor_chat_ai_store_info', '');

        // Prepend the store-level description (Settings > Store info >
        // Description) so the chat agent always knows what the business
        // actually is, not just the chat-specific instructions. The
        // Settings field is the one site owners think of as "what we do";
        // chat instructions are the operational rules on top of that
        // ("greet by name", "escalate refunds to human", etc.). Without
        // this merge the chat replied with zero baseline business context
        // unless the owner pasted it into BOTH fields.
        $storeDescription = trim((string) Config::get('instinctor_store_description', ''));
        if ($storeDescription !== '') {
            $storeInfo = $storeDescription . ($storeInfo !== '' ? "\n\n" . $storeInfo : '');
        }
        
        // Enrich with product catalog
        $catalogContext = $this->getProductCatalogContext();
        if ($catalogContext) {
            $storeInfo .= $catalogContext;
        }

        // Enrich with live store data (orders, product stock) from Instinctor's own tables
        $conv = $this->getConversation($convId);
        $visitorEmail = $conv ? trim($conv->visitor_email ?? '') : '';
        $liveContext = $this->getLiveStoreContext($convId, $visitorEmail);
        if ($liveContext) {
            $storeInfo .= $liveContext;
        }

        // Get conversation history (last 10 messages)
        $messages = $this->getMessages($convId);
        $history = array_slice($messages, -10);
        $historyJson = wp_json_encode(array_map(function($msg) {
            return [
                'sender' => $msg->sender ?? 'visitor',
                'message' => $msg->message ?? ''
            ];
        }, $history));
        
        // Format cart info
        $cartInfo = '';
        if (!empty($cartData)) {
            $cart = is_string($cartData) ? json_decode($cartData, true) : $cartData;
            if (is_array($cart) && !empty($cart['items']) && is_array($cart['items'])) {
                $items = [];
                foreach ($cart['items'] as $item) {
                    $name = $item['name'] ?? 'Item';
                    $qty = $item['quantity'] ?? $item['qty'] ?? 1;
                    $price = $item['line_total'] ?? $item['price'] ?? '';
                    $items[] = $name . ' (qty: ' . $qty . ($price ? ', $' . $price : '') . ')';
                }
                $cartInfo = 'Items: ' . implode(', ', $items) . '. Total: $' . ($cart['total'] ?? '');
            }
        }
        
        
        // Two paths through the same transport:
        //   chatReply (ai-reply)  — generic store-support agent. Knows about
        //                           the catalog + this visitor's orders.
        //                           Default for ecommerce installs.
        //   helpAsk   (ai-help)   — KB-augmented Instagent. Uses the same
        //                           knowledge base the editor's Help mode
        //                           pulls from (kb/blocks, kb/concepts,
        //                           kb/recipes). Set via config flag
        //                           `instinctor_chat_mode='help'` on
        //                           install where the chat is meant to
        //                           answer product questions, not store
        //                           orders — instinctor.com itself, the
        //                           designer marketplace flagship.
        $chatMode = (string) Config::get('instinctor_chat_mode', 'store');
        if ($chatMode === 'help') {
            $result = \Instinctor\Services\AIContentService::helpAsk($message, [
                'page_mode'        => 'public',
                // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce verified by the calling AJAX handler before this helper runs.
                'page_slug'        => esc_url_raw(wp_unslash((string) ($_POST['page_url'] ?? ''))),
                'visitor_name'     => $visitorName,
            ]);
        } else {
            // Both Instagents (section generator + chat) share AIContentService::callAIRelay
            // so there's a single transport, single auth shape, single error path.
            $result = \Instinctor\Services\AIContentService::chatReply([
                'license_key'  => $licenseKey,
                'store_url'    => home_url(),
                'rest_url'     => \Instinctor\Core\Config::restUrl('instinctor/v1'),
                'store_name'   => $storeName,
                'store_info'   => $storeInfo,
                'message'      => $message,
                'history'      => $historyJson,
                'cart'         => $cartInfo,
                'visitor_name' => $visitorName,
                'tone'         => Config::get('instinctor_chat_ai_tone', 'friendly'),
            ]);
        }

        if (is_wp_error($result)) {
            return null;
        }

        if (!empty($result['success']) && !empty($result['reply'])) {
            $reply = $result['reply'];
            // Chat bubbles render plain text (textContent, XSS-safe), so
            // model markdown reads as literal asterisks. The platform
            // prompts now ask for plain text; strip the common markers
            // defensively since models still slip them in.
            $reply = preg_replace('/\*{1,2}([^*\n]+)\*{1,2}/', '$1', $reply);
            $reply = preg_replace('/`([^`\n]+)`/', '$1', $reply);
            $reply = preg_replace('/^#{1,4}\s+/m', '', $reply);
            // House typography: no em-dashes in user-facing strings.
            $reply = preg_replace('/\s*\x{2014}\s*/u', ', ', $reply);
            $needsHandoff = false;
            
            // Check for handoff request
            if (strpos($reply, '[HANDOFF]') !== false) {
                $needsHandoff = true;
                $reply = trim(str_replace('[HANDOFF]', '', $reply));
            }

            return [
                'reply' => $reply,
                'needs_handoff' => $needsHandoff
            ];
        }
        
        
        return null;
    }
    
    /**
     * MySQL-8-safe ADD COLUMN. Guards via INFORMATION_SCHEMA because
     * `ADD COLUMN IF NOT EXISTS` only exists on MariaDB; MySQL 8 rejects
     * the statement outright and DB::query swallows it into the log.
     */
    private function ensureColumn(string $table, string $column, string $ddl): void
    {
        $exists = DB::get_var(DB::prepare(
            "SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS
             WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = %s AND COLUMN_NAME = %s",
            $table, $column
        ));
        if (!$exists) {
            DB::query("ALTER TABLE {$table} ADD COLUMN {$column} {$ddl}");
        }
    }

    /**
     * Sanitize cart data from frontend
     */
    private function sanitizeCartData($cart): ?array
    {
        if (!is_array($cart)) {
            return null;
        }
        
        $sanitized = [];
        foreach ($cart as $item) {
            if (!is_array($item)) {
                continue;
            }
            $sanitized[] = [
                'id' => isset($item['id']) ? absint($item['id']) : 0,
                'name' => isset($item['name']) ? sanitize_text_field($item['name']) : '',
                'quantity' => isset($item['quantity']) ? absint($item['quantity']) : 1,
                'price' => isset($item['price']) ? floatval($item['price']) : 0,
            ];
        }
        
        return !empty($sanitized) ? $sanitized : null;
    }
    
    /**
     * AJAX: Toggle chat visibility on a specific page
     */
    public function ajaxTogglePageChat(): void
    {
        check_ajax_referer('instinctor_chat', 'nonce');
        
        if (!Auth::can('manage_options') && !Auth::can('instinctor_chat_settings')) {
            wp_send_json_error(['message' => 'Unauthorized']);
        }
        
        $post_id = isset($_POST['post_id']) ? absint($_POST['post_id']) : 0;
        if (!$post_id) {
            wp_send_json_error(['message' => 'Invalid page']);
        }
        
        // Read current value from PageService settings (works on WP and standalone)
        $page_settings = \Instinctor\Services\PageService::getSettings($post_id);
        $current_hide = $page_settings['hide_chat'] ?? null;
        // WP legacy fallback for old installs
        if ($current_hide === null && (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress')) {
            $current_hide = get_post_meta($post_id, '_instinctor_hide_chat', true);
        }
        $new_value = ($current_hide === '1' || $current_hide === true) ? '0' : '1';
        
        // Write to PageService settings
        $page_settings['hide_chat'] = $new_value;
        \Instinctor\Services\PageService::saveSettings($post_id, $page_settings);
        // WP legacy compat: also update post meta
        if (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress') {
            update_post_meta($post_id, '_instinctor_hide_chat', $new_value);
        }
        
        wp_send_json_success(['hidden' => $new_value === '1', 'post_id' => $post_id]);
    }
    
    /**
     * Render a minimal admin toggle when chat is hidden on a page
     */
    private function renderAdminChatToggle(int $post_id, bool $is_hidden): void
    {
        $settings = $this->getWidgetSettings();
        $position = $settings['position'] ?? 'right';
        $nonce = Session::token('instinctor_chat');
        $ajax_url = admin_url('admin-ajax.php');
        $restore_edge = $position === 'left' ? 'left:14px' : 'right:14px';
        if (function_exists('wp_add_inline_style') && wp_style_is('instinctor-page-inline', 'registered')) {
            wp_add_inline_style('instinctor-page-inline', '.instinctor-chat-restore{position:fixed!important;bottom:24px!important;' . $restore_edge . '!important;z-index:999999!important;width:36px!important;height:36px!important;border-radius:50%!important;background:#1d2327!important;border:1px solid #555!important;cursor:pointer!important;display:flex!important;align-items:center!important;justify-content:center!important;color:#999!important;transition:all .2s!important;box-shadow:0 2px 8px rgba(0,0,0,.15)!important}.instinctor-chat-restore:hover{background:#555!important;border-color:#555!important;color:#fff!important;transform:scale(1.1)!important}');
        } else {
            // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- standalone <style> fallback that cannot be enqueued; the only dynamic value ($restore_edge) is esc_attr'd
            echo '<style>.instinctor-chat-restore{position:fixed!important;bottom:24px!important;' . esc_attr($restore_edge) . '!important;z-index:999999!important;width:36px!important;height:36px!important;border-radius:50%!important;background:#1d2327!important;border:1px solid #555!important;cursor:pointer!important;display:flex!important;align-items:center!important;justify-content:center!important;color:#999!important;transition:all .2s!important;box-shadow:0 2px 8px rgba(0,0,0,.15)!important}.instinctor-chat-restore:hover{background:#555!important;border-color:#555!important;color:#fff!important;transform:scale(1.1)!important}</style>';
        }
        ?>
        <button type="button" class="instinctor-chat-restore" onclick="(function(){var d=new FormData();d.append('action','instinctor_chat_toggle_page');d.append('post_id',<?php echo intval( $post_id ); ?>);d.append('_wpnonce','<?php echo esc_js($nonce); ?>');fetch('<?php echo esc_js($ajax_url); ?>',{method:'POST',body:d}).then(function(r){return r.json()}).then(function(){location.reload()});})()" title="Show chat on this page">
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
        </button>
        <?php
    }

    /**
     * Register an AJAX handler for both WordPress and standalone modes.
     * On WordPress: add_action('wp_ajax_' . $action)  (+ nopriv when $public)
     * On standalone: AjaxDispatcher::register/registerPublic
     *
     * @param bool $public Allow logged-out visitors to call this action.
     *                     Required for chat-send / chat-poll / chat-get
     *                     and any other visitor-facing endpoint. Without
     *                     it, AdminRouter's auth gate returns 403.
     */
    private function registerAjax(string $action, callable $handler, bool $public = false): void
    {
        if (!defined('INSTINCTOR_ENV') || INSTINCTOR_ENV === 'wordpress') {
            add_action('wp_ajax_' . $action, $handler);
            if ($public) {
                add_action('wp_ajax_nopriv_' . $action, $handler);
            }
        } else {
            if ($public) {
                \Instinctor\Core\AjaxDispatcher::registerPublic($action, $handler);
            } else {
                \Instinctor\Core\AjaxDispatcher::register($action, $handler);
            }
        }
    }

}
