# onwebchat/3.9.1/includes/woocommerce-sync.php

AI Chatbot for WooCommerce &amp; Live Chat – onWebChat, version 3.9.1. 1,857 lines.

- Page: https://pluginprobe.com/plugins/onwebchat/3.9.1/code/includes/woocommerce-sync.php
- Raw: https://pluginprobe.com/plugins/onwebchat/3.9.1/raw/includes/woocommerce-sync.php
- Modified: 2026-08-05T13:25:46+00:00

Line numbers below start at 1. Link to a line or a range by appending a fragment to the
page URL, for example `https://pluginprobe.com/plugins/onwebchat/3.9.1/code/includes/woocommerce-sync.php#L10-L20`.

```php
<?php
/**
 * WooCommerce Product Sync Module
 * Syncs WooCommerce products to onWebChat for AI bot training
 */

if (!defined('ABSPATH')) {
    exit; // Exit if accessed directly
}

class OnWebChat_WooCommerce_Sync {
    
    private $api_endpoint_prod = 'https://www.onwebchat.com/api/integrations/woocommerce';
    private $api_endpoint_dev = 'http://127.0.0.1:81/api/integrations/woocommerce';
    // Descriptions are sent WHOLE: the onWebChat server decides what to do with
    // them and rewrites the ones that do not fit its training text with a small
    // model, instead of cutting them off (the tail of a description usually
    // holds the specs and compatibility info). These are only sanity limits
    // against pathological descriptions (page-builder dumps), sized so a
    // 50-product batch stays far below the server's 10MB body limit.
    private $max_description_length = 20000;
    private $max_description_length_combined = 20000;
    private $batch_size = 50;
    private $use_testing_mode;

    // Large-catalogue sync scope.
    // Stores with more than CATEGORY_SELECT_THRESHOLD published products get a
    // category picker so the merchant can choose what to sync. The sync is
    // hard-capped at MAX_SYNC_PRODUCTS so we never try to embed an unbounded
    // catalogue. Above the cap a category selection is required.
    const CATEGORY_SELECT_THRESHOLD = 2000;
    const MAX_SYNC_PRODUCTS = 15000;
    
    /**
     * Get the API endpoint based on testing mode
     * @return string
     */
    private function get_api_endpoint() {
        return $this->use_testing_mode ? $this->api_endpoint_dev : $this->api_endpoint_prod;
    }
    
    public function __construct() {
        // Read testing mode from global constant (defined in onwebchat.php)
        $this->use_testing_mode = defined('ONWEBCHAT_WC_TESTING_MODE') ? ONWEBCHAT_WC_TESTING_MODE : false;

        // One-time migration to the "short + full" default. The Description Mode
        // selector was hidden in the UI before this version, so a stored
        // 'short_fallback_full' was the hidden form field's value, never a real
        // merchant choice. An explicit 'short_only' is left untouched.
        if (!get_option('onwebchat_wc_desc_mode_migrated')) {
            if (get_option('onwebchat_wc_sync_mode', 'short_plus_full') === 'short_fallback_full') {
                update_option('onwebchat_wc_sync_mode', 'short_plus_full');
            }
            update_option('onwebchat_wc_desc_mode_migrated', 1);
        }
        // Initialize settings
        add_action('admin_init', array($this, 'register_settings'));
        
        // Show authentication error notice globally (not just on WooCommerce tab)
        add_action('admin_notices', array($this, 'show_auth_error_notice'));
        
        // Product hooks - use WooCommerce hooks that fire AFTER meta data is saved
        add_action('woocommerce_update_product', array($this, 'on_product_update'), 10, 1);
        add_action('woocommerce_new_product', array($this, 'on_product_update'), 10, 1);

        // Lightweight availability hook: fires whenever a product's stock STATUS flips
        // (including order-driven stock reductions that may not trigger a full product save).
        // It pushes only the in/out-of-stock boolean to onWebChat, which updates it without
        // re-embedding. Variations are intentionally not hooked: WooCommerce recomputes the
        // parent product's stock status from its variations and fires this action for the
        // parent, which is the entity synced to onWebChat.
        add_action('woocommerce_product_set_stock_status', array($this, 'on_stock_status_change'), 10, 3);

        // Scheduled sales: WooCommerce's daily wc_scheduled_sales cron flips sale
        // prices via direct meta updates, NOT through a product save, so
        // woocommerce_update_product never fires and the AI would keep quoting the
        // pre-sale price. These two actions receive the affected product/variation
        // IDs right after the cron applies or removes the sale prices.
        add_action('wc_after_products_starting_sales', array($this, 'on_scheduled_sales'), 10, 1);
        add_action('wc_after_products_ending_sales', array($this, 'on_scheduled_sales'), 10, 1);

        // Variation price edits (the Variations tab saves via AJAX without always
        // re-saving the parent post). Collect the parent IDs and sync each parent
        // once on shutdown, so the synced price range follows variation changes.
        add_action('woocommerce_update_product_variation', array($this, 'on_variation_update'), 10, 1);
        add_action('woocommerce_save_product_variation', array($this, 'on_variation_update'), 10, 1);

        // Handle product deletion (both trash and permanent delete)
        add_action('wp_trash_post', array($this, 'on_product_trash'), 10, 1);
        add_action('before_delete_post', array($this, 'on_product_delete'), 10, 2);
        
        // Bulk sync via WP Cron
        add_action('onwebchat_wc_bulk_sync_batch', array($this, 'process_bulk_sync_batch'));
        
        // Admin AJAX handlers
        add_action('wp_ajax_onwebchat_wc_sync_now', array($this, 'ajax_sync_existing_products'));
        // Client-driven chunked bulk sync: the browser starts a run, then calls
        // the batch action repeatedly (one page per request) until it completes.
        // This replaces the single long request that timed out on large
        // catalogues and reported a false "sync failed" while products kept
        // syncing.
        add_action('wp_ajax_onwebchat_wc_sync_start', array($this, 'ajax_start_bulk_sync'));
        add_action('wp_ajax_onwebchat_wc_sync_batch', array($this, 'ajax_sync_next_batch'));
        add_action('wp_ajax_onwebchat_wc_regenerate_secret', array($this, 'ajax_regenerate_secret'));
        add_action('wp_ajax_onwebchat_wc_reset_sync_status', array($this, 'ajax_reset_sync_status'));
        add_action('wp_ajax_onwebchat_wc_connect', array($this, 'ajax_connect_woocommerce'));
        add_action('wp_ajax_onwebchat_wc_manual_process_batch', array($this, 'ajax_manual_process_batch'));
        add_action('wp_ajax_onwebchat_wc_get_sync_status', array($this, 'ajax_get_sync_status'));
        add_action('wp_ajax_onwebchat_wc_save_sync_enabled', array($this, 'ajax_save_sync_enabled'));
    }
    
    /**
     * AJAX: Connect WooCommerce with authentication
     */
    public function ajax_connect_woocommerce() {
        check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error('Insufficient permissions');
        }
        
        $email = isset($_POST['email']) ? sanitize_email($_POST['email']) : '';
        $password = isset($_POST['password']) ? sanitize_text_field($_POST['password']) : '';
        
        if (empty($email) || empty($password)) {
            wp_send_json_error('Email and password are required');
        }
        
        $result = $this->request_secret_with_auth($email, $password);
        
        if ($result['success']) {
            // Clear any previous authentication errors
            delete_transient('onwebchat_wc_auth_error');
            
            wp_send_json_success(array(
                'message' => 'WooCommerce sync connected successfully!'
            ));
        } else {
            wp_send_json_error($result['error']);
        }
    }
    
    /**
     * Show authentication error notice globally across all admin pages
     * (Hidden when already on WooCommerce tab since it has its own error message)
     */
    public function show_auth_error_notice() {
        $auth_error = get_transient('onwebchat_wc_auth_error');
        
        // Don't show if we're already on the WooCommerce tab (it has its own error message)
        $is_woocommerce_tab = isset($_GET['page']) && $_GET['page'] === 'onwebchat_settings' 
                            && isset($_GET['tab']) && $_GET['tab'] === 'woocommerce';
        
        if ($auth_error && class_exists('WooCommerce') && !$is_woocommerce_tab) {
            ?>
            <div class="notice notice-error">
                <p>
                    <strong>⚠️ onWebChat WooCommerce Sync Error:</strong> 
                    <?php echo esc_html($auth_error); ?>
                    <a href="<?php echo esc_url(admin_url('admin.php?page=onwebchat_settings&tab=woocommerce')); ?>" class="button button-small" style="margin-left: 10px;">
                        Fix Authentication
                    </a>
                </p>
            </div>
            <?php
        }
    }
    
    /**
     * Register WooCommerce sync settings
     */
    public function register_settings() {
        register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_enabled');
        register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_mode');
        register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_secret');
        register_setting('onwebchat_wc_sync', 'onwebchat_wc_last_bulk_sync');
        register_setting('onwebchat_wc_sync', 'onwebchat_wc_excluded_categories');
        // Persisted sync scope: comma-separated product_cat term IDs. Empty means
        // the whole catalogue is in scope. Drives both bulk sync and ongoing
        // per-product auto-sync.
        register_setting('onwebchat_wc_sync', 'onwebchat_wc_sync_categories');
    }
    
    /**
     * Hook: Product update (WooCommerce specific hook - fires AFTER all meta is saved)
     */
    public function on_product_update($product_id) {
        // Check if sync is enabled
        if (!get_option('onwebchat_wc_sync_enabled', false)) {
            return;
        }
        
        // Get product object (at this point all meta data including SKU is already saved)
        $product = wc_get_product($product_id);
        if (!$product) {
            return;
        }
        
        // Only sync published products
        if ($product->get_status() !== 'publish') {
            return;
        }
        
        // Check if product category is excluded
        if ($this->is_product_excluded($product)) {
            return;
        }

        // Respect the merchant's sync scope. When a category selection is active
        // and this product belongs to none of the scoped categories, remove it
        // (it may have been moved out of a scoped category after being synced)
        // and stop. Deletes always remove, regardless of scope.
        if (!$this->product_in_scope($product)) {
            $this->send_product_delete($product_id);
            return;
        }

        // Prepare and send product data
        $product_data = $this->prepare_product_data($product);
        $this->send_product_upsert($product_data, $product_id);
    }

    /**
     * Hook: WooCommerce's scheduled-sales cron started or ended sales on these
     * products. IDs can be simple products OR variations; variations are mapped to
     * their parent (the entity synced to onWebChat) and each product is re-pushed
     * once, so the AI immediately quotes the new (sale or regular) price.
     *
     * @param array $product_ids
     */
    public function on_scheduled_sales($product_ids) {
        if (!get_option('onwebchat_wc_sync_enabled', false)) {
            return;
        }

        $ids = array();
        foreach ((array) $product_ids as $product_id) {
            $product = wc_get_product($product_id);
            if (!$product) {
                continue;
            }

            $id = $product->is_type('variation') ? $product->get_parent_id() : $product->get_id();
            if ($id > 0) {
                $ids[$id] = $id; // de-duplicate
            }
        }

        foreach ($ids as $id) {
            $this->on_product_update($id);
        }
    }

    /**
     * Parent product IDs whose variations changed in this request; flushed once on
     * shutdown so a save touching 30 variations pushes the parent a single time.
     */
    private $pending_variation_parents = array();

    /**
     * Hook: a variation was created/updated (Variations tab saves happen over AJAX
     * and don't always re-save the parent post, so woocommerce_update_product may
     * never fire). Queue the parent for one sync at the end of the request.
     *
     * @param int $variation_id
     */
    public function on_variation_update($variation_id) {
        if (!get_option('onwebchat_wc_sync_enabled', false)) {
            return;
        }

        $variation = wc_get_product($variation_id);
        if (!$variation || !$variation->is_type('variation')) {
            return;
        }

        $parent_id = (int) $variation->get_parent_id();
        if ($parent_id <= 0) {
            return;
        }

        if (empty($this->pending_variation_parents)) {
            add_action('shutdown', array($this, 'flush_variation_parent_syncs'));
        }

        $this->pending_variation_parents[$parent_id] = $parent_id;
    }

    /**
     * Shutdown: sync every parent whose variations changed in this request.
     */
    public function flush_variation_parent_syncs() {
        $parent_ids = $this->pending_variation_parents;
        $this->pending_variation_parents = array();

        foreach ($parent_ids as $parent_id) {
            $this->on_product_update($parent_id);
        }
    }

    /**
     * Hook: product stock STATUS changed (in stock / out of stock / on backorder).
     * Pushes only the availability boolean to onWebChat (no re-embed). "onbackorder"
     * is treated as available since the store still accepts orders.
     *
     * @param int    $product_id
     * @param string $status  'instock' | 'outofstock' | 'onbackorder'
     * @param WC_Product|null $product
     */
    public function on_stock_status_change($product_id, $status, $product = null) {
        if (!get_option('onwebchat_wc_sync_enabled', false)) {
            return;
        }

        if (!$product || !is_object($product)) {
            $product = wc_get_product($product_id);
        }
        if (!$product) {
            return;
        }

        // Only products that would actually be synced: published, not excluded, in scope.
        // Out-of-scope / excluded products are not in onWebChat, so there is nothing to update.
        if ($product->get_status() !== 'publish') {
            return;
        }
        if ($this->is_product_excluded($product) || !$this->product_in_scope($product)) {
            return;
        }

        $in_stock = ($status !== 'outofstock');
        $this->send_product_stock($product_id, $in_stock);
    }

    /**
     * Hook: Product trash (when moved to trash)
     */
    public function on_product_trash($post_id) {
        // Check if it's a product
        if (get_post_type($post_id) !== 'product') {
            return;
        }
        
        if (!get_option('onwebchat_wc_sync_enabled', false)) {
            return;
        }
        
        // Send delete request when product is trashed
        $this->send_product_delete($post_id);
    }
    
    /**
     * Hook: Product permanent delete
     */
    public function on_product_delete($post_id, $post) {
        if ($post->post_type !== 'product') {
            return;
        }
        
        if (!get_option('onwebchat_wc_sync_enabled', false)) {
            return;
        }
        
        // Send delete request when product is permanently deleted
        $this->send_product_delete($post_id);
    }
    
    /**
     * Check if product is in excluded categories
     */
    private function is_product_excluded($product) {
        $excluded_categories = get_option('onwebchat_wc_excluded_categories', array());
        if (empty($excluded_categories)) {
            return false;
        }
        
        $product_categories = $product->get_category_ids();
        foreach ($product_categories as $cat_id) {
            if (in_array($cat_id, $excluded_categories)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Get the saved sync scope as an array of product_cat term IDs.
     * An empty array means the whole catalogue is in scope.
     */
    private function get_sync_scope() {
        $raw = (string) get_option('onwebchat_wc_sync_categories', '');
        if ($raw === '') {
            return array();
        }

        $ids = array();
        foreach (explode(',', $raw) as $id) {
            $id = (int) trim($id);
            if ($id > 0) {
                $ids[$id] = $id; // de-duplicate
            }
        }

        return array_values($ids);
    }

    /**
     * Persist the sync scope. Pass an empty array to clear it (whole catalogue).
     */
    private function save_sync_scope($category_ids) {
        $clean = array();
        foreach ((array) $category_ids as $id) {
            $id = (int) $id;
            if ($id > 0) {
                $clean[$id] = $id;
            }
        }

        update_option('onwebchat_wc_sync_categories', implode(',', array_values($clean)));
    }

    /**
     * Is the product within the current sync scope?
     * No scope set means everything is in scope. The picker only offers
     * top-level categories, and selecting one covers its whole subtree, so a
     * product is in scope when any of its categories is a scoped category OR a
     * descendant of one. This mirrors the bulk sync tax query
     * (include_children = true).
     */
    private function product_in_scope($product) {
        $scope = $this->get_sync_scope();
        if (empty($scope)) {
            return true;
        }

        foreach ($product->get_category_ids() as $cat_id) {
            $cat_id = (int) $cat_id;
            if (in_array($cat_id, $scope, true)) {
                return true;
            }
            // Walk up to the root: a scoped ancestor puts the product in scope.
            foreach (get_ancestors($cat_id, 'product_cat', 'taxonomy') as $ancestor_id) {
                if (in_array((int) $ancestor_id, $scope, true)) {
                    return true;
                }
            }
        }

        return false;
    }

    /**
     * Count published products within the given scope (empty = whole catalogue).
     * Uses found_posts so we do not load every ID into memory.
     */
    private function count_products_in_scope($category_ids) {
        $args = array(
            'post_type'      => 'product',
            'post_status'    => 'publish',
            'posts_per_page' => 1,
            'fields'         => 'ids',
            'no_found_rows'  => false,
        );

        if (!empty($category_ids)) {
            $args['tax_query'] = array(array(
                'taxonomy'         => 'product_cat',
                'field'            => 'term_id',
                'terms'            => array_map('intval', $category_ids),
                'include_children' => true,
            ));
        }

        $query = new WP_Query($args);
        return (int) $query->found_posts;
    }

    /**
     * Turn HTML entities into real characters. Product text is often stored
     * double-encoded ("&amp;quot;" for a quote), where a single pass still
     * leaves "&quot;" in the text the bot is trained on, so decode until the
     * string stops changing (3 passes is far more than any real content needs).
     * Always call this AFTER strip_tags: decoding first could turn text like
     * "price &lt; 100 and &gt; 50" into something strip_tags eats as a tag.
     */
    private function decode_entities($value) {
        $value = (string) $value;

        for ($i = 0; $i < 3; $i++) {
            $decoded = html_entity_decode($value, ENT_QUOTES, 'UTF-8');
            if ($decoded === $value) {
                break;
            }
            $value = $decoded;
        }

        // html_entity_decode turns &nbsp; into a non-breaking space; make it a
        // plain space so the text does not carry invisible oddities.
        return str_replace("\xC2\xA0", ' ', $value);
    }

    /**
     * Prepare product data for sync
     */
    private function prepare_product_data($product) {
        $sync_mode = get_option('onwebchat_wc_sync_mode', 'short_plus_full');

        // Get description based on sync mode
        $description = '';
        $short_description = $this->decode_entities(strip_tags($product->get_short_description()));

        if ($sync_mode === 'short_only') {
            $description = $short_description;
        } else if ($sync_mode === 'short_plus_full') {
            // Send both texts: the short description first, then the full one.
            $full_description = $this->decode_entities(strip_tags($product->get_description()));
            $parts = array_filter(array(trim($short_description), trim($full_description)));
            $description = implode("\n\n", $parts);
        } else if ($sync_mode === 'short_fallback_full') {
            if (!empty($short_description)) {
                $description = $short_description;
            } else {
                // Fallback to the first 200 words of the full description.
                // Split with a Unicode-aware regex: str_word_count() does not
                // recognize non-latin (e.g. Greek) words, so the old word cut
                // was unreliable on multibyte text.
                $full_description = $this->decode_entities(strip_tags($product->get_description()));
                $words = preg_split('/\s+/u', trim($full_description), -1, PREG_SPLIT_NO_EMPTY);

                if (is_array($words) && count($words) > 200) {
                    $description = implode(' ', array_slice($words, 0, 200)) . '...';
                } else {
                    $description = $full_description;
                }
            }
        }

        // Enforce max length by characters, not bytes: a byte-based substr()
        // can cut a multibyte UTF-8 character (e.g. Greek text) in half.
        $max_length = ($sync_mode === 'short_plus_full')
            ? $this->max_description_length_combined
            : $this->max_description_length;
        if (mb_strlen($description, 'UTF-8') > $max_length) {
            $description = mb_substr($description, 0, $max_length, 'UTF-8') . '...';
        }

        $sku = $product->get_sku();
        $categories = $this->get_product_category_names($product);
        $url = get_permalink($product->get_id());

        // Structured fields. The server rebuilds the embedding text from these,
        // so there is no need to send a pre-formatted "text" blob.
        $data = array(
            'product_id'        => $product->get_id(),
            'name'              => $product->get_name(),
            'short_description' => trim($description),
            'url'               => $url,
            'sku'               => $sku,
            'categories'        => $categories,
            'currency'          => get_woocommerce_currency(),
        );

        // Price, as the customer sees it in the shop. get_price() returns the value
        // as entered in admin, which excludes tax on shops that enter net prices but
        // display gross ones, so the AI would quote a price the visitor never sees.
        // wc_get_price_to_display() applies the shop's tax display settings.
        $raw_price = $product->get_price();
        if ($raw_price !== '') {
            $data['price'] = wc_get_price_to_display($product);

            // When the shop displays taxed prices, also send the untaxed price so the
            // AI can quote both.
            if (wc_tax_enabled()) {
                $price_excl_tax = wc_get_price_excluding_tax($product);
                if ((float) $price_excl_tax !== (float) $data['price']) {
                    $data['price_excl_tax'] = $price_excl_tax;
                }
            }
        }

        if ($product->is_type('variable')) {
            // Display min/max prices, consistent with the display price used for
            // simple products.
            $data['price_min'] = $product->get_variation_price('min', true);
            $data['price_max'] = $product->get_variation_price('max', true);
        } else {
            $regular_price = $product->get_regular_price();
            if ($regular_price !== '') {
                $data['regular_price'] = wc_get_price_to_display($product, array('price' => $regular_price));
            }
            // Only advertise a sale price while the sale is actually active.
            if ($product->is_on_sale() && $product->get_sale_price() !== '') {
                $data['sale_price'] = wc_get_price_to_display($product, array('price' => $product->get_sale_price()));
            }
        }

        // Stock availability
        $data['in_stock'] = $product->is_in_stock();
        if ($product->managing_stock()) {
            $stock_qty = $product->get_stock_quantity();
            if ($stock_qty !== null) {
                $data['quantity'] = (int) $stock_qty;
            }
        }

        // Brand (renders as "Brand:" on the server). Detect the common brand taxonomies.
        $brand = $this->get_product_brand($product);
        if (!empty($brand)) {
            $data['manufacturer'] = $brand;
        }

        // Variation attributes / options (Color, Size, ...)
        $attributes = $this->get_product_attributes($product);
        if (!empty($attributes)) {
            $data['attributes'] = $attributes;
        }

        // Tags
        $tags = $this->get_product_tags($product);
        if (!empty($tags)) {
            $data['tags'] = $tags;
        }

        // Average rating and review count
        $rating = (float) $product->get_average_rating();
        if ($rating > 0) {
            $data['rating'] = $rating;
            $data['review_count'] = (int) $product->get_review_count();
        }

        return $data;
    }
    
    /**
     * Get product category names
     */
    private function get_product_category_names($product) {
        $categories = array();
        $category_ids = $product->get_category_ids();
        
        foreach ($category_ids as $cat_id) {
            $term = get_term($cat_id, 'product_cat');
            if ($term && !is_wp_error($term)) {
                $categories[] = $term->name;
            }
        }
        
        return $categories;
    }

    /**
     * Get the product's brand name from whichever brand taxonomy is available.
     * Supports WooCommerce 9.6+ native brands and the common brand plugins.
     */
    private function get_product_brand($product) {
        $taxonomies = array('product_brand', 'pwb-brand', 'yith_product_brand', 'pa_brand');

        foreach ($taxonomies as $taxonomy) {
            if (!taxonomy_exists($taxonomy)) {
                continue;
            }

            $terms = wp_get_post_terms($product->get_id(), $taxonomy, array('fields' => 'names'));
            if (!is_wp_error($terms) && !empty($terms)) {
                return $terms[0];
            }
        }

        return '';
    }

    /**
     * Get visible product attributes as an array of { name, options }.
     * Works for both custom and taxonomy-based (global) attributes.
     */
    private function get_product_attributes($product) {
        $result = array();

        foreach ($product->get_attributes() as $attribute) {
            if (!is_object($attribute) || !$attribute->get_visible()) {
                continue;
            }

            $name = wc_attribute_label($attribute->get_name());

            if ($attribute->is_taxonomy()) {
                $options = wc_get_product_terms($product->get_id(), $attribute->get_name(), array('fields' => 'names'));
            } else {
                $options = $attribute->get_options();
            }

            $options = array_values(array_filter(array_map('trim', (array) $options)));

            if (!empty($name) && !empty($options)) {
                $result[] = array(
                    'name'    => $name,
                    'options' => $options,
                );
            }
        }

        return $result;
    }

    /**
     * Get product tag names.
     */
    private function get_product_tags($product) {
        $tags = wp_get_post_terms($product->get_id(), 'product_tag', array('fields' => 'names'));

        if (is_wp_error($tags) || empty($tags)) {
            return array();
        }

        return $tags;
    }
    
    /**
     * Send batch of products to API (optimized)
     * @param array $products    - Array of product data
     * @param int   $sync_total  - Total products in the current bulk run (0 = not a bulk run)
     * @param int   $sync_done   - Products pushed so far in the run, including this batch
     *
     * When $sync_total is > 0 the batch is tagged with the run total/progress so
     * the server can relay a live progress bar to open dashboards.
     */
    private function send_product_batch($products, $sync_total = 0, $sync_done = 0) {
        $chatId = get_option('onwebchat_plugin_option');
        $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
        
        if (empty($chatId)) {
            error_log('onWebChat WooCommerce Sync - Chat ID not configured');
            return false;
        }
        
        // Ensure we have a secret (must be obtained via authenticated connection in WooCommerce settings)
        $secret = $this->get_secret(false);
        if (empty($secret)) {
            error_log('onWebChat WooCommerce Sync - No secret configured. Please connect WooCommerce in the plugin settings.');
            return array(
                'success' => false,
                'error' => 'No secret configured. Please connect WooCommerce integration.',
                'needs_reconnect' => true
            );
        }
        
        // Extract key part (before first slash if present)
        $chatIdKey = explode('/', $chatId)[0];
        
        $endpoint = $this->get_api_endpoint() . '/product/batch';
        $payload = array(
            'site_id' => $chatIdKey,
            'site_url' => get_site_url(),
            'products' => $products
        );

        // Tag bulk-run batches with the run total + progress so the server can
        // relay a live progress bar to open dashboards. Omitted for incremental
        // single-product syncs (which pass no total).
        if ((int) $sync_total > 0) {
            $payload['sync_total'] = (int) $sync_total;
            $payload['sync_done']  = min((int) $sync_done, (int) $sync_total);
        }
        
        // Generate authentication headers (same as send_authenticated_request)
        $timestamp = time();
        $nonce = base64_encode(random_bytes(16));
        $body_json = wp_json_encode($payload);
        
        // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
        $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
        $signature = hash_hmac('sha256', $message, $secret);
        
        // Send request
        $request_args = array(
            'method' => 'POST',
            // A batch whose descriptions are summarized for the first time costs
            // the server one model call per oversized product, so it needs far
            // more than the 30s that is plenty for every other endpoint.
            'timeout' => 180,
            'headers' => array(
                'Content-Type' => 'application/json',
                'X-OWC-SiteId' => $chatIdKey,
                'X-OWC-Timestamp' => $timestamp,
                'X-OWC-Nonce' => $nonce,
                'X-OWC-Signature' => $signature,
            ),
            'body' => $body_json,
        );
        
        // Disable SSL verification for local dev server
        if ($this->use_testing_mode) {
            $request_args['sslverify'] = false;
        }
        
        $response = wp_remote_post($endpoint, $request_args);
        
        if (is_wp_error($response)) {
            error_log('onWebChat WooCommerce Sync - Batch sync error: ' . $response->get_error_message());
            return array(
                'success' => false,
                'error' => 'Network error: ' . $response->get_error_message()
            );
        }
        
        $response_code = wp_remote_retrieve_response_code($response);
        $body = json_decode(wp_remote_retrieve_body($response), true);
        
        // If authentication failed (401), the secret is invalid or out of sync
        if ($response_code === 401) {
            // Clear the invalid secret
            delete_option('onwebchat_wc_sync_secret');
            
            error_log('onWebChat WooCommerce Sync - Authentication failed (401): Secret is invalid or out of sync. Please reconnect WooCommerce in the plugin settings.');
            
            // Store admin notice about authentication failure
            set_transient('onwebchat_wc_auth_error', 'Authentication failed. Your WooCommerce secret is invalid or out of sync. Please reconnect WooCommerce integration in the plugin settings.', 3600);
            
            return array(
                'success' => false,
                'error' => 'Authentication failed. Secret is invalid. Please reconnect WooCommerce integration.',
                'needs_reconnect' => true
            );
        }
        
        if ($response_code === 200 && isset($body['success']) && $body['success']) {
            // Clear any previous auth errors on success
            delete_transient('onwebchat_wc_auth_error');
            return $body; // Return full response with stats
        }
        
        $error_msg = 'Batch sync failed';
        if (isset($body['error'])) {
            $error_msg .= ': ' . $body['error'];
        }
        error_log('onWebChat WooCommerce Sync - ' . $error_msg . ' - Response: ' . print_r($body, true));
        
        return array(
            'success' => false,
            'error' => $error_msg
        );
    }
    
    /**
     * Send sync completion notification to server (triggers Angular modal)
     */
    private function send_sync_completion_notification($total_stats) {
        $chatId = get_option('onwebchat_plugin_option');
        $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
        
        if (empty($chatId)) {
            error_log('onWebChat WooCommerce Sync - Chat ID not configured');
            return false;
        }
        
        $secret = $this->get_secret(false);
        if (empty($secret)) {
            error_log('onWebChat WooCommerce Sync - No secret configured');
            return false;
        }
        
        $chatIdKey = explode('/', $chatId)[0];
        
        $endpoint = $this->get_api_endpoint() . '/product/sync-complete';
        $payload = array(
            'site_id' => $chatIdKey,
            'stats' => $total_stats
        );
        
        // Generate authentication headers
        $timestamp = time();
        $nonce = base64_encode(random_bytes(16));
        $body_json = wp_json_encode($payload);
        $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
        $signature = hash_hmac('sha256', $message, $secret);
        
        $request_args = array(
            'method' => 'POST',
            'timeout' => 10,
            'headers' => array(
                'Content-Type' => 'application/json',
                'X-OWC-SiteId' => $chatIdKey,
                'X-OWC-Timestamp' => $timestamp,
                'X-OWC-Nonce' => $nonce,
                'X-OWC-Signature' => $signature,
            ),
            'body' => $body_json,
        );
        
        if ($this->use_testing_mode) {
            $request_args['sslverify'] = false;
        }
        
        $response = wp_remote_post($endpoint, $request_args);
        
        if (is_wp_error($response)) {
            error_log('onWebChat WooCommerce Sync - Completion notification failed: ' . $response->get_error_message());
            return false;
        }
        
        $response_code = wp_remote_retrieve_response_code($response);
        
        // If authentication failed (401), the secret is invalid or out of sync
        if ($response_code === 401) {
            delete_option('onwebchat_wc_sync_secret');
            error_log('onWebChat WooCommerce Sync - Completion notification authentication failed (401): Secret is invalid. Please reconnect WooCommerce.');
            set_transient('onwebchat_wc_auth_error', 'Authentication failed. Your WooCommerce secret is invalid or out of sync. Please reconnect WooCommerce integration in the plugin settings.', 3600);
            return false;
        }
        
        if ($response_code >= 200 && $response_code < 300) {
            error_log('onWebChat WooCommerce Sync - Completion notification sent successfully');
            return true;
        }
        
        error_log('onWebChat WooCommerce Sync - Completion notification failed with code: ' . $response_code);
        return false;
    }
    
    /**
     * Send product upsert to server (uses batch endpoint with single product)
     */
    private function send_product_upsert($product_data, $product_id) {
        // Use batch endpoint with single product
        $result = $this->send_product_batch(array($product_data));
        
        if ($result && isset($result['success']) && $result['success']) {
            // Clear any previous errors
            delete_post_meta($product_id, '_onwebchat_sync_error');
            update_post_meta($product_id, '_onwebchat_last_sync', current_time('timestamp'));
            return true;
        } else {
            // Log error if batch failed
            $error_message = 'Failed to sync product';
            if ($result && isset($result['error'])) {
                $error_message = $result['error'];
            } elseif (!$result) {
                $error_message = 'Batch sync request failed';
            }
            $this->log_error($product_id, $error_message);
            return false;
        }
    }
    
    /**
     * Send product delete to server
     */
    private function send_product_delete($product_id) {
        $chatId = get_option('onwebchat_plugin_option');
        $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
        
        if (empty($chatId)) {
            return false;
        }
        
        // Extract key part (before first slash if present)
        $chatIdKey = explode('/', $chatId)[0];
        
        $endpoint = $this->get_api_endpoint() . '/product/delete';
        $payload = array(
            'site_id' => $chatIdKey,  // Use key part only
            'site_url' => get_site_url(),
            'product_id' => $product_id
        );
        
        $this->send_authenticated_request($endpoint, $payload, $product_id);
    }

    /**
     * Send a lightweight availability update to onWebChat (no re-embed on the server).
     */
    private function send_product_stock($product_id, $in_stock) {
        $chatId = get_option('onwebchat_plugin_option');
        $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';

        if (empty($chatId)) {
            return false;
        }

        // Extract key part (before first slash if present)
        $chatIdKey = explode('/', $chatId)[0];

        $endpoint = $this->get_api_endpoint() . '/product/stock';
        $payload = array(
            'site_id' => $chatIdKey,  // Use key part only
            'site_url' => get_site_url(),
            'product_id' => $product_id,
            'in_stock' => (bool) $in_stock,
        );

        $this->send_authenticated_request($endpoint, $payload, $product_id);
    }

    /**
     * Get cached secret from local options
     * @param {bool} force_refresh - Not used (kept for compatibility), secret must be obtained via authenticated request
     */
    private function get_secret($force_refresh = false) {
        // Always return cached secret - never fetch automatically
        // Secret must be obtained via authenticated request in WooCommerce settings
        $secret = get_option('onwebchat_wc_sync_secret');
        
        if (!empty($secret)) {
            return $secret;
        }
        
        // No secret available - user must authenticate in WooCommerce settings
        return null;
    }
    
    /**
     * Request secret from server with authentication
     * This is called when user clicks "Connect WooCommerce" with their password
     * 
     * @param {string} email - User's onWebChat email
     * @param {string} password - User's onWebChat password
     * @return {array} - ['success' => bool, 'secret' => string, 'error' => string]
     */
    public function request_secret_with_auth($email, $password) {
        $chatId = get_option('onwebchat_plugin_option');
        $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
        
        if (empty($chatId)) {
            return array('success' => false, 'error' => 'No Chat ID configured');
        }
        
        // Extract key part (before first slash if present)
        $key = explode('/', $chatId)[0];
        
        // Request secret from server with authentication
        $secret_endpoint = $this->get_api_endpoint() . '/secret';
        
        $response = wp_remote_post($secret_endpoint, array(
            'timeout' => 15,
            'sslverify' => !$this->use_testing_mode,
            'headers' => array(
                'Content-Type' => 'application/json',
            ),
            'body' => wp_json_encode(array(
                'email' => $email,
                'password' => $password,
                'site_key' => $key,
                'version' => defined('ONWEBCHAT_PLUGIN_VERSION') ? ONWEBCHAT_PLUGIN_VERSION : '',
            )),
        ));
        
        if (is_wp_error($response)) {
            $error_message = $response->get_error_message();
            error_log('onWebChat WooCommerce Sync - Connection error: ' . $error_message);
            return array('success' => false, 'error' => 'Connection failed: ' . $error_message);
        }
        
        $status_code = wp_remote_retrieve_response_code($response);
        $response_body_raw = wp_remote_retrieve_body($response);
        $body = json_decode($response_body_raw, true);
        
        // Log response for debugging. Redact the secret so it never lands in server/debug logs
        // (a successful response body contains the HMAC secret).
        $log_body = preg_replace('/("secret"\s*:\s*")[^"]*(")/i', '$1[REDACTED]$2', (string) $response_body_raw);
        error_log('onWebChat WooCommerce Sync - API response: Status=' . $status_code . ', Body=' . substr($log_body, 0, 500));
        
        // Handle specific HTTP status codes
        if ($status_code === 401) {
            return array('success' => false, 'error' => 'Invalid email or password');
        }
        
        if ($status_code === 403) {
            return array('success' => false, 'error' => 'You do not have access to this site');
        }
        
        // Success case
        if ($status_code >= 200 && $status_code < 300 && isset($body['success']) && $body['success']) {
            $secret = isset($body['secret']) ? $body['secret'] : null;
            if (empty($secret)) {
                error_log('onWebChat WooCommerce Sync - Success response but no secret provided');
                return array('success' => false, 'error' => 'Server response missing secret');
            }
            update_option('onwebchat_wc_sync_secret', $secret);

            // Enable AI order-status lookup by default on connect and register our
            // callback URL with onWebChat (best effort; the merchant can toggle it off).
            update_option('onwebchat_wc_order_lookup_enabled', true);
            global $onwebchat_wc_orders;
            if (isset($onwebchat_wc_orders) && is_object($onwebchat_wc_orders)) {
                $onwebchat_wc_orders->push_order_lookup_config(true);
            }

            return array('success' => true, 'secret' => $secret);
        }
        
        // Extract error message from various possible response formats
        $error_message = 'Unknown error';
        
        if (is_array($body)) {
            // Try different possible error fields
            if (isset($body['error'])) {
                $error_message = is_string($body['error']) ? $body['error'] : json_encode($body['error']);
            } elseif (isset($body['message'])) {
                $error_message = is_string($body['message']) ? $body['message'] : json_encode($body['message']);
            } elseif (isset($body['errors']) && is_array($body['errors'])) {
                $error_message = implode(', ', $body['errors']);
            }
        } elseif (!empty($response_body_raw)) {
            // If body is not JSON or empty, use raw response (truncated)
            $error_message = 'Server returned: ' . substr(strip_tags($response_body_raw), 0, 200);
        }

        // Defense in depth: the remote error is shown in the admin UI, so strip any markup here too
        // (the client also renders it as text). Prevents a malicious/MITM'd API response carrying HTML.
        $error_message = sanitize_text_field($error_message);
        
        // Include status code in error message if not already included
        if ($status_code && strpos($error_message, 'HTTP') === false) {
            $error_message = 'HTTP ' . $status_code . ': ' . $error_message;
        }
        
        error_log('onWebChat WooCommerce Sync - Connection failed: ' . $error_message);
        return array('success' => false, 'error' => $error_message);
    }
    
    /**
     * Send authenticated request with HMAC signature
     */
    private function send_authenticated_request($endpoint, $payload, $product_id = null) {
        // Get cached secret (must be obtained via authenticated connection in WooCommerce settings)
        $secret = $this->get_secret(false);
        
        if (empty($secret)) {
            if ($product_id) {
                $this->log_error($product_id, 'No secret configured. Please connect WooCommerce in the plugin settings.');
            }
            return array('success' => false, 'error' => 'Secret not available. Please connect WooCommerce in plugin settings.');
        }
        
        $chatId = get_option('onwebchat_plugin_option');
        $chatId = (is_array($chatId) && isset($chatId['text_string'])) ? $chatId['text_string'] : '';
        
        // Extract key part (before first slash if present) for consistency with server
        // e.g., "5f02c87b60726a4663b25463a424a034/1/1" -> "5f02c87b60726a4663b25463a424a034"
        $chatIdKey = explode('/', $chatId)[0];
        
        // Generate authentication headers
        $timestamp = time();
        $nonce = base64_encode(random_bytes(16));
        $body_json = wp_json_encode($payload);
        
        // Create signature: HMAC_SHA256(secret, site_id.timestamp.nonce.body)
        // IMPORTANT: Use the key part (not full chat_id) to match server-side verification
        $message = $chatIdKey . '.' . $timestamp . '.' . $nonce . '.' . $body_json;
        $signature = hash_hmac('sha256', $message, $secret);
        
        // Send request
        $request_args = array(
            'method' => 'POST',
            'timeout' => 10,
            'headers' => array(
                'Content-Type' => 'application/json',
                'X-OWC-SiteId' => $chatIdKey,  // Use key part only
                'X-OWC-Timestamp' => $timestamp,
                'X-OWC-Nonce' => $nonce,
                'X-OWC-Signature' => $signature,
            ),
            'body' => $body_json,
        );
        
        // Disable SSL verification for local dev server
        if ($this->use_testing_mode) {
            $request_args['sslverify'] = false;
        }
        
        $response = wp_remote_post($endpoint, $request_args);
        
        // Handle response
        if (is_wp_error($response)) {
            $error_message = $response->get_error_message();
            if ($product_id) {
                $this->log_error($product_id, $error_message);
            }
            return array('success' => false, 'error' => $error_message);
        }
        
        $status_code = wp_remote_retrieve_response_code($response);
        
        // Success
        if ($status_code >= 200 && $status_code < 300) {
            return array('success' => true);
        }
        
        // If authentication failed (401), the secret may be invalid
        if ($status_code === 401) {
            // Clear the invalid secret
            delete_option('onwebchat_wc_sync_secret');
            
            if ($product_id) {
                $this->log_error($product_id, 'Authentication failed. Please reconnect WooCommerce in the plugin settings.');
            }
            return array('success' => false, 'error' => 'Authentication failed. Please reconnect WooCommerce in plugin settings.');
        }
        
        // Error
        $error_body = wp_remote_retrieve_body($response);
        if ($product_id) {
            $this->log_error($product_id, "HTTP $status_code: $error_body");
        }
        
        return array('success' => false, 'error' => "HTTP $status_code", 'status_code' => $status_code);
    }
    
    /**
     * Log sync error to product meta
     */
    private function log_error($product_id, $error_message) {
        update_post_meta($product_id, '_onwebchat_sync_error', array(
            'message' => $error_message,
            'timestamp' => current_time('timestamp')
        ));
    }
    
    /**
     * AJAX: Start bulk sync
     */
    public function ajax_sync_existing_products() {
        check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');

        // This path can walk the whole catalogue in one request, and each batch
        // waits for the server (which may summarize descriptions with a model).
        @set_time_limit(0);

        if (!current_user_can('manage_options')) {
            wp_send_json_error('Insufficient permissions');
        }
        
        // Check if sync is already in progress
        if (get_option('onwebchat_wc_bulk_in_progress', false)) {
            wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
        }
        
        // Rate limiting: prevent syncing more than once every 5 minutes
        $last_sync_time = get_option('onwebchat_wc_last_sync_start', 0);
        $cooldown_period = 5 * 60; // 5 minutes in seconds //also in the file woocommerce.php // 5 * 60
        $time_since_last_sync = time() - $last_sync_time;
        
        if ($time_since_last_sync < $cooldown_period) {
            $wait_time = $cooldown_period - $time_since_last_sync;
            $minutes = ceil($wait_time / 60);
            wp_send_json_error('Please wait ' . $minutes . ' minute(s) before syncing again.');
        }
        
        // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue.
        $category_ids = array();
        if (isset($_POST['categories']) && $_POST['categories'] !== '') {
            foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) {
                $id = (int) trim($id);
                if ($id > 0) {
                    $category_ids[] = $id;
                }
            }
        }

        // Above the hard cap a category selection is required: refuse an
        // unrestricted "sync all" when the catalogue is larger than the cap.
        $published_total = $this->count_products_in_scope(array());
        if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) {
            wp_send_json_error(sprintf(
                'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.',
                number_format_i18n($published_total),
                number_format_i18n(self::MAX_SYNC_PRODUCTS)
            ));
        }

        // Remember the merchant's choice so ongoing auto-sync stays within it:
        // selected categories become the sync scope; an unrestricted "sync all"
        // clears the scope (the whole catalogue is in scope again).
        $this->save_sync_scope($category_ids);

        // Store the current sync start time
        update_option('onwebchat_wc_last_sync_start', time());

        // Reset bulk sync progress
        update_option('onwebchat_wc_bulk_page', 0);
        update_option('onwebchat_wc_bulk_done', 0);

        // Count total products within scope, capped at the hard limit.
        $total = $this->count_products_in_scope($category_ids);
        if ($total > self::MAX_SYNC_PRODUCTS) {
            $total = self::MAX_SYNC_PRODUCTS;
        }

        update_option('onwebchat_wc_bulk_total', $total);
        update_option('onwebchat_wc_bulk_done', 0); // Initialize progress counter
        update_option('onwebchat_wc_bulk_in_progress', true);

        // Process sync directly instead of using unreliable WP Cron
        $sync_result = $this->do_bulk_sync_all($category_ids);

        wp_send_json_success(array(
            'message' => 'Bulk sync completed',
            'total' => $total,
            'result' => $sync_result
        ));
    }
    
    /**
     * AJAX: begin a client-driven bulk sync.
     *
     * Sets up the progress state and returns the total number of products to
     * sync. The browser then calls ajax_sync_next_batch() repeatedly (one page
     * per request) until the run reports it is complete. Because each request is
     * short, the whole sync no longer rides on a single request that outran the
     * web server timeout and reported a false failure while products kept
     * syncing.
     */
    public function ajax_start_bulk_sync() {
        check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');

        if (!current_user_can('manage_options')) {
            wp_send_json_error('Insufficient permissions');
        }

        if (get_option('onwebchat_wc_bulk_in_progress', false)) {
            wp_send_json_error('A sync is already in progress. Please wait for it to complete.');
        }

        // Read the chosen sync scope (product_cat term IDs). Empty = whole catalogue.
        $category_ids = array();
        if (isset($_POST['categories']) && $_POST['categories'] !== '') {
            foreach (explode(',', sanitize_text_field(wp_unslash($_POST['categories']))) as $id) {
                $id = (int) trim($id);
                if ($id > 0) {
                    $category_ids[] = $id;
                }
            }
        }

        // Above the hard cap a category selection is required: refuse an
        // unrestricted "sync all" when the catalogue is larger than the cap.
        $published_total = $this->count_products_in_scope(array());
        if (empty($category_ids) && $published_total > self::MAX_SYNC_PRODUCTS) {
            wp_send_json_error(sprintf(
                'Your store has %s products, which is more than can be synced at once (%s). Please select specific categories to sync.',
                number_format_i18n($published_total),
                number_format_i18n(self::MAX_SYNC_PRODUCTS)
            ));
        }

        // Remember the merchant's choice so ongoing auto-sync stays within it:
        // selected categories become the sync scope; an unrestricted "sync all"
        // clears the scope (the whole catalogue is in scope again).
        $this->save_sync_scope($category_ids);

        // Count total products within scope, capped at the hard limit.
        $total = $this->count_products_in_scope($category_ids);
        if ($total > self::MAX_SYNC_PRODUCTS) {
            $total = self::MAX_SYNC_PRODUCTS;
        }

        // Reset progress state for a fresh run.
        update_option('onwebchat_wc_last_sync_start', time());
        update_option('onwebchat_wc_bulk_page', 0);
        update_option('onwebchat_wc_bulk_done', 0);
        update_option('onwebchat_wc_bulk_total', $total);
        update_option('onwebchat_wc_bulk_stats', array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0));
        // Only enter the "in progress" state when there is actually something to
        // sync, so a 0-product start (empty scope) can't leave the store stuck at
        // "a sync is already in progress".
        update_option('onwebchat_wc_bulk_in_progress', $total > 0);

        wp_send_json_success(array('total' => $total));
    }

    /**
     * AJAX: process the next page of the in-progress bulk sync and report progress.
     */
    public function ajax_sync_next_batch() {
        check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');

        // One page of products waits for the server, which may summarize long
        // descriptions with a model: that outlives a default max_execution_time.
        @set_time_limit(0);

        if (!current_user_can('manage_options')) {
            wp_send_json_error('Insufficient permissions');
        }

        wp_send_json_success($this->sync_next_page());
    }

    /**
     * Process exactly one page (batch_size products) of the in-progress bulk
     * sync, advancing the persisted progress. The browser calls this once per
     * request (via ajax_sync_next_batch) until it reports the run is complete,
     * so no single request has to stay open for the whole catalogue.
     *
     * Unlike do_bulk_sync_all()/the WP-Cron path this does NOT sleep and does
     * NOT schedule a follow-up cron event: the browser drives the loop. Stats
     * are accumulated in an option across pages so the completion notification
     * (which drives the dashboard notice) carries the full run totals.
     *
     * @return array Progress snapshot: in_progress, complete, done, total, stats.
     */
    private function sync_next_page() {
        $total = (int) get_option('onwebchat_wc_bulk_total', 0);

        if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
            return array(
                'in_progress' => false,
                'complete'    => true,
                'done'        => (int) get_option('onwebchat_wc_bulk_done', 0),
                'total'       => $total,
                'stats'       => $this->get_bulk_stats(),
            );
        }

        $page = (int) get_option('onwebchat_wc_bulk_page', 0);
        $done = (int) get_option('onwebchat_wc_bulk_done', 0);
        $stats = $this->get_bulk_stats();

        $args = array(
            'post_type'      => 'product',
            'post_status'    => 'publish',
            'posts_per_page' => $this->batch_size,
            'paged'          => $page + 1,
            'orderby'        => 'ID',
            'order'          => 'ASC',
        );

        // Restrict to the saved sync scope and its subcategories, consistent
        // with the per-product scope check and the counts shown in the picker.
        $scope = $this->get_sync_scope();
        if (!empty($scope)) {
            $args['tax_query'] = array(array(
                'taxonomy'         => 'product_cat',
                'field'            => 'term_id',
                'terms'            => array_map('intval', $scope),
                'include_children' => true,
            ));
        }

        $query = new WP_Query($args);
        $complete = false;

        if ($query->have_posts()) {
            $products_batch = array();
            foreach ($query->posts as $post) {
                $product = wc_get_product($post->ID);
                if ($product && !$this->is_product_excluded($product)) {
                    $products_batch[] = $this->prepare_product_data($product);
                }
            }

            $batch_done = 0;
            if (!empty($products_batch)) {
                // Tag with run total + running progress so the dashboard bar advances.
                $result = $this->send_product_batch($products_batch, $total, $done + count($products_batch));
                if ($result && isset($result['stats'])) {
                    $batch_done = (int) $result['stats']['created'] + (int) $result['stats']['updated'] + (int) $result['stats']['skipped'];
                    $stats['created'] += (int) $result['stats']['created'];
                    $stats['updated'] += (int) $result['stats']['updated'];
                    $stats['skipped'] += (int) $result['stats']['skipped'];
                    $stats['errors']  += (int) $result['stats']['errors'];
                } else {
                    // Batch failed outright: count the products as errors so the
                    // summary reflects reality rather than silently skipping them.
                    $batch_done = count($products_batch);
                    $stats['errors'] += count($products_batch);
                }
            }

            $done += $batch_done;
            $page += 1;

            update_option('onwebchat_wc_bulk_page', $page);
            update_option('onwebchat_wc_bulk_done', $done);
            update_option('onwebchat_wc_bulk_stats', $stats);

            // Stop once we have covered the counted total or reached the hard cap.
            if ($done >= $total || ($page * $this->batch_size) >= self::MAX_SYNC_PRODUCTS) {
                $complete = true;
            }
        } else {
            // No more products in scope.
            $complete = true;
        }

        wp_reset_postdata();

        if ($complete) {
            $this->send_sync_completion_notification($stats);
            update_option('onwebchat_wc_bulk_in_progress', false);
            update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
            // Show 100% when the counted total was reached; otherwise leave the
            // real processed figure (e.g. an early empty page or the hard cap).
            if ($total > 0 && $done >= $total) {
                $done = $total;
            }
            update_option('onwebchat_wc_bulk_done', $done);
        }

        return array(
            'in_progress' => !$complete,
            'complete'    => $complete,
            'done'        => $done,
            'total'       => $total,
            'stats'       => $stats,
        );
    }

    /**
     * Read the accumulated bulk-sync stats option, normalised to the four keys.
     */
    private function get_bulk_stats() {
        $stats = get_option('onwebchat_wc_bulk_stats', array());
        if (!is_array($stats)) {
            $stats = array();
        }
        return array(
            'created' => isset($stats['created']) ? (int) $stats['created'] : 0,
            'updated' => isset($stats['updated']) ? (int) $stats['updated'] : 0,
            'skipped' => isset($stats['skipped']) ? (int) $stats['skipped'] : 0,
            'errors'  => isset($stats['errors'])  ? (int) $stats['errors']  : 0,
        );
    }

    /**
     * Process all products in bulk sync directly (not via cron).
     *
     * @param array $category_ids Sync scope (product_cat term IDs). Empty = whole catalogue.
     */
    private function do_bulk_sync_all($category_ids = array()) {
        $total = get_option('onwebchat_wc_bulk_total', 0);
        $page = 0;
        $total_done = 0;
        $considered = 0; // products fetched so far, used to enforce the hard cap
        $all_stats = array('created' => 0, 'updated' => 0, 'skipped' => 0, 'errors' => 0);
        $max = self::MAX_SYNC_PRODUCTS;

        // Process all products in batches
        while (true) {
            $args = array(
                'post_type' => 'product',
                'post_status' => 'publish',
                'posts_per_page' => $this->batch_size,
                'paged' => $page + 1,
                'orderby' => 'ID',
                'order' => 'ASC',
            );

            // Restrict to the chosen top-level categories and their subtrees, to
            // match the per-product scope check and the counts shown in the picker.
            if (!empty($category_ids)) {
                $args['tax_query'] = array(array(
                    'taxonomy'         => 'product_cat',
                    'field'            => 'term_id',
                    'terms'            => array_map('intval', $category_ids),
                    'include_children' => true,
                ));
            }

            $query = new WP_Query($args);

            if (!$query->have_posts()) {
                break;
            }

            // Collect products in this batch, honoring the hard cap.
            $products_batch = array();
            $reached_cap = false;
            foreach ($query->posts as $post) {
                if ($considered >= $max) {
                    $reached_cap = true;
                    break;
                }
                $considered++;
                $product = wc_get_product($post->ID);
                if ($product && !$this->is_product_excluded($product)) {
                    $products_batch[] = $this->prepare_product_data($product);
                }
            }

            // Send batch
            if (!empty($products_batch)) {
                $result = $this->send_product_batch($products_batch);
                if ($result && isset($result['stats'])) {
                    $total_done += $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
                    $all_stats['created'] += $result['stats']['created'];
                    $all_stats['updated'] += $result['stats']['updated'];
                    $all_stats['skipped'] += $result['stats']['skipped'];
                    $all_stats['errors'] += $result['stats']['errors'];
                } else {
                    // Fallback
                    $total_done += count($products_batch);
                }

                // Update progress after each batch so AJAX polling can see it
                update_option('onwebchat_wc_bulk_done', $total_done);

                // Wait 4 seconds before next batch
                sleep(4);
            }

            wp_reset_postdata();
            $page++;

            // Stop once the hard cap is reached.
            if ($reached_cap || $considered >= $max) {
                break;
            }

            // Safety check - don't loop forever. The cap allows up to
            // MAX_SYNC_PRODUCTS / batch_size batches, so keep a generous guard.
            if ($page > ($max / $this->batch_size) + 10) {
                break;
            }
        }
        
        // Send completion notification to Angular dashboard with total stats
        $this->send_sync_completion_notification($all_stats);
        
        // Mark sync as complete
        update_option('onwebchat_wc_bulk_in_progress', false);
        update_option('onwebchat_wc_bulk_done', $total_done);
        update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
        
        return array(
            'done' => $total_done,
            'total' => $total,
            'stats' => $all_stats
        );
    }
    
    /**
     * Process bulk sync batch (via WP Cron) - Uses batch API endpoint
     */
    public function process_bulk_sync_batch() {
        error_log('onWebChat WooCommerce Sync - process_bulk_sync_batch called');
        
        if (!get_option('onwebchat_wc_bulk_in_progress', false)) {
            error_log('onWebChat WooCommerce Sync - Sync not in progress, exiting');
            return;
        }
        
        $page = get_option('onwebchat_wc_bulk_page', 0);
        $done = get_option('onwebchat_wc_bulk_done', 0);
        $total = get_option('onwebchat_wc_bulk_total', 0);
        
        error_log('onWebChat WooCommerce Sync - Starting batch: page=' . $page . ', done=' . $done . ', total=' . $total);

        // Get batch of products
        $args = array(
            'post_type' => 'product',
            'post_status' => 'publish',
            'posts_per_page' => $this->batch_size,
            'paged' => $page + 1,
            'orderby' => 'ID',
            'order' => 'ASC',
        );

        // Restrict to the saved sync scope and its subcategories, consistent
        // with the synchronous bulk sync path.
        $scope = $this->get_sync_scope();
        if (!empty($scope)) {
            $args['tax_query'] = array(array(
                'taxonomy'         => 'product_cat',
                'field'            => 'term_id',
                'terms'            => array_map('intval', $scope),
                'include_children' => true,
            ));
        }

        $query = new WP_Query($args);
        
        if ($query->have_posts()) {
            // Collect all products in this batch
            $products_batch = array();
            
            foreach ($query->posts as $post) {
                $product = wc_get_product($post->ID);
                if ($product && !$this->is_product_excluded($product)) {
                    $products_batch[] = $this->prepare_product_data($product);
                }
            }
            
            // Send entire batch in one request
            $batch_done = 0;
            if (!empty($products_batch)) {
                $result = $this->send_product_batch($products_batch);
                error_log('onWebChat WooCommerce Sync - Batch result: ' . print_r($result, true));
                if ($result && isset($result['stats'])) {
                    // Count created + updated + skipped as "done"
                    $batch_done = $result['stats']['created'] + $result['stats']['updated'] + $result['stats']['skipped'];
                    error_log('onWebChat WooCommerce Sync - Batch done: ' . $batch_done);
                } else {
                    // Fallback: assume all sent
                    $batch_done = count($products_batch);
                    error_log('onWebChat WooCommerce Sync - No stats in result, using fallback count: ' . $batch_done);
                }
            }
            
            // Update progress
            $new_done = $done + $batch_done;
            error_log('onWebChat WooCommerce Sync - Progress update: done=' . $done . ' + batch_done=' . $batch_done . ' = new_done=' . $new_done . ' / total=' . $total);
            update_option('onwebchat_wc_bulk_page', $page + 1);
            update_option('onwebchat_wc_bulk_done', $new_done);
            
            // Check if we've processed all products
            if ($new_done >= $total || !$query->have_posts()) {
                // Sync complete
                error_log('onWebChat WooCommerce Sync - Marking sync as complete');
                update_option('onwebchat_wc_bulk_in_progress', false);
                update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
                update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
            } else {
                // Schedule next batch
                error_log('onWebChat WooCommerce Sync - Scheduling next batch in 60 seconds');
                wp_schedule_single_event(time() + 60, 'onwebchat_wc_bulk_sync_batch');
            }
        } else {
            // No more products - sync complete
            update_option('onwebchat_wc_bulk_in_progress', false);
            update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
            update_option('onwebchat_wc_bulk_done', $total); // Ensure it shows 100%
        }
        
        wp_reset_postdata();
    }
    
    /**
     * AJAX: Regenerate secret (fetch from server)
     */
    public function ajax_regenerate_secret() {
        check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error('Insufficient permissions');
        }
        
        // Clear cached secret - user will need to re-authenticate
        delete_option('onwebchat_wc_sync_secret');
        
        // Clear any authentication error notices
        delete_transient('onwebchat_wc_auth_error');
        
        wp_send_json_success(array(
            'message' => 'Secret cleared. Please reconnect WooCommerce with your credentials.',
            'needs_reconnect' => true
        ));
    }
    
    /**
     * AJAX: Reset sync status
     */
    public function ajax_reset_sync_status() {
        check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error('Insufficient permissions');
        }
        
        $total = get_option('onwebchat_wc_bulk_total', 0);
        
        // Mark sync as complete
        update_option('onwebchat_wc_bulk_in_progress', false);
        update_option('onwebchat_wc_bulk_done', $total);
        update_option('onwebchat_wc_last_bulk_sync', current_time('timestamp'));
        
        wp_send_json_success(array(
            'message' => 'Sync status reset successfully'
        ));
    }
    
    /**
     * AJAX handler to get current sync status
     */
    public function ajax_get_sync_status() {
        check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error('Insufficient permissions');
        }
        
        $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
        $done = get_option('onwebchat_wc_bulk_done', 0);
        $total = get_option('onwebchat_wc_bulk_total', 0);
        
        wp_send_json_success(array(
            'in_progress' => $in_progress,
            'done' => $done,
            'total' => $total
        ));
    }
    
    /**
     * AJAX handler to save sync enabled setting
     */
    public function ajax_save_sync_enabled() {
        check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error('Insufficient permissions');
        }
        
        $sync_enabled = isset($_POST['sync_enabled']) && $_POST['sync_enabled'] === '1';
        
        update_option('onwebchat_wc_sync_enabled', $sync_enabled);
        
        wp_send_json_success(array(
            'message' => $sync_enabled ? 'WooCommerce product sync enabled' : 'WooCommerce product sync disabled',
            'enabled' => $sync_enabled
        ));
    }
    
    /**
     * Get sync status for admin display
     */
    public function get_sync_status() {
        $last_sync = get_option('onwebchat_wc_last_bulk_sync', 0);
        $in_progress = get_option('onwebchat_wc_bulk_in_progress', false);
        $done = get_option('onwebchat_wc_bulk_done', 0);
        $total = get_option('onwebchat_wc_bulk_total', 0);
        
        return array(
            'last_sync' => $last_sync,
            'in_progress' => $in_progress,
            'done' => $done,
            'total' => $total,
        );
    }
    
    /**
     * AJAX: Manually process batch (for debugging)
     */
    public function ajax_manual_process_batch() {
        check_ajax_referer('onwebchat_wc_sync_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error('Insufficient permissions');
        }
        
        // Manually trigger the cron job
        error_log('onWebChat WooCommerce Sync - Manual batch process triggered via AJAX');
        $this->process_bulk_sync_batch();
        
        // Return current status
        $status = $this->get_sync_status();
        wp_send_json_success(array(
            'message' => 'Batch processed',
            'status' => $status
        ));
    }
}

// Initialize the sync module
global $onwebchat_wc_sync;
$onwebchat_wc_sync = new OnWebChat_WooCommerce_Sync();


```
