<?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';

    /** Per-page persona resolved in ajaxSendMessage (vendor pages only). */
    private string $pagePersona = '';

    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']);
        
        // Mint the visitor cookie while headers are still open. The widget
        // renders on wp_footer, and getVisitorId() sets a cookie — by then
        // WordPress has long since flushed output, so setcookie() only raised
        // "headers already sent" and the cookie was NEVER stored. Every page
        // view therefore minted a fresh visitor id, fragmenting chat threads
        // across page loads on every WP install. Doing it on `init` costs one
        // cookie read on front-end requests and makes the id stable.
        add_action('init', [$this, 'primeVisitorCookie']);

        // 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;
        }
        
        // Tier-driven since the addons layer collapse (2026-07-29): chat ships
        // with the product; free-tier limits apply at the reply gate.
        return true;
    }
    
    /**
     * 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
    {
        // Pro line for the free-publish model: the tenant owner must be Pro (platform
        // flag). Also honour the legacy chat_pro lock for non-tenant installs.
        return \Instinctor\Core\TenantPlan::smsAllowed() && !\instinctor_locked('chat_pro');
    }

    /**
     * The number chat notifications text. A chat-specific number wins; otherwise
     * fall back to the owner's BusinessProfile "Text me when someone books" number,
     * so a tenant sets ONE phone and it covers both bookings and chat. Resolves the
     * current owner (chat handlers enter the owner's Tenant context first).
     */
    private function chatNotifyNumber(): string
    {
        // TENANT FIRST, AND NEVER THE VENDOR'S NUMBER FOR A TENANT. Config::get on a
        // tenant falls through to the GLOBAL value when the tenant set nothing, and
        // the global is the vendor's own number: every chat on a fresh tenant texted
        // Sergei, one SMS per chat round on every recorded take (2026-08-01). For a
        // tenant: their own chat number, else their notify_phone, else silence.
        $oid = \Instinctor\Core\Tenant::currentOwnerId();
        if ($oid) {
            $n = trim((string) Config::ownerOnly($oid, 'instinctor_chat_sms_number', ''));
            if ($n === '') $n = trim((string) \Instinctor\Services\BusinessProfile::get('notify_phone', $oid));
            return $n;
        }
        return trim((string) Config::get('instinctor_chat_sms_number', ''));
    }

    /**
     * The owner's WhatsApp notification number, tenant-scoped.
     *
     * Same rule as chatNotifyNumber above, for the same reason: this is not a
     * whitelisted tenant key, so Config::get falls through to the GLOBAL value
     * when a tenant has set nothing, and the global is the vendor's own number.
     * A tenant who never filled the field would WhatsApp us on every chat. For a
     * tenant: their own number or silence; the global only in vendor scope.
     */
    private function chatNotifyWhatsApp(): string
    {
        $oid = \Instinctor\Core\Tenant::currentOwnerId();
        return $oid !== null
            ? trim((string) Config::ownerOnly($oid, 'instinctor_chat_whatsapp_number', ''))
            : trim((string) Config::get('instinctor_chat_whatsapp_number', ''));
    }

    /**
     * Turn a stored notification number into a relay destination, or null if it
     * cannot be one.
     *
     * Two things every chat notification needs and none of them were doing:
     *
     * 1. E.164. Owners type what they think a phone number looks like, and the
     *    chat paths passed that straight through - the relay does not normalise
     *    either ($toFmt = $to). Telnyx answers 40310 "Invalid 'to' address" and
     *    the owner just never hears about the lead. Core\Sms::normalize is the
     *    one place that already knows the coercion, so reuse it.
     *
     * 2. The channel. The Telnyx long code only reaches +1, so an alert to any
     *    other country dies with a silent 409 - the Klevez (+48) failure fixed
     *    in Core\Sms::dispatch on 2026-08-11. That fix never reached these call
     *    sites, which hardcoded channel:'sms'. Same rule here: +1 by SMS,
     *    everything else via the approved WhatsApp template.
     *
     * $whatsAppOnly is for the WhatsApp field itself, which is a WhatsApp
     * destination whatever its country code - it must never fall back to SMS.
     */
    private static function relayTarget(string $raw, bool $whatsAppOnly = false): ?array
    {
        $to = \Instinctor\Core\Sms::normalize(trim($raw));
        if ($to === '') return null;
        return [
            'to'      => $to,
            'channel' => ($whatsAppOnly || !str_starts_with($to, '+1')) ? 'whatsapp' : 'sms',
        ];
    }

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

    /**
     * Which bucket the relay's monthly AI fence should count this reply against.
     *
     * - A tenant: their owner id. Every instinctor.com tenant relays under the SAME
     *   license, so without this the fence would bucket every free site together and one
     *   ceiling would either break all of them or protect none.
     * - A LICENSED single-tenant install: empty, so the relay buckets per license, which
     *   is what it already did and what a paying customer expects.
     * - A FREE WordPress.org install: it has NO license key, so "bucket per license" means
     *   every free install in the world shares the empty bucket - the exact failure the
     *   tenant case above exists to avoid. Its own URL is the only stable per-install id
     *   we have, and the relay is already sent it as store_url, so this reveals nothing new.
     *
     * Never a tier signal. The relay does not trust it for gating: it only separates
     * buckets. The tier is enforced plugin-side by TenantPlan.
     */
    private function relayOwnerRef(): string
    {
        $oid = \Instinctor\Core\Tenant::currentOwnerId();
        if ($oid) return (string) $oid;
        $key = trim((string) Config::get('instinctor_license_key', ''));
        if ($key !== '') return '';
        return 'site:' . substr(md5((string) home_url()), 0, 16);
    }
    
    /**
     * 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 callback: platform checks live product stock on behalf of AI.
        // Handler existed since the tool shipped but was never registered, so
        // the check_stock tool 404'd on every call.
        Router::restRaw('/relay/stock', [
            'methods'             => 'GET',
            'callback'            => [$this, 'handleRelayStock'],
            'permission_callback' => '__return_true',
        ]);

        // Relay callback: platform places a pending order on behalf of AI.
        // Same story as /relay/stock: implemented, never routed.
        Router::restRaw('/relay/create-order', [
            'methods'             => 'POST',
            'callback'            => [$this, 'handleRelayCreateOrder'],
            '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;
        }

        // Never render the chat bubble on a demo preview: it is unconfigured (no
        // owner KB, cannot actually answer) and competes with the demo control
        // panel + publish CTA in the same corner (the "publish to keep it" banner
        // overlapped it). Smart chat is sold on the product screens instead. 2026-07-22.
        if (!empty($GLOBALS['impulse_is_demo'])) {
            return;
        }

        $settings = $this->getWidgetSettings();

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

        // The WIDGET is free, on every build. It used to be gated on 'chat_pro', so a free
        // WordPress.org install skipped it entirely and the owner never saw smart chat
        // exist: not capped, absent. You cannot want a thing you have never seen work,
        // which is the same reason free tenants get the widget and the same reason a free
        // plumber gets /dashboard/reviews (Sergei, 2026-07-16: treat free WP exactly as
        // free verticals).
        //
        // 'chat_pro' still gates what it should: SMS + WhatsApp notifications
        // (canSendSms/canSendWhatsApp above). TierManager already aliases chat_sms /
        // chat_whatsapp / chat_ai onto it, so the paid extras keep their lock. AI replies
        // are free-with-a-cap, enforced in TenantPlan::aiReplyAllowed().

        // 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);
    }
    /**
     * Create the visitor cookie early in the request, before any output.
     * Hooked on `init` purely so getVisitorId()'s setcookie() lands while
     * headers are still open; the widget itself still renders on wp_footer.
     * Front-end only — admin, AJAX and cron have no chat widget to identify.
     */
    public function primeVisitorCookie(): void
    {
        if (headers_sent()) return;
        if (function_exists('is_admin') && is_admin()) return;
        if (defined('DOING_AJAX') && DOING_AJAX) return;
        if (defined('DOING_CRON') && DOING_CRON) return;
        if (defined('WP_CLI') && WP_CLI) return;
        $this->getVisitorId();
    }

    /**
     * Get visitor ID (from cookie or generate new)
     */
    private function getVisitorId(): string
    {
        // Scope the visitor cookie to the tenant whose page is rendering. instinctor.com
        // serves every tenant from one origin, so a single '/'-scoped cookie made two
        // salons share one visitor id — and thus one merged chat thread. A per-owner key
        // isolates threads with no client change: poll/typing keep working because the
        // widget just echoes this id back on every call. NULL owner = the legacy key.
        $ownerId = \Instinctor\Core\Tenant::currentOwnerId();
        $cookie  = 'instinctor_visitor_id' . ($ownerId ? '_' . $ownerId : '');
        if (isset($_COOKIE[$cookie])) {
            return sanitize_text_field(wp_unslash((string)$_COOKIE[$cookie]));
        }

        $visitorId = 'v_' . bin2hex(random_bytes(16));

        // BOUND THE COOKIE JAR. One cookie per owner, path '/', a year long, on an origin
        // that serves every tenant: a browser that meets many tenants accumulates them
        // without limit and eventually nginx answers '400 Request Header Or Cookie Too
        // Large' and the whole domain is unreachable. A day of demo takes did exactly
        // that (Sergei 2026-08-01). Demos are purged hourly, so their ids are worthless
        // the moment the demo dies: give those a session cookie. Published tenants keep
        // the year, but only the most recent KEEP of them, oldest evicted first.
        $isDemo  = !empty($GLOBALS['impulse_is_demo']);
        $expires = $isDemo ? 0 : time() + (86400 * 365);
        // Guarded: primeVisitorCookie() normally gets here first, while headers
        // are open. If something reaches this later in the response (a
        // shortcode on a page that already flushed), skip the header rather
        // than emit a PHP warning into the page. Seeding $_COOKIE keeps the id
        // stable for the rest of THIS request either way.
        if (!headers_sent()) {
            setcookie($cookie, $visitorId, $expires, '/');
        }
        $_COOKIE[$cookie] = $visitorId;

        // Evict the excess. PHP gives no cookie timestamps, so 'oldest' is approximated
        // by $_COOKIE order, which is the order the browser sends them: insertion order
        // in every engine that matters. Losing an old id only starts a fresh chat thread
        // on a tenant not visited in a long while.
        $KEEP = 8;
        $mine = [];
        foreach (array_keys($_COOKIE) as $k) {
            if (strpos((string) $k, 'instinctor_visitor_id') === 0 && $k !== $cookie) $mine[] = $k;
        }
        if (count($mine) >= $KEEP) {
            // At most a handful per response. Evicting the whole backlog at once means
            // one Set-Cookie header per stale id, and a browser carrying dozens (a day
            // of demo takes) overran nginx's fastcgi buffer: 'upstream sent too big
            // header' -> 502 on every page (Sergei 2026-08-01). A few per request
            // drains the same backlog across ordinary browsing instead.
            $evict = array_slice($mine, 0, min(4, count($mine) - $KEEP + 1));
            foreach ($evict as $stale) {
                if (!headers_sent()) setcookie($stale, '', time() - 86400, '/');
                unset($_COOKIE[$stale]);
            }
        }
        return $visitorId;
    }

    /**
     * The current tenant's business name (from BusinessProfile), or the site name
     * outside a tenant context. Chat notifications and the assistant use this so an
     * owner sees "New chat on Lume Lash", not the vendor site name. Resolves per
     * owner because the chat flow enters the owner's Tenant context first.
     */
    private function tenantStoreName(): string
    {
        $oid = \Instinctor\Core\Tenant::currentOwnerId();
        if ($oid) {
            $name = trim((string) \Instinctor\Services\BusinessProfile::get('business_name', $oid));
            if ($name !== '') return $name;
        }
        return get_bloginfo('name');
    }

    /**
     * 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
    {
        // Which tenant owns the page this chat sits on. The widget injects ownerId
        // from the page's render context and echoes it back here, so every config /
        // AI-instruction / SMS-notify read below resolves THIS owner, not the global
        // vendor scope. NULL = single-tenant / standalone (no-op).
        // phpcs:ignore WordPress.Security.NonceVerification.Missing -- scoping hint only; the nonce is checked immediately below before any state changes.
        $ownerId = isset($_POST['owner_id']) ? (int) $_POST['owner_id'] : 0;
        \Instinctor\Core\Tenant::enter($ownerId > 0 ? $ownerId : null);
        try {
            check_ajax_referer('instinctor_chat', 'nonce');

            $visitorId = isset($_POST['visitor_id']) ? sanitize_text_field(wp_unslash($_POST['visitor_id'])) : '';
            // Client-posted, used ONLY to mute owner notifications: the worst a forger
            // achieves is silencing the alert for their own message. Never trusted for
            // anything that grants or reveals.
            $isDemoChat = !empty($_POST['demo']);
            // Per-page persona, server-trusted: the widget posts only the page id;
            // the persona text lives in that page's settings and only VENDOR pages
            // qualify (a tenant's chat keeps the owner's own brief).
            $this->pagePersona = '';
            $personaPid = isset($_POST['page_id']) ? (int) $_POST['page_id'] : 0;
            if ($personaPid > 0 && $ownerId <= 0) {
                $pp = \Instinctor\Services\PageService::getById($personaPid);
                if ($pp && empty($pp['owner_id'])) {
                    $this->pagePersona = trim((string) ($pp['settings']['chat_persona'] ?? ''));
                }
            }
            $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 {
                // Every alert costs a text, so notification fires at the two moments
                // worth paying for and no others: the conversation opening, and the
                // visitor handing over a way to reach them. The stretch between is
                // Instagent's to handle. Notifying only on the first message lost the
                // lead outright whenever the visitor opened with "hi" and gave their
                // number four messages later, which is the normal shape of a chat
                // (bathdone.com, 2026-08-13); notifying on every message bills a text
                // per "ok" and is never the answer.
                $notifyEvery  = Config::get('instinctor_chat_notify_every_message', '0');
                $isLeadMoment = !$isNewConversation
                    && $this->isFirstContactDetail($convId, $messageId, $message);
                if ($isNewConversation || $isLeadMoment || $notifyEvery === '1') {
                    if (!$isDemoChat) $this->notifyAgents($convId, $message, $name, $cart, (string) ($aiReply ?? ''), $isLeadMoment);
                }
            } 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());
        } finally {
            \Instinctor\Core\Tenant::leave();
        }
    }

    /**
     * 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 widget's own nonce
        // (the widget carries the 'instinctor_chat' nonce, not 'instinctor_frontend').
        // No cap check — typing indicators are anonymous-visitor-scoped.
        if (!check_ajax_referer('instinctor_chat', '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'];
        }

        // Tenant this booking belongs to, from the service being booked (same
        // derivation as BookingService::handleCreateBooking).
        $ownerId = \Instinctor\Services\BookingService::ownerForService($serviceId);

        // Find or create customer, scoped to THIS tenant: email is unique per
        // owner (0058), and an unscoped match could hand this tenant another
        // tenant's client row.
        $customerId = 0;
        if ($email) {
            $customer = DB::get_row(DB::prepare(
                "SELECT id FROM {$p}instinctor_customers WHERE email = %s
                   AND " . \Instinctor\Core\Tenant::scopeSqlFor($ownerId, 'owner_id') . " 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,
                    'owner_id'   => $ownerId,
                    '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,
            // Without this the tenant dashboard's owner-scoped queries never
            // showed chat-made bookings.
            'owner_id'      => $ownerId,
            '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;
                }
                
                // Test through the SAME normalisation and channel choice as a real
                // alert, or the button lies: an owner who typed "754 210 0400" would
                // see the test fail on a number that works, or see it pass on a
                // non-+1 number that production would route differently.
                $testTarget = self::relayTarget($to, $channel === 'whatsapp');
                if (!$testTarget) {
                    wp_send_json_error('That does not look like a phone number. Include the country code, e.g. +1 555 123 4567.');
                    return;
                }

                $requestData = [
                    'license_key' => $licenseKey,
                    'site_url'    => home_url(),
                    'to'          => $testTarget['to'],
                    'message'     => $testMessage,
                    'channel'     => $testTarget['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
     */
    /**
     * True when THIS visitor message is the first in the conversation to carry a way
     * of reaching them back.
     *
     * The moment a visitor types a phone or an email is the moment a chat turns into
     * a lead, and it is almost never their opening line. Requiring it to be the FIRST
     * such message is what holds the cost at one extra text: every later message still
     * quoting the same number notifies nobody.
     */
    private function isFirstContactDetail(int $convId, int $messageId, string $message): bool
    {
        if (!self::hasContactDetail($message)) {
            return false;
        }
        $earlier = DB::get_col(DB::prepare(
            "SELECT message FROM {$this->table_messages} "
            . "WHERE conversation_id = %d AND sender = 'visitor' AND id < %d",
            $convId,
            $messageId
        ));
        foreach ($earlier as $prev) {
            if (self::hasContactDetail((string) $prev)) return false;
        }
        return true;
    }

    /**
     * An email address, or a run of 10+ digits that separators may break up.
     *
     * Ten, not seven: a written date (2026-08-13) is eight digits once the dashes are
     * ignored and would otherwise bill a text every time somebody named a day.
     */
    private static function hasContactDetail(string $text): bool
    {
        if (preg_match('/[\w.+-]+@[\w-]+\.[A-Za-z]{2,}/', $text)) {
            return true;
        }
        return (bool) preg_match('/(?:\+?\d[\s().-]*){10,}/', $text);
    }

    private function notifyAgents(int $convId, string $message, string $visitorName, $cartData, string $aiReply = '', bool $isLeadMoment = false): 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
        // one hint: reply to step in, and the AI goes silent. Doing nothing needs
        // no command - Instagent carries the conversation on its own, so the old
        // "reply # to leave it to Instagent" half asked for an action that has no
        // handler on either side: a "#" would just be relayed to the visitor as
        // the owner's message (Sergei 2026-08-13). GSM-safe separators; 400 cap.
        // Line one names the business. Without it the alert arrives from an unknown
        // number carrying a stranger's words and nothing that ties it to the owner's
        // own website, so it reads as spam and gets ignored: Bath Done's owner
        // reported "no SMS notifications" on 2026-08-14 while Telnyx and T-Mobile
        // both recorded every one of them as delivered to his handset.
        $smsSiteName = trim((string) $this->tenantStoreName());

        $buildSmsBody = function (bool $includeCart) use ($message, $visitorName, $visitorEmail, $visitorPhone, $cartData, $aiReply, $isLeadMoment, $smsSiteName) {
            $lines = [];
            $lines[] = $smsSiteName !== '' ? ($smsSiteName . ': new chat') : 'New chat';
            if ($isLeadMoment) $lines[] = 'Visitor left contact details.';
            $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.';
            $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' => \Instinctor\Core\Tenant::currentOwnerId() !== null
                        ? (function_exists('home_url') ? home_url('/dashboard/chat?c=' . $convId) : '/dashboard/chat?c=' . $convId)
                        : admin_url('admin.php?page=instinctor-chat&conv=' . $convId)
                ]);
                $emailsSent[] = $agent->email;
            }
            
            // SMS. Dedupe on the NORMALISED number so the same phone entered two
            // ways on two agents is alerted once, and billed once.
            $smsTarget = self::relayTarget((string) $agent->sms_number);
            if ($smsTarget && !in_array($smsTarget['to'], $smsSent, true)) {
                $smsBody = $buildSmsBody($smsIncludeCart);
                $this->callRelay('sms', [
                    'license_key'     => $licenseKey,
                    'site_url'        => $siteUrl,
                    'to'              => $smsTarget['to'],
                    'message'         => $smsBody,
                    'channel'         => $smsTarget['channel'],
                    'conversation_id' => $convId,
                ]);
                $smsSent[] = $smsTarget['to'];
            }

            // WhatsApp
            $waTarget = self::relayTarget((string) $agent->whatsapp_number, true);
            if ($waTarget && !in_array($waTarget['to'], $whatsappSent, true)) {
                $waBody = substr($message, 0, 300);
                $this->callRelay('sms', [
                    'license_key'     => $licenseKey,
                    'site_url'        => $siteUrl,
                    'to'              => $waTarget['to'],
                    'message'         => $waBody,
                    'channel'         => $waTarget['channel'],
                    'conversation_id' => $convId,
                ]);
                $whatsappSent[] = $waTarget['to'];
            }
        }
        
        // 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)
            // ownerOnly for a tenant: Config::get falls through to the GLOBAL value
            // when the tenant has set nothing, and the global is the vendor's own chat
            // address, so tenant chats emailed US (2026-08-01). Fall back to the
            // owner's account email instead: a chat is a lead, and a lead the owner
            // never hears about is the product failing at its one job.
            $emailOid = \Instinctor\Core\Tenant::currentOwnerId();
            if ($emailOid !== null) {
                $emailAddress = trim((string) Config::ownerOnly($emailOid, 'instinctor_chat_email_address', ''));
                if ($emailAddress === '') {
                    $emailAddress = trim((string) \Instinctor\Core\DB::get_var(\Instinctor\Core\DB::prepare(
                        "SELECT email FROM " . \Instinctor\Core\DB::prefix() . "instinctor_users WHERE id = %d LIMIT 1",
                        $emailOid
                    )));
                }
            } else {
                $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' => \Instinctor\Core\Tenant::currentOwnerId() !== null
                        ? (function_exists('home_url') ? home_url('/dashboard/chat?c=' . $convId) : '/dashboard/chat?c=' . $convId)
                        : admin_url('admin.php?page=instinctor-chat&conv=' . $convId)
                ]);
            }
            
            // SMS notification (from settings) - PRO feature
            $smsNumber = $this->chatNotifyNumber();
            $notifySms = $smsNumber !== '';
            if ($notifySms && !empty($smsNumber)) {
                $includeCart = Config::get('instinctor_chat_sms_include_cart', '0') === '1';
                // A CHAT IS A LEAD. This is the owner's OWN number, so it goes through
                // Sms::sendLeadAlert exactly as a form, booking or quote does, rather than
                // straight to the relay. That is what makes chat count against the free
                // taste (TenantPlan::FREE_CUSTOMERS) and what stops a free owner losing a
                // customer who asked at 9pm and heard nothing (Sergei 2026-07-26).
                //
                // canSendSms() is deliberately NOT the gate: it requires Pro, which is why
                // chat was the one lead type that never reached a free owner at all.
                // sendLeadAlert gates itself - unlimited for Pro, the first few for free,
                // silent after that.
                if (\Instinctor\Core\TenantPlan::smsAllowed() || \Instinctor\Core\TenantPlan::leadAlertAllowed()) {
                    try {
                        \Instinctor\Core\Sms::sendLeadAlert($smsNumber, $buildSmsBody($includeCart), $convId);
                    } catch (\Throwable $eSms) { /* a text must never break the chat */ }
                }
            }
            
            // WhatsApp notification (from settings) - PRO feature
            $waTarget = self::relayTarget($this->chatNotifyWhatsApp(), true);
            if ($waTarget && $this->canSendWhatsApp()) {
                $this->callRelay('sms', [
                    'license_key'     => $licenseKey,
                    'site_url'        => $siteUrl,
                    'to'              => $waTarget['to'],
                    'message'         => $message,
                    'channel'         => $waTarget['channel'],
                    'conversation_id' => $convId,
                ]);
            }
        }
    }

    /**
     * Call relay service
     */
    private function callRelay(string $action, array $data): array
    {
        // Free-publish gate: SMS and WhatsApp are Pro. A free tenant (owner in context,
        // not Pro) never relays a text, whichever chat path triggered it (agent list,
        // settings number, or test). Non-SMS relay actions (email, balance, checkout)
        // are unaffected, and no-owner contexts fall through as before.
        if (in_array($action, ['sms', 'whatsapp', 'send'], true) && !\Instinctor\Core\TenantPlan::smsAllowed()) {
            return ['success' => false, 'error' => 'sms_locked'];
        }

        // 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 = $this->tenantStoreName();
        $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, ?int $ownerId = null): array
    {
        $p = DB::prefix();
        $usersTable = $this->usersTable();

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

        // Optional tenant scope: the tenant inbox passes its owner_id so an owner
        // sees only their own threads. Vendor admin passes null = all conversations.
        $conds = [];
        if ($status !== 'all') $conds[] = DB::prepare("c.status = %s", $status);
        if ($ownerId !== null && $ownerId > 0) {
            // Tenant dashboard inbox: only that owner's own threads.
            $conds[] = DB::prepare("c.owner_id = %d", $ownerId);
        } else {
            // Vendor (Instinctor) admin workspace: ONLY Instinctor's own,
            // non-tenant conversations (instinctor.com's marketing chat).
            // Tenant-page conversations are owner-scoped and belong solely in
            // that owner's dashboard inbox: tenants cannot reach this workspace
            // and the Instinctor team does not want them here. Was `null = all
            // conversations`, which swept every tenant thread into the vendor
            // inbox. Sergei 2026-07-23.
            $conds[] = "c.owner_id IS NULL";
        }
        $where = $conds ? 'WHERE ' . implode(' AND ', $conds) : '';
        $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();
        }
        
        \Instinctor\Services\AnalyticsService::ensureAttributionColumn($this->table_conversations);
        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,
            'owner_id' => $data['owner_id'] ?? (\Instinctor\Core\Tenant::currentOwnerId() ?: null),
            'attribution' => \Instinctor\Services\AnalyticsService::attributionFromRequest(),
            '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),
            owner_id INT UNSIGNED NULL DEFAULT NULL,
            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),
            INDEX idx_owner (owner_id)
        ) $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','owner') 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 a human stepped in: any 'owner' message (the owner replying
        // from the tenant dashboard is human by definition), or an 'admin'
        // message with admin_user_id set (admin-inbox agent; AI writes 'admin'
        // with NULL admin_user_id).
        $humanMessageCount = DB::get_var(DB::prepare(
            "SELECT COUNT(*) FROM {$this->table_messages}
             WHERE conversation_id = %d
             AND (sender = 'owner' OR (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');
        ?>

        <?php
        // DOM ORDER IS LAYOUT ORDER. #instinctor-chat is a flex column panel
        // now, so header, log and input have to be emitted in reading order.
        // Nothing here carries inline positioning any more: the wrapper used to
        // hardcode position:fixed;bottom:21px;right:62px with !important, which
        // outranks any stylesheet, so the input could not be laid out from CSS
        // at all no matter what chat-widget.css said.
        ?>
        <div id="instinctor-chat"<?php echo $chat_hidden ? ' class="chat-hidden"' : ''; ?> data-position="right">

        <?php
        // No header. There is nothing true to put in one: Instagent answers
        // instantly so any "replies in N minutes" line is noise, and the
        // visitor already knows whose site they are on from the site header
        // above. All that survives is the close, floated over the top-right
        // of the log so it takes no layout height.
        ?>
        <button type="button" class="instinctor-chat-x" onclick="InstinctorChat.close()" aria-label="<?php echo esc_attr__('Close chat', 'instinctor'); ?>">
            <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
        </button>

        <div class="instinctor-ghost-bubbles<?php echo ($isRtl ?? false) ? ' ghost-rtl' : ''; ?>" id="instinctor-ghost-bubbles"></div>

        <div id="instinctor-ghost-pill-wrap">
        <div id="instinctor-ghost-preview" style="display:none"></div>
        <div class="instinctor-ghost-pill<?php echo ($isRtl ?? false) ? ' ghost-rtl' : ''; ?>" id="instinctor-ghost-pill">
            <div class="instinctor-ghost-pill-inner">
                <textarea class="instinctor-ghost-field" id="instinctor-ghost-input" placeholder="<?php echo esc_attr__('Message...', 'instinctor'); ?>" 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>
            <button type="button" class="instinctor-chat-send" onclick="InstinctorChat.ghostSendClick()" aria-label="<?php echo esc_attr__('Send message', 'instinctor'); ?>">
                <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m22 2-7 20-4-9-9-4Z"/><path d="M22 2 11 13"/></svg>
            </button>
        </div>
        </div><!-- end pill-wrap -->

        <div id="instinctor-ghost-ring" style="display:none"></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,
            'ownerId'        => \Instinctor\Core\Tenant::currentOwnerId() ?: 0,
            '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',
            // The greeting has been a setting for a long time and has never
            // rendered: the only markup that referenced it, the element
            // #instinctor-ghost-greeting, was dropped at some point while the
            // JS kept looking it up. It ships as the opening bubble now.
            'greeting'       => (string) ($settings['greeting'] ?? ''),
            'nonce'          => $nonce,
            'isStandalone'   => (defined('INSTINCTOR_ENV') && INSTINCTOR_ENV === 'standalone'),
            // A DEMO CHAT IS A SHOWROOM, NOT A LEAD. Demo pages have no owner, so
            // their chat falls into vendor scope and notifyAgents resolved the
            // VENDOR's channels: Sergei was texted on every demo chat round
            // (2026-08-01). The widget echoes this back and the notify layer goes
            // quiet; the AI reply (the thing being demonstrated) is untouched.
            'isDemo'         => !empty($GLOBALS['impulse_is_demo']),
            // Which page hosts this chat. Server-side it resolves a per-page
            // persona (product pages answer as the vertical's demo business,
            // not corporate Instinctor). Id only: persona text is never
            // client-supplied.
            'pageId'         => (int) ($GLOBALS['impulse_current_page_id'] ?? 0),
        ];
        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): ?>
        <?php
        // With attach + screenshots off the bar has NOTHING to expand for -
        // the .no-actions class pins its height on open, so no chrome can
        // "emerge from the trigger then disappear" (Sergei 2026-07-22). The
        // icon swap (chat -> X) is driven by .bar-open and still happens.
        $_barNoActions = ($settings['attachments'] !== '1' && $settings['screenshots'] !== '1');
        ?>
        <div id="instinctor-chat-bar"<?php echo $_barNoActions ? ' class="no-actions"' : ''; ?> onclick="InstinctorChat.ghostToggle ? InstinctorChat.ghostToggle() : null">
            <?php if (!$_barNoActions) : ?>
            <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>
            <?php endif; // !$_barNoActions ?>
            <div class="instinctor-bar-bottom">
                <!-- Chat trigger - dark frosted disc (CSS on .instinctor-bar-bottom)
                     with the Lucide headset 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="M3 11h3a2 2 0 0 1 2 2v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-5Zm0 0a9 9 0 1 1 18 0m0 0v5a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3Z"/><path d="M21 16v2a4 4 0 0 1-4 4h-5"/>
                </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') {
            if (empty($_POST['demo'])) $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') {
            if (empty($_POST['demo'])) $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 = $this->tenantStoreName();
        $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
            $smsTarget = self::relayTarget($this->chatNotifyNumber());
            if ($smsTarget) {
                $smsBody = substr($lastMessage, 0, 160);
                $this->callRelay('sms', [
                    'license_key'     => $this->getLicenseKey(),
                    'channel'         => $smsTarget['channel'],
                    'to'              => $smsTarget['to'],
                    'message'         => $smsBody,
                    'site_url'        => home_url(),
                    'conversation_id' => $convId,
                ]);
            }
        }

        // WhatsApp notification (if enabled) - PRO feature
        if ($this->canSendWhatsApp()) { // number presence checked below
            $waTarget = self::relayTarget($this->chatNotifyWhatsApp(), true);
            if ($waTarget) {
                $waBody = substr($lastMessage, 0, 300);
                $this->callRelay('sms', [
                    'license_key'     => $this->getLicenseKey(),
                    'channel'         => $waTarget['channel'],
                    'to'              => $waTarget['to'],
                    'message'         => $waBody,
                    'site_url'        => home_url(),
                    'conversation_id' => $convId,
                ]);
            }
        }
    }
    
    /**
     * 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;
        $visitorId = isset($_POST['visitor_id']) ? sanitize_text_field(wp_unslash($_POST['visitor_id'])) : '';

        if (!$convId || $visitorId === '') {
            wp_send_json_error('Invalid conversation');
            return;
        }

        // Ownership check: conversation ids are sequential, so without the
        // visitor_id match any visitor could flip another conversation's
        // handoff state and inject the follow-up message into it.
        $conversation = DB::get_row(DB::prepare(
            "SELECT * FROM {$this->table_conversations} WHERE id = %d AND visitor_id = %s",
            $convId,
            $visitorId
        ));
        
        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 IN ('admin','owner')
             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, ($timeoutMinutes * 60) - $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 the relay somehow allows it.
        //
        // EXCEPT the free WordPress.org build, which is CAPPED rather than locked out:
        // 30 replies a month (TenantPlan::FREE_AI_REPLIES_PER_MONTH, relay AI_CAP_FREE
        // matches), the same allowance a free tenant gets (Sergei 2026-07-16, treat free
        // WP exactly as free verticals). Locking it meant a free install never
        // saw smart chat work and so never wanted it.
        //
        // The !isFreeBuild() half is load-bearing, not tidiness: a LICENSED site whose Pro
        // has lapsed is not a free build, so with a blanket lift it would fall through the
        // owner check below as "no owner, not free" and get UNLIMITED AI on our key. It
        // stays locked. The cap is the free tier; this is still the lapse gate.
        // A FREE-PUBLISH TENANT IS NOT A LAPSED LICENCE, and this lock was treating it as
        // one. chat_pro reads as locked on a free tenant, and a managed tenant is not a
        // free build, so BOTH halves were true and every free-published site returned
        // null here. The allowance immediately below, written specifically to keep smart
        // chat present-but-capped on free tenant sites, was unreachable for exactly the
        // sites it was written for: their Smart chat never answered once.
        //
        // Found 2026-07-27 by publishing a site the way a real visitor does, hours before
        // a video that tells people the Smart chat answers for them.
        //
        // Scoping to a tenant owner keeps the case this check exists to protect: a lapsed
        // LICENSED install has no tenant owner, so it still returns null here rather than
        // falling through to aiReplyAllowed() and getting unlimited replies on our key.
        $tenantOwner = (int) (\Instinctor\Core\Tenant::currentOwnerId() ?: 0);
        if (!\instinctor_is_free_build() && $tenantOwner <= 0 && \instinctor_locked('chat_pro')) {
            return null;
        }

        // Free-publish allowance: smart chat is PRESENT on free tenant sites (an owner
        // will not pay for a feature they never see work), but capped per month so a bot
        // hammering the widget cannot run up unbounded spend on our API key. Past the cap
        // we return null and the widget degrades to plain live chat: the visitor's message
        // still reaches the owner and is still captured as a lead, so it never reads as
        // broken in front of the owner's client. Pro is unlimited. See TenantPlan.
        if (!\Instinctor\Core\TenantPlan::aiReplyAllowed()) {
            return null;
        }

        $licenseKey = $this->getLicenseKey();
        if (empty($licenseKey)) {
            return null;
        }
        
        // Tenant's own business name (from BusinessProfile) so the assistant refers
        // to the salon, not the vendor. Resolves per owner because ajaxSendMessage
        // entered the owner's Tenant context before calling here.
        $storeName = $this->tenantStoreName();
        // OWNER-SCOPED, or a tenant's chat speaks as US. Config::get is the plain global
        // getter: it ignores the Tenant context the comment above relies on, so every
        // tenant's assistant was briefed with the VENDOR's chat instructions. A nail
        // salon's visitor asking "what's your pricing?" was told the platform costs
        // $29.90 a month and $249 a year (Sergei caught it live, 2026-07-26).
        //
        // ownerOnly, never getForOwner: "this tenant wrote no instructions" must mean
        // silence, not our words. Vendor context (owner 0) keeps the global read, so
        // instinctor.com's own assistant is untouched.
        $chatOwner = (int) (\Instinctor\Core\Tenant::currentOwnerId() ?? 0);
        $storeInfo = $chatOwner > 0
            ? (string) Config::ownerOnly($chatOwner, 'instinctor_chat_ai_store_info', '')
            : (string) 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.
        // Same leak, same fix. This is the "what we do" line and ours describes a web
        // production platform. BusinessProfile::get is owner-only now and 'tagline' maps
        // to this exact key, so a tenant reads their own words or nothing.
        $storeDescription = $chatOwner > 0
            ? trim((string) \Instinctor\Services\BusinessProfile::get('tagline', $chatOwner))
            : trim((string) Config::get('instinctor_store_description', ''));
        if ($storeDescription !== '') {
            $storeInfo = $storeDescription . ($storeInfo !== '' ? "\n\n" . $storeInfo : '');
        }
        // Product pages: the chat IS the demo. The persona makes it answer as the
        // vertical's demo business (the same one whose back-office captures sit on
        // the page) instead of as corporate Instinctor. APPENDED LAST, after the
        // corporate brief: prepending lost the fight, the model followed the later
        // corporate block and kept answering as Instinctor (2026-08-03).
        if ($this->pagePersona !== '') {
            $storeInfo .= "\n\n" . "ROLE OVERRIDE FOR THIS PAGE (this outranks everything above, including "
                . "any instruction to answer as Instinctor or to search the product knowledge base):\n"
                . $this->pagePersona;
        }
        
        // 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_type ?? 'visitor',   // getMessages aliases sender AS sender_type
                '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.
        // THE root cause of the wording Sergei saw. 'help' mode routes to
        // AIContentService::helpAsk: our documentation agent, our knowledge base, our
        // prices. instinctor.com is set to 'help', and read globally every tenant site
        // inherited it, so a salon's visitor met "I'm Instagent, Instinctor's built-in
        // assistant. Ask me anything about building pages."
        //
        // A tenant is always 'store'. Help mode exists to answer questions about OUR
        // product on OUR marketing site; no version of it belongs on a customer's page.
        $chatMode = $chatOwner > 0 ? 'store' : (string) Config::get('instinctor_chat_mode', 'store');
        // A page persona flips this page to the STORE path: help mode is the
        // corporate Instagent and ignores store_info entirely, so the product
        // pages' the-page-is-the-demo chat could never answer as the vertical's
        // business through it (2026-08-03). The persona text carries its own
        // hand-back rule for product and pricing questions.
        if ($chatMode === 'help' && $this->pagePersona !== '') {
            $chatMode = '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'         => $chatOwner > 0
                    ? Config::ownerOnly($chatOwner, 'instinctor_chat_ai_tone', 'friendly')
                    : Config::get('instinctor_chat_ai_tone', 'friendly'),
                // Which tenant this reply is for. Every instinctor.com tenant relays under
                // the SAME license, so without this the relay's abuse fence would bucket
                // every free site together: one ceiling would either break all of them or
                // protect none. Empty on single-tenant installs (standalone / DIY WP),
                // which then bucket per license. Not a tier signal: the relay never trusts
                // it for gating, it only separates buckets.
                'owner_ref'    => $this->relayOwnerRef(),
            ]);
        }

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

            // Count against the free monthly allowance only now that the model has
            // actually answered: a failed relay call must never burn a free owner's month.
            \Instinctor\Core\TenantPlan::recordAiReply();

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

}
