<?php
/**
 * Plugin Name: ChatSell Widgets
 * Plugin URI: https://chatsell.co/wordpress-plugin
 * Description: Easy-to-use chat and product search widgets for WordPress sites. Add AI-powered customer support and product discovery to your site.
 * Version: 1.2.1
 * Author: ChatSell
 * Author URI: https://chatsell.co
 * License: GPL v2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: chatsell-widgets
 * Domain Path: /languages
 * Requires at least: 5.0
 * Tested up to: 6.9
 * Requires PHP: 7.4
 */

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

define( 'CSWIDGETS_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
define( 'CSWIDGETS_PLUGIN_PATH', plugin_dir_path( __FILE__ ) );
define( 'CSWIDGETS_VERSION', '1.2.1' );

/**
 * Main ChatSell Widgets Plugin Class
 */
class CSWidgets_Plugin {

	/**
	 * Description stamped on every WooCommerce key this plugin creates. It is
	 * how we tell our keys from the merchant's own, so provisioning, purging and
	 * revoking must all agree on it — hence one constant rather than a literal.
	 */
	const WC_KEY_DESCRIPTION = 'ChatSell (auto-generated)';

	private static $instance = null;

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

	private function __construct() {
		add_action( 'init', array( $this, 'init' ) );
		add_action( 'admin_menu', array( $this, 'add_admin_menu' ) );
		add_action( 'admin_init', array( $this, 'admin_init' ) );
		add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_admin_scripts' ) );
		add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
		add_action( 'wp_footer', array( $this, 'render_widgets' ) );

		add_shortcode( 'cswidgets_chat', array( $this, 'chat_shortcode' ) );
		add_shortcode( 'cswidgets_search', array( $this, 'search_shortcode' ) );
		add_shortcode( 'cswidgets_ask', array( $this, 'ask_shortcode' ) );

		add_action( 'save_post', array( $this, 'sync_product_saved' ), 10, 3 );
		add_action( 'before_delete_post', array( $this, 'sync_product_deleted' ), 10, 1 );
		add_action( 'save_post', array( $this, 'sync_knowledge_source_saved' ), 20, 3 );
		add_action( 'before_delete_post', array( $this, 'sync_knowledge_source_deleted' ), 20, 1 );
		add_action( 'woocommerce_save_product_variation', array( $this, 'sync_product_variation_saved' ), 10, 2 );
		add_action( 'woocommerce_update_product_variation', array( $this, 'sync_product_variation_updated' ), 10, 1 );

		// The plugin's only inbound route: lets ChatSell confirm that the plugin
		// on this domain really started a connection, so a link naming someone
		// else's site cannot be approved.
		add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );

		// Runs only on our settings screen, where the connect redirect lands.
		add_action( 'load-settings_page_chatsell-widgets', array( $this, 'maybe_handle_connect_callback' ) );

		// Store-connect actions. All are capability- and nonce-checked in
		// their handlers, and all redirect back to the settings screen.
		add_action( 'admin_post_cswidgets_connect', array( $this, 'handle_connect_start' ) );
		add_action( 'admin_post_cswidgets_pair', array( $this, 'handle_pair_code' ) );
		add_action( 'admin_post_cswidgets_refresh', array( $this, 'handle_refresh_connection' ) );
		add_action( 'admin_post_cswidgets_disconnect', array( $this, 'handle_disconnect' ) );
		add_action( 'admin_post_cswidgets_sync', array( $this, 'handle_sync_now' ) );

		// Progress polling. Proxied through WordPress so the webhook secret
		// stays server-side rather than being handed to admin JavaScript.
		add_action( 'wp_ajax_cswidgets_sync_status', array( $this, 'ajax_sync_status' ) );

		register_activation_hook( __FILE__, array( $this, 'activate' ) );
		register_deactivation_hook( __FILE__, array( $this, 'deactivate' ) );
	}

	public function init() {
		$this->migrate_legacy_options();
		add_action( 'cswidgets_sync_product_delayed', array( $this, 'sync_product_delayed' ), 10, 2 );
		add_action( 'cswidgets_sync_knowledge_source_delayed', array( $this, 'sync_knowledge_source_delayed' ), 10, 2 );
	}

	public function add_admin_menu() {
		add_options_page(
			__( 'ChatSell Widgets', 'chatsell-widgets' ),
			__( 'ChatSell Widgets', 'chatsell-widgets' ),
			'manage_options',
			'chatsell-widgets',
			array( $this, 'admin_page' )
		);
	}

	public function enqueue_admin_scripts( $hook_suffix ) {
		if ( 'settings_page_chatsell-widgets' !== $hook_suffix ) {
			return;
		}

		// Use each asset's modification time so reverse proxies and browser caches
		// cannot keep an older admin UI when the PHP markup changes. Fall back to
		// the release version if the file metadata is unavailable.
		$admin_css_path    = CSWIDGETS_PLUGIN_PATH . 'css/admin.css';
		$admin_js_path     = CSWIDGETS_PLUGIN_PATH . 'js/admin.js';
		$admin_css_version = is_readable( $admin_css_path ) ? (string) filemtime( $admin_css_path ) : CSWIDGETS_VERSION;
		$admin_js_version  = is_readable( $admin_js_path ) ? (string) filemtime( $admin_js_path ) : CSWIDGETS_VERSION;

		wp_enqueue_style(
			'cswidgets-admin',
			CSWIDGETS_PLUGIN_URL . 'css/admin.css',
			array(),
			$admin_css_version
		);
		wp_enqueue_script(
			'cswidgets-admin',
			CSWIDGETS_PLUGIN_URL . 'js/admin.js',
			array(),
			$admin_js_version,
			true
		);
		wp_localize_script(
			'cswidgets-admin',
			'CsWidgetsAdminL10n',
			array(
				'copied'               => __( 'Copied!', 'chatsell-widgets' ),
				'failedCopy'           => __( 'Failed to copy!', 'chatsell-widgets' ),
				'copy'                 => __( 'Copy', 'chatsell-widgets' ),
				'failed'               => __( 'Failed!', 'chatsell-widgets' ),
				'conversion_required'  => __( 'Please enter parameters in simple format first.', 'chatsell-widgets' ),
				'converted_to_json'    => __( 'Converted to JSON format! You can now customize the storage sources if needed.', 'chatsell-widgets' ),
				'no_valid_pairs'       => __( 'No valid key=value pairs found. Please use format: key=value,key2=value2', 'chatsell-widgets' ),
				'ajaxUrl'              => admin_url( 'admin-ajax.php' ),
				'syncStatusNonce'      => wp_create_nonce( 'cswidgets_sync_status' ),
				'syncQueued'           => __( 'Waiting for a worker to pick this up…', 'chatsell-widgets' ),
				'syncRunning'          => __( 'Syncing products…', 'chatsell-widgets' ),
				'syncCompleted'        => __( 'Sync complete.', 'chatsell-widgets' ),
				'syncFailed'           => __( 'Sync failed.', 'chatsell-widgets' ),
				'syncUnavailable'      => __( 'Sync status is temporarily unavailable. Refresh this page to check again.', 'chatsell-widgets' ),
				'syncTakingLonger'     => __( 'This sync is taking longer than expected. Refresh this page to continue monitoring it.', 'chatsell-widgets' ),
				'on'                   => __( 'On', 'chatsell-widgets' ),
				'off'                  => __( 'Off', 'chatsell-widgets' ),
				'configured'           => __( 'Configured', 'chatsell-widgets' ),
				'none'                 => __( 'None', 'chatsell-widgets' ),
				'unsaved'              => __( 'You have unsaved changes.', 'chatsell-widgets' ),
			)
		);
	}

	public function admin_init() {
		register_setting(
			'cswidgets_settings',
			'cswidgets_options',
			array(
				'type'              => 'array',
				'sanitize_callback' => array( $this, 'sanitize_options' ),
				'default'           => $this->get_default_options(),
			)
		);

		add_settings_section(
			'cswidgets_general',
			__( 'Store & sync', 'chatsell-widgets' ),
			array( $this, 'general_section_callback' ),
			'chatsell-widgets-general'
		);

		add_settings_field( 'widget_locale', __( 'Widget language', 'chatsell-widgets' ), array( $this, 'field_callback' ), 'chatsell-widgets-general', 'cswidgets_general', array( 'field' => 'widget_locale', 'class' => 'cswidgets-field-row cswidgets-field-widget_locale', 'label_for' => 'cswidgets-widget_locale' ) );
		add_settings_field( 'realtime_sync_enabled', __( 'Real-time sync', 'chatsell-widgets' ), array( $this, 'realtime_sync_field_callback' ), 'chatsell-widgets-general', 'cswidgets_general', array( 'class' => 'cswidgets-field-row cswidgets-field-realtime_sync_enabled', 'label_for' => 'cswidgets-realtime_sync_enabled' ) );

		// Site ID, service URLs and the webhook secret are negotiated by the
		// connect handshake. They stay registered — self-hosted deployments and
		// installs that predate the handshake still need to set them by hand —
		// but they live on a separate page slug so admin_page() can render them
		// inside a collapsed <details> instead of as the first thing a merchant
		// sees. Same settings group, so one form submit still saves them.
		add_settings_section(
			'cswidgets_advanced',
			'',
			array( $this, 'advanced_section_callback' ),
			'chatsell-widgets-advanced'
		);

		add_settings_field( 'site_id', __( 'Site ID', 'chatsell-widgets' ), array( $this, 'site_id_field_callback' ), 'chatsell-widgets-advanced', 'cswidgets_advanced', array( 'label_for' => 'cswidgets-site_id' ) );
		add_settings_field( 'frontend_url', __( 'Frontend URL', 'chatsell-widgets' ), array( $this, 'frontend_url_field_callback' ), 'chatsell-widgets-advanced', 'cswidgets_advanced', array( 'label_for' => 'cswidgets-frontend_url' ) );
		add_settings_field( 'api_url', __( 'API URL', 'chatsell-widgets' ), array( $this, 'api_url_field_callback' ), 'chatsell-widgets-advanced', 'cswidgets_advanced', array( 'label_for' => 'cswidgets-api_url' ) );
		add_settings_field( 'webhook_secret', __( 'Webhook secret', 'chatsell-widgets' ), array( $this, 'webhook_secret_field_callback' ), 'chatsell-widgets-advanced', 'cswidgets_advanced', array( 'label_for' => 'cswidgets-webhook_secret' ) );

		add_settings_section(
			'cswidgets_chat',
			__( 'Chat widget', 'chatsell-widgets' ),
			array( $this, 'chat_section_callback' ),
			'chatsell-widgets-chat'
		);

		$chat_fields = array(
			'chat_enabled'         => __( 'Show chat widget', 'chatsell-widgets' ),
			'chat_title'           => __( 'Widget title', 'chatsell-widgets' ),
			'chat_subtitle'        => __( 'Subtitle', 'chatsell-widgets' ),
			'chat_welcome_message' => __( 'Welcome message', 'chatsell-widgets' ),
			'chat_placeholder'     => __( 'Message placeholder', 'chatsell-widgets' ),
			'chat_position'        => __( 'Screen position', 'chatsell-widgets' ),
			'chat_primary_color'   => __( 'Primary colour', 'chatsell-widgets' ),
			'chat_hover_color'     => __( 'Hover colour', 'chatsell-widgets' ),
			'chat_show_avatar'     => __( 'Show assistant avatar', 'chatsell-widgets' ),
			'chat_enable_sound'    => __( 'Notification sounds', 'chatsell-widgets' ),
			'chat_variants'        => __( 'Product variants', 'chatsell-widgets' ),
			'chat_variant_filters' => __( 'Variant filters', 'chatsell-widgets' ),
			'chat_dynamic_params'  => __( 'Visitor context', 'chatsell-widgets' ),
			'chat_shortcode'       => __( 'Place with a shortcode', 'chatsell-widgets' ),
		);

		foreach ( $chat_fields as $field => $label ) {
			$field_args = array( 'field' => $field, 'class' => 'cswidgets-field-row cswidgets-field-' . $field );
			if ( false === strpos( $field, 'shortcode' ) && false === strpos( $field, 'dynamic_params' ) ) {
				$field_args['label_for'] = 'cswidgets-' . $field;
			}
			add_settings_field( $field, $label, array( $this, 'field_callback' ), 'chatsell-widgets-chat', 'cswidgets_chat', $field_args );
		}

		add_settings_section(
			'cswidgets_ask',
			__( 'Ask AI widget', 'chatsell-widgets' ),
			array( $this, 'ask_section_callback' ),
			'chatsell-widgets-ask'
		);

		$ask_fields = array(
			'ask_enabled'            => __( 'Show Ask AI widget', 'chatsell-widgets' ),
			'ask_button_label'       => __( 'Button label', 'chatsell-widgets' ),
			'ask_placeholder'        => __( 'Question placeholder', 'chatsell-widgets' ),
			'ask_position'           => __( 'Screen position', 'chatsell-widgets' ),
			'ask_primary_color'      => __( 'Primary colour', 'chatsell-widgets' ),
			'ask_mount_mode'         => __( 'Placement', 'chatsell-widgets' ),
			'ask_container_selector' => __( 'Page container', 'chatsell-widgets' ),
			'ask_shortcode'          => __( 'Place with a shortcode', 'chatsell-widgets' ),
		);

		foreach ( $ask_fields as $field => $label ) {
			$field_args = array( 'field' => $field, 'class' => 'cswidgets-field-row cswidgets-field-' . $field );
			if ( false === strpos( $field, 'shortcode' ) ) {
				$field_args['label_for'] = 'cswidgets-' . $field;
			}
			add_settings_field( $field, $label, array( $this, 'field_callback' ), 'chatsell-widgets-ask', 'cswidgets_ask', $field_args );
		}

		add_settings_section(
			'cswidgets_product_discovery',
			__( 'Product matching', 'chatsell-widgets' ),
			array( $this, 'product_discovery_section_callback' ),
			'chatsell-widgets-product-discovery'
		);

		$product_discovery_fields = array(
			'product_variants'        => __( 'Product variants', 'chatsell-widgets' ),
			'product_variant_filters' => __( 'Variant filters', 'chatsell-widgets' ),
			'product_dynamic_params'  => __( 'Visitor context', 'chatsell-widgets' ),
		);

		foreach ( $product_discovery_fields as $field => $label ) {
			$field_args = array( 'field' => $field, 'class' => 'cswidgets-field-row cswidgets-field-' . $field );
			if ( false === strpos( $field, 'dynamic_params' ) ) {
				$field_args['label_for'] = 'cswidgets-' . $field;
			}
			add_settings_field( $field, $label, array( $this, 'field_callback' ), 'chatsell-widgets-product-discovery', 'cswidgets_product_discovery', $field_args );
		}

		add_settings_section(
			'cswidgets_search',
			__( 'Product search', 'chatsell-widgets' ),
			array( $this, 'search_section_callback' ),
			'chatsell-widgets-search'
		);

		$search_fields = array(
			'search_enabled'              => __( 'Show product search', 'chatsell-widgets' ),
			'search_placeholder'          => __( 'Search placeholder', 'chatsell-widgets' ),
			'search_limit'                => __( 'Results shown', 'chatsell-widgets' ),
			'search_position'             => __( 'Screen position', 'chatsell-widgets' ),
			'search_primary_color'        => __( 'Primary colour', 'chatsell-widgets' ),
			'search_hover_color'          => __( 'Hover colour', 'chatsell-widgets' ),
			'search_show_catalog_button'  => __( 'Catalog shortcut', 'chatsell-widgets' ),
			'search_catalog_button_label' => __( 'Catalog button label', 'chatsell-widgets' ),
			'search_mount_mode'           => __( 'Placement', 'chatsell-widgets' ),
			'search_container_selector'   => __( 'Page container', 'chatsell-widgets' ),
			'search_full_width'           => __( 'Use full container width', 'chatsell-widgets' ),
			'search_shortcode'            => __( 'Place with a shortcode', 'chatsell-widgets' ),
		);

		foreach ( $search_fields as $field => $label ) {
			$field_args = array( 'field' => $field, 'class' => 'cswidgets-field-row cswidgets-field-' . $field );
			if ( false === strpos( $field, 'shortcode' ) ) {
				$field_args['label_for'] = 'cswidgets-' . $field;
			}
			add_settings_field( $field, $label, array( $this, 'field_callback' ), 'chatsell-widgets-search', 'cswidgets_search', $field_args );
		}
	}

	public function sanitize_options( $input ) {
		if ( ! is_array( $input ) ) {
			$input = array();
		}

		$sanitized       = array();
		$existing_options = $this->get_options();

		// Connection state is negotiated with ChatSell, not typed into this form.
		// This function returns only what it builds, so without carrying these
		// across, hitting "Save Changes" would silently unlink the site.
		$connection_fields = array(
			'connection_status', 'connected_site_name', 'connected_org_name',
			'connected_at', 'wc_key_id', 'last_sync_at', 'sync_task_id',
			'last_connect_error',
		);
		foreach ( $connection_fields as $field ) {
			if ( isset( $existing_options[ $field ] ) ) {
				$sanitized[ $field ] = $existing_options[ $field ];
			}
		}

		$text_fields = array(
			'site_id', 'chat_title', 'chat_subtitle', 'chat_welcome_message',
			'chat_placeholder', 'chat_variant_filters', 'search_placeholder',
			'search_variant_filters', 'search_container_selector', 'search_catalog_button_label',
			'ask_button_label', 'ask_placeholder', 'ask_container_selector',
			'product_variant_filters',
		);

		foreach ( $text_fields as $field ) {
			if ( isset( $input[ $field ] ) ) {
				$sanitized[ $field ] = $this->sanitize_text_option( $input[ $field ] );
			}
		}

		if ( isset( $input['clear_webhook_secret'] ) && 1 === $this->sanitize_checkbox_value( $input['clear_webhook_secret'] ) ) {
			$sanitized['webhook_secret'] = '';
		} elseif ( isset( $input['webhook_secret'] ) ) {
			$webhook_secret = $this->sanitize_text_option( $input['webhook_secret'] );
			if ( '' !== $webhook_secret ) {
				$sanitized['webhook_secret'] = $webhook_secret;
			} elseif ( ! empty( $existing_options['webhook_secret'] ) ) {
				$sanitized['webhook_secret'] = $existing_options['webhook_secret'];
			}
		} elseif ( ! empty( $existing_options['webhook_secret'] ) ) {
			$sanitized['webhook_secret'] = $existing_options['webhook_secret'];
		}

		$url_fields = array( 'frontend_url', 'api_url' );
		$defaults   = $this->get_default_options();
		foreach ( $url_fields as $field ) {
			if ( isset( $input[ $field ] ) ) {
				$fallback            = isset( $existing_options[ $field ] ) ? $this->sanitize_service_url( $existing_options[ $field ], $defaults[ $field ] ) : $defaults[ $field ];
				$sanitized[ $field ] = $this->sanitize_service_url( $input[ $field ], $fallback );
			}
		}

		$select_fields = array(
			'widget_locale'    => array( 'auto', 'en', 'es', 'fr', 'de', 'pt', 'it', 'nl', 'pl', 'tr', 'ar' ),
			// Legacy chat-only locale, kept so settings saved by older versions still validate.
			'chat_locale'      => array( 'auto', 'en', 'es', 'fr', 'de', 'pt', 'it', 'nl', 'pl', 'tr', 'ar' ),
			'chat_position'    => array( 'bottom-right', 'bottom-left', 'top-right', 'top-left' ),
			'chat_variants'    => array( 'none', 'all', 'only' ),
			'product_variants' => array( 'none', 'all', 'only' ),
			'search_position'  => array( 'top-right', 'top-left', 'bottom-right', 'bottom-left' ),
			'search_variants'  => array( 'none', 'all', 'only' ),
			'search_mount_mode' => array( 'floating', 'inline' ),
			'ask_position'     => array( 'bottom-right', 'bottom-left', 'top-right', 'top-left' ),
			'ask_mount_mode'   => array( 'floating', 'inline' ),
		);

		foreach ( $select_fields as $field => $allowed_values ) {
			$value = isset( $input[ $field ] ) ? $this->sanitize_key_option( $input[ $field ] ) : '';
			if ( in_array( $value, $allowed_values, true ) ) {
				$sanitized[ $field ] = $value;
			}
		}

		$color_fields = array( 'chat_primary_color', 'search_primary_color', 'chat_hover_color', 'search_hover_color', 'ask_primary_color' );
		foreach ( $color_fields as $field ) {
			if ( isset( $input[ $field ] ) ) {
				$sanitized[ $field ] = $this->sanitize_color_option( $input[ $field ] );
			}
		}

		if ( isset( $input['search_limit'] ) ) {
			$sanitized['search_limit'] = min( 50, max( 1, $this->sanitize_absint_option( $input['search_limit'] ) ) );
		}

		$checkbox_fields = array( 'chat_enabled', 'chat_show_avatar', 'chat_enable_sound', 'search_enabled', 'search_full_width', 'search_show_catalog_button', 'realtime_sync_enabled', 'ask_enabled' );
		foreach ( $checkbox_fields as $field ) {
			$sanitized[ $field ] = isset( $input[ $field ] ) ? $this->sanitize_checkbox_value( $input[ $field ] ) : 0;
		}

		$dynamic_fields = array( 'chat_dynamic_params', 'search_dynamic_params', 'product_dynamic_params' );
		foreach ( $dynamic_fields as $field ) {
			if ( isset( $input[ $field ] ) && ! empty( $input[ $field ] ) ) {
				$sanitized[ $field ] = $this->sanitize_dynamic_params( $input[ $field ] );
			} elseif ( isset( $input[ $field . '_simple' ] ) && ! empty( $input[ $field . '_simple' ] ) ) {
				$sanitized[ $field ] = $this->sanitize_dynamic_params( $this->convert_simple_to_json( $input[ $field . '_simple' ] ) );
			}
		}

		return $sanitized;
	}

	public function get_default_options() {
		return array(
			'site_id'                    => '',
			'frontend_url'               => 'https://chatsell.co',
			'api_url'                    => 'https://backend.chatsell.co',
			'widget_locale'              => '',
			'chat_enabled'               => 0,
			'chat_title'                 => 'Chat with us!',
			'chat_subtitle'              => 'Ask about our products',
			'chat_position'              => 'bottom-right',
			'chat_primary_color'         => '#173F35',
			'chat_hover_color'           => '',
			'chat_show_avatar'           => 0,
			'chat_enable_sound'          => 1,
			'chat_welcome_message'       => 'Hi! How can I help you today?',
			'chat_placeholder'           => 'Type your message...',
			'chat_variants'              => 'none',
			'chat_variant_filters'       => '',
			'chat_dynamic_params'        => '',
			'product_variants'           => '',
			'product_variant_filters'    => '',
			'product_dynamic_params'     => '',
			'search_enabled'             => 0,
			'search_position'            => 'top-right',
			'search_primary_color'       => '#3B82F6',
			'search_hover_color'         => '',
			'search_placeholder'         => 'Search products...',
			'search_limit'               => 10,
			'search_variants'            => 'none',
			'search_variant_filters'     => '',
			'search_dynamic_params'      => '',
			'search_show_catalog_button' => 1,
			'search_catalog_button_label' => 'Browse catalog',
			'search_mount_mode'          => 'floating',
			'search_container_selector'  => '',
			'search_full_width'          => 0,
			'realtime_sync_enabled'      => 0,
			'webhook_secret'             => '',
			'ask_enabled'                => 0,
			'ask_position'               => 'bottom-right',
			'ask_primary_color'          => '#3B82F6',
			'ask_button_label'           => 'Ask AI',
			'ask_placeholder'            => 'Ask a question...',
			'ask_mount_mode'             => 'floating',
			'ask_container_selector'     => '',
			// Store-connect state. Written by the connect handlers, never by the
			// settings form — see the preserve list in sanitize_options().
			'connection_status'          => 'unlinked',
			'connected_site_name'        => '',
			'connected_org_name'         => '',
			'connected_at'               => 0,
			'wc_key_id'                  => 0,
			'last_sync_at'               => 0,
			'sync_task_id'               => '',
			'last_connect_error'         => '',
		);
	}

	public function get_options() {
		$defaults = $this->get_default_options();
		$options  = get_option( 'cswidgets_options', array() );
		if ( ! is_array( $options ) ) {
			$options = array();
		}

		return wp_parse_args( $options, $defaults );
	}

	/**
	 * Write options from plugin code rather than from the settings form.
	 *
	 * register_setting() hangs sanitize_options() on the
	 * sanitize_option_cswidgets_options filter, and update_option() runs that
	 * filter on *every* write — not only the one options.php makes. Because
	 * sanitize_options() deliberately restores the connection fields from the
	 * stored value (so "Save Changes" cannot unlink the site), a plain
	 * update_option() here would silently discard the very fields the caller is
	 * trying to change: the connect handshake would leave connection_status at
	 * "unlinked", wc_key_id would never stick and every reconnect would mint a
	 * fresh WooCommerce key, and disconnect could not clear the secret.
	 *
	 * Values reaching this method are already sanitized by their callers, so the
	 * filter is dropped for the duration of our own write and then restored at
	 * the priority it was registered with.
	 */
	private function save_options( $options ) {
		$filter   = 'sanitize_option_cswidgets_options';
		$callback = array( $this, 'sanitize_options' );

		// has_filter() returns the priority, which may legitimately be 0.
		$priority = has_filter( $filter, $callback );
		if ( false !== $priority ) {
			remove_filter( $filter, $callback, $priority );
		}

		update_option( 'cswidgets_options', $options );

		if ( false !== $priority ) {
			add_filter( $filter, $callback, $priority );
		}
	}

	private function migrate_legacy_options() {
		if ( false !== get_option( 'cswidgets_legacy_options_migrated', false ) ) {
			return;
		}

		$legacy_options = get_option( 'chatsell_options', false );
		if ( ! is_array( $legacy_options ) ) {
			return;
		}

		$current_options = get_option( 'cswidgets_options', false );
		if ( false !== $current_options && ! $this->options_match_defaults( $current_options ) ) {
			update_option( 'cswidgets_legacy_options_migrated', CSWIDGETS_VERSION );
			return;
		}

		$defaults = $this->get_default_options();
		$input    = wp_parse_args( $legacy_options, $defaults );
		$migrated = wp_parse_args( $this->sanitize_options( $input ), $defaults );

		// Already sanitized on the line above; save_options() keeps the filter
		// from applying it a second time.
		$this->save_options( $migrated );
		update_option( 'cswidgets_legacy_options_migrated', CSWIDGETS_VERSION );
	}

	private function options_match_defaults( $options ) {
		if ( ! is_array( $options ) ) {
			return false;
		}

		$defaults = $this->get_default_options();
		$options  = wp_parse_args( $options, $defaults );

		foreach ( $defaults as $key => $default_value ) {
			if ( (string) $options[ $key ] !== (string) $default_value ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Widget language for every widget this plugin renders. Falls back to the
	 * legacy chat-only option so installs upgrading from an older version keep
	 * the language they picked until they save the settings page again.
	 */
	private function get_widget_locale( $options ) {
		if ( ! empty( $options['widget_locale'] ) ) {
			return $options['widget_locale'];
		}
		return ! empty( $options['chat_locale'] ) ? $options['chat_locale'] : 'auto';
	}

	/**
	 * Adds the merchant's language choice to a widget config.
	 *
	 * On "auto" no locale is sent at all: the widget then runs its own
	 * resolution chain (ChatSell site language → the page's <html lang> →
	 * the visitor's browser), which lands on the visitor's language rather
	 * than on this site's admin language.
	 */
	private function apply_widget_locale( $config, $options ) {
		return $this->apply_locale( $config, $this->get_widget_locale( $options ) );
	}

	private function apply_locale( $config, $locale ) {
		$locale = $this->sanitize_key_option( $locale );
		if ( '' !== $locale && 'auto' !== $locale ) {
			$config['locale'] = $locale;
		}
		return $config;
	}

	/**
	 * Add display settings WooCommerce already knows to product widgets.
	 *
	 * Supplying both values lets search and the product widgets it opens avoid
	 * the public widget-config capability request. Older/manual embeds that omit
	 * either value retain the JavaScript fallback to that endpoint.
	 */
	private function apply_product_display_settings( $config, $locale ) {
		$config = $this->apply_locale( $config, $locale );

		if ( empty( $config['locale'] ) && function_exists( 'get_locale' ) ) {
			$config = $this->apply_locale( $config, get_locale() );
		}

		if ( function_exists( 'get_woocommerce_currency' ) ) {
			$currency = strtoupper( trim( (string) get_woocommerce_currency() ) );
			if ( preg_match( '/^[A-Z]{3}$/', $currency ) ) {
				$config['currency'] = $currency;
			}
		}

		return $config;
	}

	/**
	 * True when the merchant replaced a shipped English default. Untouched
	 * defaults must not be sent to a widget — an explicit config value wins
	 * over the widget's own translated catalog and would pin it to English.
	 */
	private function is_customized_text( $value, $default ) {
		$value = trim( (string) $value );

		return '' !== $value && $value !== trim( (string) $default );
	}

	private function get_product_variants( $options ) {
		if ( ! empty( $options['product_variants'] ) ) {
			return $options['product_variants'];
		}
		return ! empty( $options['search_variants'] ) ? $options['search_variants'] : 'none';
	}

	private function get_product_variant_filters( $options ) {
		if ( ! empty( $options['product_variant_filters'] ) ) {
			return $options['product_variant_filters'];
		}
		return ! empty( $options['search_variant_filters'] ) ? $options['search_variant_filters'] : '';
	}

	private function get_product_dynamic_params( $options ) {
		if ( ! empty( $options['product_dynamic_params'] ) ) {
			return $options['product_dynamic_params'];
		}
		return ! empty( $options['search_dynamic_params'] ) ? $options['search_dynamic_params'] : '';
	}

	private function json_encode_for_script( $data ) {
		$json = wp_json_encode( $data, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT );
		return false === $json ? '{}' : $json;
	}

	private function is_local_service_host( $host ) {
		$host = strtolower( trim( (string) $host, '[]' ) );

		if ( in_array( $host, array( 'localhost', '127.0.0.1', '::1', 'host.docker.internal' ), true ) ) {
			return true;
		}

		return strlen( $host ) > 10 && '.localhost' === substr( $host, -10 );
	}

	private function sanitize_text_option( $value ) {
		if ( ! is_scalar( $value ) ) {
			return '';
		}

		return sanitize_text_field( wp_unslash( (string) $value ) );
	}

	private function sanitize_key_option( $value ) {
		if ( ! is_scalar( $value ) ) {
			return '';
		}

		return sanitize_key( wp_unslash( (string) $value ) );
	}

	private function sanitize_color_option( $value ) {
		if ( ! is_scalar( $value ) ) {
			return '';
		}

		$color = sanitize_hex_color( wp_unslash( (string) $value ) );
		return null === $color ? '' : $color;
	}

	private function sanitize_absint_option( $value ) {
		if ( ! is_scalar( $value ) ) {
			return 0;
		}

		return absint( wp_unslash( (string) $value ) );
	}

	private function sanitize_checkbox_value( $value ) {
		return $this->sanitize_absint_option( $value ) > 0 ? 1 : 0;
	}

	private function sanitize_service_url( $url, $fallback = '' ) {
		if ( ! is_scalar( $url ) ) {
			return $fallback;
		}

		$url = trim( wp_unslash( (string) $url ) );
		if ( '' === $url ) {
			return $fallback;
		}

		$url   = esc_url_raw( $url, array( 'https', 'http' ) );
		$parts = wp_parse_url( $url );

		if ( empty( $url ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return $fallback;
		}

		$scheme = strtolower( $parts['scheme'] );
		$host   = strtolower( trim( $parts['host'], '[]' ) );

		if ( 'https' === $scheme || ( 'http' === $scheme && $this->is_local_service_host( $host ) ) ) {
			return untrailingslashit( $url );
		}

		return $fallback;
	}

	private function get_service_url( $options, $field ) {
		$defaults = $this->get_default_options();
		$fallback = isset( $defaults[ $field ] ) ? $defaults[ $field ] : '';
		$value    = isset( $options[ $field ] ) ? $options[ $field ] : $fallback;

		return $this->sanitize_service_url( $value, $fallback );
	}

	private function is_safe_webhook_url( $url ) {
		$parts = wp_parse_url( $url );

		if ( empty( $parts['scheme'] ) || empty( $parts['host'] ) ) {
			return false;
		}

		$scheme = strtolower( $parts['scheme'] );
		$host   = strtolower( trim( $parts['host'], '[]' ) );

		if ( $this->is_local_service_host( $host ) ) {
			return true;
		}

		if ( 'https' !== $scheme || ! wp_http_validate_url( $url ) ) {
			return false;
		}

		if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
			return (bool) filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE );
		}

		return true;
	}

	private function sanitize_dynamic_params( $value ) {
		if ( ! is_scalar( $value ) ) {
			return '';
		}

		$params = $this->parse_dynamic_params( $value );
		if ( empty( $params ) ) {
			return '';
		}

		$json = wp_json_encode( $params, JSON_PRETTY_PRINT );
		return false === $json ? '' : $json;
	}

	private function parse_dynamic_params( $value ) {
		if ( ! is_scalar( $value ) ) {
			return array();
		}

		$value = trim( wp_unslash( (string) $value ) );
		if ( '' === $value ) {
			return array();
		}

		$decoded = json_decode( $value, true );
		if ( ! is_array( $decoded ) ) {
			return array();
		}

		$params = array();
		foreach ( $decoded as $key => $config ) {
			if ( ! is_string( $key ) || ! is_array( $config ) || empty( $config['source'] ) || empty( $config['name'] ) ) {
				continue;
			}

			$key    = trim( $key );
			$source = trim( (string) $config['source'] );
			$name   = trim( (string) $config['name'] );

			if ( ! preg_match( '/^[A-Za-z0-9_.:-]{1,80}$/', $key ) || ! preg_match( '/^[A-Za-z0-9_.:-]{1,120}$/', $name ) ) {
				continue;
			}

			if ( ! in_array( $source, array( 'localStorage', 'sessionStorage', 'cookie' ), true ) ) {
				continue;
			}

			$params[ $key ] = array(
				'source' => $source,
				'name'   => $name,
			);
		}

		return $params;
	}

	private function get_shortcode_dynamic_params( $raw_atts, $option_value ) {
		$allowed_params = $this->parse_dynamic_params( $option_value );
		if ( ! is_array( $raw_atts ) || ! array_key_exists( 'dynamic_params', $raw_atts ) ) {
			return $allowed_params;
		}

		$requested_params = $this->parse_dynamic_params( $raw_atts['dynamic_params'] );
		if ( empty( $requested_params ) || empty( $allowed_params ) ) {
			return array();
		}

		$params = array();
		foreach ( $requested_params as $key => $config ) {
			if (
				isset( $allowed_params[ $key ] ) &&
				$allowed_params[ $key ]['source'] === $config['source'] &&
				$allowed_params[ $key ]['name'] === $config['name']
			) {
				$params[ $key ] = $config;
			}
		}

		return $params;
	}

	public function convert_simple_to_json( $value ) {
		if ( ! is_scalar( $value ) ) {
			return '';
		}

		$value = (string) $value;
		if ( empty( $value ) || '{' === $value[0] ) {
			return $value;
		}

		$pairs = explode( ',', $value );
		$json  = array();

		foreach ( $pairs as $pair ) {
			$pair = trim( $pair );
			if ( false === strpos( $pair, '=' ) ) {
				continue;
			}
			list( $key, $val ) = explode( '=', $pair, 2 );
			$key = trim( $key );
			$val = trim( $val );

			if ( empty( $key ) ) {
				continue;
			}

			$source     = 'localStorage';
			$storage_key = $key;

			if ( false !== strpos( $key, '@' ) ) {
				list( $actual_key, $source_type ) = explode( '@', $key, 2 );
				$actual_key  = trim( $actual_key );
				$source_type = strtolower( trim( $source_type ) );

				switch ( $source_type ) {
					case 'cookie':
					case 'cookies':
						$source = 'cookie';
						break;
					case 'session':
					case 'sessionstorage':
						$source = 'sessionStorage';
						break;
					default:
						$source = 'localStorage';
				}

				$storage_key = $actual_key;
			}

			$json[ $storage_key ] = array(
				'source' => $source,
				'name'   => $val,
			);
		}

		return empty( $json ) ? $value : wp_json_encode( $json, JSON_PRETTY_PRINT );
	}

	public function get_simple_format( $value ) {
		if ( ! is_scalar( $value ) ) {
			return '';
		}

		$value = (string) $value;
		if ( empty( $value ) || '{' !== $value[0] ) {
			return $value;
		}

		$json = json_decode( $value, true );
		if ( ! is_array( $json ) ) {
			return '';
		}

		$pairs = array();
		foreach ( $json as $key => $config ) {
			if ( isset( $config['source'], $config['name'] ) ) {
				$source_suffix = '';
				switch ( $config['source'] ) {
					case 'cookie':
						$source_suffix = '@cookie';
						break;
					case 'sessionStorage':
						$source_suffix = '@session';
						break;
				}
				$pairs[] = $key . $source_suffix . '=' . $config['name'];
			}
		}

		return implode( ',', $pairs );
	}

	public function general_section_callback() {
		echo '<p>' . esc_html__( 'Choose the language shoppers see and keep your catalogue knowledge current.', 'chatsell-widgets' ) . '</p>';
	}

	public function chat_section_callback() {
		echo '<p>' . esc_html__( 'Shape the conversation shoppers open from your storefront.', 'chatsell-widgets' ) . '</p>';
	}

	public function search_section_callback() {
		echo '<p>' . esc_html__( 'Help shoppers describe what they need and reach matching products quickly.', 'chatsell-widgets' ) . '</p>';
	}

	public function ask_section_callback() {
		echo '<p>' . esc_html__( 'Offer a focused question box powered by your ChatSell knowledge base.', 'chatsell-widgets' ) . '</p>';
	}

	public function product_discovery_section_callback() {
		echo '<p>' . esc_html__( 'Control which product variants ChatSell can recommend and how visitor context narrows results.', 'chatsell-widgets' ) . '</p>';
	}

	public function build_search_shortcode( $options = null ) {
		if ( null === $options ) {
			$options = $this->get_options();
		}
		$defaults  = $this->get_default_options();
		$atts      = $this->apply_locale( array(), $this->get_widget_locale( $options ) );
		$text_keys = array( 'search_placeholder', 'search_catalog_button_label', 'ask_button_label', 'ask_placeholder' );
		$map       = array(
			'search_position'             => 'position',
			'search_primary_color'        => 'primary_color',
			'search_hover_color'          => 'hover_color',
			'search_placeholder'          => 'placeholder',
			'search_full_width'           => 'full_width',
			'search_limit'                => 'limit',
			'product_variants'            => 'variants',
			'product_variant_filters'     => 'variant_filters',
			'product_dynamic_params'      => 'dynamic_params',
			'search_show_catalog_button'  => 'show_catalog_button',
			'search_catalog_button_label' => 'catalog_button_label',
			'ask_enabled'                 => 'show_ask_ai_button',
			'ask_button_label'            => 'ask_ai_button_label',
			'ask_placeholder'             => 'ask_ai_placeholder',
		);
		foreach ( $map as $opt_key => $attr_name ) {
			if ( 'product_variants' === $opt_key ) {
				$val = $this->get_product_variants( $options );
			} elseif ( 'product_variant_filters' === $opt_key ) {
				$val = $this->get_product_variant_filters( $options );
			} elseif ( 'product_dynamic_params' === $opt_key ) {
				$val = $this->get_product_dynamic_params( $options );
			} else {
				$val = isset( $options[ $opt_key ] ) ? $options[ $opt_key ] : ( isset( $defaults[ $opt_key ] ) ? $defaults[ $opt_key ] : '' );
			}
			if ( 'search_full_width' === $opt_key || 'search_show_catalog_button' === $opt_key || 'ask_enabled' === $opt_key ) {
				$val = ! empty( $val ) ? '1' : '0';
			}
			// Untouched English defaults are left out so the widget translates them itself.
			if ( in_array( $opt_key, $text_keys, true ) && ! $this->is_customized_text( $val, $defaults[ $opt_key ] ) ) {
				continue;
			}
			if ( '' !== (string) $val ) {
				$atts[ $attr_name ] = $val;
			}
		}
		$parts = array();
		foreach ( $atts as $k => $v ) {
			$v = (string) $v;
			if ( false !== strpos( $v, '"' ) ) {
				$parts[] = $k . "='" . str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), $v ) . "'";
			} else {
				$parts[] = $k . '="' . str_replace( '\\', '\\\\', $v ) . '"';
			}
		}
		return '[cswidgets_search' . ( count( $parts ) ? ' ' . implode( ' ', $parts ) : '' ) . ']';
	}

	public function build_chat_shortcode( $options = null ) {
		if ( null === $options ) {
			$options = $this->get_options();
		}
		$defaults  = $this->get_default_options();
		$atts      = $this->apply_locale( array(), $this->get_widget_locale( $options ) );
		$text_keys = array( 'chat_title', 'chat_subtitle', 'chat_welcome_message', 'chat_placeholder' );
		$map       = array(
			'chat_title'           => 'title',
			'chat_subtitle'        => 'subtitle',
			'chat_position'        => 'position',
			'chat_primary_color'   => 'primary_color',
			'chat_hover_color'     => 'hover_color',
			'chat_show_avatar'     => 'show_avatar',
			'chat_enable_sound'    => 'enable_sound',
			'chat_welcome_message' => 'welcome_message',
			'chat_placeholder'     => 'placeholder',
			'chat_variants'        => 'variants',
			'chat_variant_filters' => 'variant_filters',
			'chat_dynamic_params'  => 'dynamic_params',
		);
		foreach ( $map as $opt_key => $attr_name ) {
			$val = isset( $options[ $opt_key ] ) ? $options[ $opt_key ] : ( isset( $defaults[ $opt_key ] ) ? $defaults[ $opt_key ] : '' );
			if ( 'chat_show_avatar' === $opt_key || 'chat_enable_sound' === $opt_key ) {
				$val = ! empty( $val ) ? '1' : '0';
			}
			// Untouched English defaults are left out so the widget translates them itself.
			if ( in_array( $opt_key, $text_keys, true ) && ! $this->is_customized_text( $val, $defaults[ $opt_key ] ) ) {
				continue;
			}
			if ( '' !== (string) $val ) {
				$atts[ $attr_name ] = $val;
			}
		}
		$parts = array();
		foreach ( $atts as $k => $v ) {
			$v = (string) $v;
			if ( false !== strpos( $v, '"' ) ) {
				$parts[] = $k . "='" . str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), $v ) . "'";
			} else {
				$parts[] = $k . '="' . str_replace( '\\', '\\\\', $v ) . '"';
			}
		}
		return '[cswidgets_chat' . ( count( $parts ) ? ' ' . implode( ' ', $parts ) : '' ) . ']';
	}

	public function build_ask_shortcode( $options = null ) {
		if ( null === $options ) {
			$options = $this->get_options();
		}
		$defaults  = $this->get_default_options();
		$atts      = $this->apply_locale( array(), $this->get_widget_locale( $options ) );
		$text_keys = array( 'ask_button_label', 'ask_placeholder' );
		$map       = array(
			'ask_position'      => 'position',
			'ask_primary_color' => 'primary_color',
			'ask_button_label'  => 'button_label',
			'ask_placeholder'   => 'placeholder',
			'product_variants'  => 'variants',
			'product_variant_filters' => 'variant_filters',
			'product_dynamic_params' => 'dynamic_params',
		);
		foreach ( $map as $opt_key => $attr_name ) {
			if ( 'product_variants' === $opt_key ) {
				$val = $this->get_product_variants( $options );
			} elseif ( 'product_variant_filters' === $opt_key ) {
				$val = $this->get_product_variant_filters( $options );
			} elseif ( 'product_dynamic_params' === $opt_key ) {
				$val = $this->get_product_dynamic_params( $options );
			} else {
				$val = isset( $options[ $opt_key ] ) ? $options[ $opt_key ] : ( isset( $defaults[ $opt_key ] ) ? $defaults[ $opt_key ] : '' );
			}
			// Untouched English defaults are left out so the widget translates them itself.
			if ( in_array( $opt_key, $text_keys, true ) && ! $this->is_customized_text( $val, $defaults[ $opt_key ] ) ) {
				continue;
			}
			if ( '' !== (string) $val ) {
				$atts[ $attr_name ] = $val;
			}
		}
		$parts = array();
		foreach ( $atts as $k => $v ) {
			$v = (string) $v;
			if ( false !== strpos( $v, '"' ) ) {
				$parts[] = $k . "='" . str_replace( array( '\\', "'" ), array( '\\\\', "\\'" ), $v ) . "'";
			} else {
				$parts[] = $k . '="' . str_replace( '\\', '\\\\', $v ) . '"';
			}
		}
		return '[cswidgets_ask' . ( count( $parts ) ? ' ' . implode( ' ', $parts ) : '' ) . ']';
	}

	public function site_id_field_callback() {
		$options = $this->get_options();
		echo '<input type="text" id="cswidgets-site_id" name="cswidgets_options[site_id]" value="' . esc_attr( $options['site_id'] ) . '" class="regular-text" required />';
		echo '<p class="description">' . esc_html__( 'Your ChatSell site ID. Get this from your ChatSell dashboard.', 'chatsell-widgets' ) . '</p>';
	}

	public function frontend_url_field_callback() {
		$options = $this->get_options();
		echo '<input type="url" id="cswidgets-frontend_url" name="cswidgets_options[frontend_url]" value="' . esc_attr( $options['frontend_url'] ) . '" class="regular-text" />';
		echo '<p class="description">' . esc_html__( 'The URL where ChatSell widget files are served. Leave as default unless self-hosting.', 'chatsell-widgets' ) . '</p>';
	}

	public function api_url_field_callback() {
		$options = $this->get_options();
		echo '<input type="url" id="cswidgets-api_url" name="cswidgets_options[api_url]" value="' . esc_attr( $options['api_url'] ) . '" class="regular-text" />';
		echo '<p class="description">' . esc_html__( 'The ChatSell API URL. Leave as default unless self-hosting.', 'chatsell-widgets' ) . '</p>';
	}

	public function realtime_sync_field_callback() {
		$options = $this->get_options();
		echo '<input type="checkbox" id="cswidgets-realtime_sync_enabled" name="cswidgets_options[realtime_sync_enabled]" value="1" ' . checked( 1, $options['realtime_sync_enabled'], false ) . ' />';
		echo '<p class="description">' . esc_html__( 'Automatically sync product, page, and post changes to ChatSell in real-time. Product sync requires WooCommerce to be active.', 'chatsell-widgets' ) . '</p>';
	}

	public function webhook_secret_field_callback() {
		$options     = $this->get_options();
		$value       = isset( $options['webhook_secret'] ) ? $options['webhook_secret'] : '';
		$placeholder = '' !== $value ? __( 'Saved. Enter a new value to replace it.', 'chatsell-widgets' ) : '';

		echo '<input type="password" id="cswidgets-webhook_secret" name="cswidgets_options[webhook_secret]" value="" class="regular-text" autocomplete="new-password" placeholder="' . esc_attr( $placeholder ) . '" />';
		echo '<p class="description">' . esc_html__( 'Required for real-time sync. Generate in ChatSell dashboard (Site → Settings → Webhook secret). Keep secret; regenerate if leaked.', 'chatsell-widgets' ) . '</p>';
		if ( '' !== $value ) {
			echo '<p class="description">' . esc_html__( 'A webhook secret is already stored. Leave this blank to keep the current secret.', 'chatsell-widgets' ) . '</p>';
			echo '<label><input type="checkbox" name="cswidgets_options[clear_webhook_secret]" value="1" /> ' . esc_html__( 'Clear the stored webhook secret when saving.', 'chatsell-widgets' ) . '</label>';
		}
	}

	public function field_callback( $args ) {
		$options = $this->get_options();
		$field   = $args['field'];
		$value   = isset( $options[ $field ] ) ? $options[ $field ] : '';
		$input_id = 'cswidgets-' . sanitize_html_class( $field );

		switch ( $field ) {
			case 'chat_shortcode':
				$shortcode = $this->build_chat_shortcode();
				echo '<div class="cswidgets-shortcode-wrap">';
				echo '<p class="description">' . esc_html__( 'Copy this shortcode to show chat in a page or post.', 'chatsell-widgets' ) . '</p>';
				echo '<div class="cswidgets-shortcode-row">';
				echo '<code id="cswidgets-chat-shortcode-text" data-shortcode="' . esc_attr( $shortcode ) . '">' . esc_html( $shortcode ) . '</code>';
				echo '<button type="button" class="button" id="cswidgets-copy-chat-shortcode">' . esc_html__( 'Copy', 'chatsell-widgets' ) . '</button>';
				echo '</div></div>';
				break;

			case 'search_shortcode':
				$shortcode = $this->build_search_shortcode();
				echo '<div class="cswidgets-shortcode-wrap">';
				echo '<p class="description">' . esc_html__( 'Copy this shortcode to place product search in a page or post.', 'chatsell-widgets' ) . '</p>';
				echo '<div class="cswidgets-shortcode-row">';
				echo '<code id="cswidgets-search-shortcode-text" data-shortcode="' . esc_attr( $shortcode ) . '">' . esc_html( $shortcode ) . '</code>';
				echo '<button type="button" class="button" id="cswidgets-copy-search-shortcode">' . esc_html__( 'Copy', 'chatsell-widgets' ) . '</button>';
				echo '</div></div>';
				break;

			case 'ask_shortcode':
				$shortcode = $this->build_ask_shortcode();
				echo '<div class="cswidgets-shortcode-wrap">';
				echo '<p class="description">' . esc_html__( 'Copy this shortcode to place Ask AI in a page or post.', 'chatsell-widgets' ) . '</p>';
				echo '<div class="cswidgets-shortcode-row">';
				echo '<code id="cswidgets-ask-shortcode-text" data-shortcode="' . esc_attr( $shortcode ) . '">' . esc_html( $shortcode ) . '</code>';
				echo '<button type="button" class="button" id="cswidgets-copy-ask-shortcode">' . esc_html__( 'Copy', 'chatsell-widgets' ) . '</button>';
				echo '</div></div>';
				break;

			case 'search_show_catalog_button':
				echo '<input type="checkbox" id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']" value="1" ' . checked( 1, $value, false ) . ' />';
				echo '<p class="description">' . esc_html__( 'Show a "Browse catalog" button inside the product search modal.', 'chatsell-widgets' ) . '</p>';
				break;

			case 'search_catalog_button_label':
				echo '<input type="text" id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']" value="' . esc_attr( $value ) . '" class="regular-text" placeholder="Browse catalog" />';
				echo '<p class="description">' . esc_html__( 'Label text for the catalog button shown in the search modal.', 'chatsell-widgets' ) . '</p>';
				break;

			case 'widget_locale':
				$value   = $this->get_widget_locale( $options );
				$locales = array(
					'auto' => __( 'Auto (detect visitor language)', 'chatsell-widgets' ),
					'en'   => 'English',
					'es'   => 'Español',
					'fr'   => 'Français',
					'de'   => 'Deutsch',
					'pt'   => 'Português',
					'it'   => 'Italiano',
					'nl'   => 'Nederlands',
					'pl'   => 'Polski',
					'tr'   => 'Türkçe',
					'ar'   => 'العربية',
				);
				echo '<select id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']">';
				foreach ( $locales as $locale_key => $locale_label ) {
					echo '<option value="' . esc_attr( $locale_key ) . '" ' . selected( $value ?: 'auto', $locale_key, false ) . '>' . esc_html( $locale_label ) . '</option>';
				}
				echo '</select>';
				echo '<p class="description">' . esc_html__( 'Language for the built-in texts of every ChatSell widget (chat, product search, catalog and Ask AI). Auto follows your ChatSell site language, then the page language and the visitor\'s browser. Texts you customize below are used as-is in every language.', 'chatsell-widgets' ) . '</p>';
				break;

			case 'chat_enabled':
			case 'chat_show_avatar':
			case 'chat_enable_sound':
			case 'search_enabled':
			case 'search_full_width':
			case 'ask_enabled':
				echo '<input type="checkbox" id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']" value="1" ' . checked( 1, $value, false ) . ' />';
				break;

			case 'chat_position':
				$positions = array(
					'bottom-right' => __( 'Bottom Right', 'chatsell-widgets' ),
					'bottom-left'  => __( 'Bottom Left', 'chatsell-widgets' ),
					'top-right'    => __( 'Top Right', 'chatsell-widgets' ),
					'top-left'     => __( 'Top Left', 'chatsell-widgets' ),
				);
				echo '<select id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']">';
				foreach ( $positions as $pos => $label ) {
					echo '<option value="' . esc_attr( $pos ) . '" ' . selected( $value, $pos, false ) . '>' . esc_html( $label ) . '</option>';
				}
				echo '</select>';
				break;

			case 'search_position':
				$positions = array(
					'top-right'    => __( 'Top Right', 'chatsell-widgets' ),
					'top-left'     => __( 'Top Left', 'chatsell-widgets' ),
					'bottom-right' => __( 'Bottom Right', 'chatsell-widgets' ),
					'bottom-left'  => __( 'Bottom Left', 'chatsell-widgets' ),
				);
				echo '<select id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']">';
				foreach ( $positions as $pos => $label ) {
					echo '<option value="' . esc_attr( $pos ) . '" ' . selected( $value, $pos, false ) . '>' . esc_html( $label ) . '</option>';
				}
				echo '</select>';
				break;

			case 'ask_position':
				$positions = array(
					'bottom-right' => __( 'Bottom Right', 'chatsell-widgets' ),
					'bottom-left'  => __( 'Bottom Left', 'chatsell-widgets' ),
					'top-right'    => __( 'Top Right', 'chatsell-widgets' ),
					'top-left'     => __( 'Top Left', 'chatsell-widgets' ),
				);
				echo '<select id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']">';
				foreach ( $positions as $pos => $label ) {
					echo '<option value="' . esc_attr( $pos ) . '" ' . selected( $value, $pos, false ) . '>' . esc_html( $label ) . '</option>';
				}
				echo '</select>';
				break;

			case 'chat_variants':
			case 'search_variants':
			case 'product_variants':
				if ( 'product_variants' === $field && empty( $value ) ) {
					$value = $this->get_product_variants( $options );
				}
				$variants = array(
					'none' => __( 'Exclude variations (default)', 'chatsell-widgets' ),
					'all'  => __( 'Include parent and variations', 'chatsell-widgets' ),
					'only' => __( 'Only variations', 'chatsell-widgets' ),
				);
				echo '<select id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']">';
				foreach ( $variants as $var => $label ) {
					echo '<option value="' . esc_attr( $var ) . '" ' . selected( $value, $var, false ) . '>' . esc_html( $label ) . '</option>';
				}
				echo '</select>';
				break;

			case 'search_mount_mode':
			case 'ask_mount_mode':
				$modes = array(
					'floating' => __( 'Floating (default)', 'chatsell-widgets' ),
					'inline'   => __( 'Inline (mount inside a container)', 'chatsell-widgets' ),
				);
				echo '<select id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']">';
				foreach ( $modes as $mode => $label ) {
					echo '<option value="' . esc_attr( $mode ) . '" ' . selected( $value, $mode, false ) . '>' . esc_html( $label ) . '</option>';
				}
				echo '</select>';
				break;

			case 'chat_primary_color':
			case 'search_primary_color':
			case 'chat_hover_color':
			case 'search_hover_color':
			case 'ask_primary_color':
				echo '<input type="color" id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']" value="' . esc_attr( $value ) . '" />';
				break;

			case 'search_limit':
				echo '<input type="number" id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']" value="' . esc_attr( $value ) . '" min="1" max="50" class="small-text" />';
				break;

			case 'search_container_selector':
			case 'ask_container_selector':
				echo '<input type="text" id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']" value="' . esc_attr( $value ) . '" class="regular-text" placeholder="#search-container" />';
				echo '<p class="description">' . esc_html__( 'CSS selector for the container element (only used in inline mode).', 'chatsell-widgets' ) . '</p>';
				break;

			case 'ask_button_label':
				echo '<input type="text" id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']" value="' . esc_attr( $value ) . '" class="regular-text" placeholder="Ask AI" />';
				break;

			case 'ask_placeholder':
				echo '<input type="text" id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']" value="' . esc_attr( $value ) . '" class="regular-text" placeholder="Ask a question..." />';
				break;

			case 'chat_variant_filters':
			case 'search_variant_filters':
			case 'product_variant_filters':
				if ( 'product_variant_filters' === $field && empty( $value ) ) {
					$value = $this->get_product_variant_filters( $options );
				}
				echo '<input type="text" id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']" value="' . esc_attr( $value ) . '" class="regular-text" placeholder="location=calgary,user=retail" />';
				echo '<p class="description">' . esc_html__( 'Comma-separated key=value pairs for filtering product variants. Shared by Ask AI and Product Search when configured here.', 'chatsell-widgets' ) . '</p>';
				break;

			case 'chat_dynamic_params':
			case 'search_dynamic_params':
			case 'product_dynamic_params':
				if ( 'product_dynamic_params' === $field && empty( $value ) ) {
					$value = $this->get_product_dynamic_params( $options );
				}
				$converted_value = $this->convert_simple_to_json( $value );
				echo '<div class="cswidgets-dynamic-params-container">';
				echo '<div class="cswidgets-param-inputs">';
				echo '<h4>' . esc_html__( 'Simple Format (Recommended)', 'chatsell-widgets' ) . '</h4>';
				echo '<textarea name="cswidgets_options[' . esc_attr( $field ) . '_simple]" rows="4" cols="50" class="large-text" placeholder="location=Calgary,userType@cookie=premium" id="' . esc_attr( $field ) . '_simple">' . esc_textarea( $this->get_simple_format( $value ) ) . '</textarea>';
				echo '<p class="description">' . esc_html__( 'Enter parameters as: key=value,key2=value2. Use @source for different storage:', 'chatsell-widgets' ) . '</p>';
				echo '<ul class="description">';
				echo '<li><code>location=Calgary</code> - ' . esc_html__( 'localStorage (default)', 'chatsell-widgets' ) . '</li>';
				echo '<li><code>userType@cookie=premium</code> - ' . esc_html__( 'from cookies', 'chatsell-widgets' ) . '</li>';
				echo '<li><code>sessionId@session=abc123</code> - ' . esc_html__( 'from sessionStorage', 'chatsell-widgets' ) . '</li>';
				echo '</ul>';
				echo '<button type="button" class="button" data-field="' . esc_attr( $field ) . '" onclick="cswidgetsConvertToJson(' . esc_js( "'" . $field . "'" ) . ')">' . esc_html__( 'Convert to JSON', 'chatsell-widgets' ) . '</button>';
				echo '</div>';
				echo '<div class="cswidgets-json-input">';
				echo '<h4>' . esc_html__( 'Advanced JSON Format', 'chatsell-widgets' ) . '</h4>';
				echo '<textarea name="cswidgets_options[' . esc_attr( $field ) . ']" rows="6" cols="50" class="large-text" id="' . esc_attr( $field ) . '">' . esc_textarea( $converted_value ) . '</textarea>';
				echo '<p class="description">' . esc_html__( 'Advanced JSON configuration for different storage sources.', 'chatsell-widgets' ) . '</p>';
				if ( 'product_dynamic_params' === $field ) {
					echo '<p class="description">' . esc_html__( 'These dynamic parameters are sent with both Ask AI product questions and Product Search.', 'chatsell-widgets' ) . '</p>';
				}
				echo '</div>';
				echo '</div>';
				break;

			default:
				echo '<input type="text" id="' . esc_attr( $input_id ) . '" name="cswidgets_options[' . esc_attr( $field ) . ']" value="' . esc_attr( $value ) . '" class="regular-text" />';
				break;
		}
	}

	public function admin_page() {
		$options       = $this->get_options();
		$is_linked     = $this->is_linked();
		$chat_color    = sanitize_hex_color( $options['chat_primary_color'] );
		$search_color  = sanitize_hex_color( $options['search_primary_color'] );
		$ask_color     = sanitize_hex_color( $options['ask_primary_color'] );
		$widget_locale = $this->get_widget_locale( $options );

		$chat_color   = $chat_color ? $chat_color : '#173f35';
		$search_color = $search_color ? $search_color : '#2563eb';
		$ask_color    = $ask_color ? $ask_color : '#3b82f6';
		?>
		<div class="wrap cswidgets-admin">
			<header class="cswidgets-page-header">
				<div class="cswidgets-brand-mark" aria-hidden="true">
					<span class="dashicons dashicons-format-chat"></span>
				</div>
				<div>
					<p class="cswidgets-eyebrow"><?php esc_html_e( 'ChatSell for WordPress', 'chatsell-widgets' ); ?></p>
					<h1><?php esc_html_e( 'Customer experience settings', 'chatsell-widgets' ); ?></h1>
					<p><?php esc_html_e( 'Connect your store, choose what shoppers see, and preview each widget before saving.', 'chatsell-widgets' ); ?></p>
				</div>
			</header>

			<?php
			$this->render_admin_notice();
			$this->render_connection_panel();
			?>

			<form method="post" action="options.php" id="cswidgets-settings-form">
				<?php settings_fields( 'cswidgets_settings' ); ?>

				<div class="cswidgets-editor">
					<nav class="cswidgets-tabs" role="tablist" aria-label="<?php esc_attr_e( 'ChatSell settings', 'chatsell-widgets' ); ?>">
						<button type="button" role="tab" aria-selected="true" aria-controls="cswidgets-panel-general" id="cswidgets-tab-general" data-cswidgets-tab="general">
							<span class="dashicons dashicons-store" aria-hidden="true"></span>
							<span><strong><?php esc_html_e( 'Store & sync', 'chatsell-widgets' ); ?></strong><small><?php esc_html_e( 'Language and catalogue freshness', 'chatsell-widgets' ); ?></small></span>
						</button>
						<button type="button" role="tab" aria-selected="false" aria-controls="cswidgets-panel-chat" id="cswidgets-tab-chat" data-cswidgets-tab="chat" tabindex="-1">
							<span class="dashicons dashicons-format-chat" aria-hidden="true"></span>
							<span><strong><?php esc_html_e( 'Chat', 'chatsell-widgets' ); ?></strong><small><?php esc_html_e( 'Conversation and appearance', 'chatsell-widgets' ); ?></small></span>
						</button>
						<button type="button" role="tab" aria-selected="false" aria-controls="cswidgets-panel-search" id="cswidgets-tab-search" data-cswidgets-tab="search" tabindex="-1">
							<span class="dashicons dashicons-search" aria-hidden="true"></span>
							<span><strong><?php esc_html_e( 'Product search', 'chatsell-widgets' ); ?></strong><small><?php esc_html_e( 'Search bar and results', 'chatsell-widgets' ); ?></small></span>
						</button>
						<button type="button" role="tab" aria-selected="false" aria-controls="cswidgets-panel-ask" id="cswidgets-tab-ask" data-cswidgets-tab="ask" tabindex="-1">
							<span class="dashicons dashicons-lightbulb" aria-hidden="true"></span>
							<span><strong><?php esc_html_e( 'Ask AI', 'chatsell-widgets' ); ?></strong><small><?php esc_html_e( 'Knowledge-base questions', 'chatsell-widgets' ); ?></small></span>
						</button>
						<button type="button" role="tab" aria-selected="false" aria-controls="cswidgets-panel-product-discovery" id="cswidgets-tab-product-discovery" data-cswidgets-tab="product-discovery" tabindex="-1">
							<span class="dashicons dashicons-filter" aria-hidden="true"></span>
							<span><strong><?php esc_html_e( 'Product matching', 'chatsell-widgets' ); ?></strong><small><?php esc_html_e( 'Variants and visitor context', 'chatsell-widgets' ); ?></small></span>
						</button>
					</nav>

					<div class="cswidgets-workspace">
						<div class="cswidgets-panels">
							<section class="cswidgets-panel" id="cswidgets-panel-general" role="tabpanel" aria-labelledby="cswidgets-tab-general" data-cswidgets-panel="general">
								<?php do_settings_sections( 'chatsell-widgets-general' ); ?>
								<details class="cswidgets-advanced"<?php echo $is_linked ? '' : ' open'; ?>>
									<summary>
										<span class="dashicons dashicons-admin-tools" aria-hidden="true"></span>
										<span><strong><?php esc_html_e( 'Advanced connection settings', 'chatsell-widgets' ); ?></strong><small><?php esc_html_e( 'Site ID, service addresses, and webhook secret', 'chatsell-widgets' ); ?></small></span>
									</summary>
									<div class="cswidgets-advanced-body"><?php do_settings_sections( 'chatsell-widgets-advanced' ); ?></div>
								</details>
							</section>

							<section class="cswidgets-panel" id="cswidgets-panel-chat" role="tabpanel" aria-labelledby="cswidgets-tab-chat" data-cswidgets-panel="chat" hidden>
								<?php do_settings_sections( 'chatsell-widgets-chat' ); ?>
							</section>

							<section class="cswidgets-panel" id="cswidgets-panel-search" role="tabpanel" aria-labelledby="cswidgets-tab-search" data-cswidgets-panel="search" hidden>
								<?php do_settings_sections( 'chatsell-widgets-search' ); ?>
							</section>

							<section class="cswidgets-panel" id="cswidgets-panel-ask" role="tabpanel" aria-labelledby="cswidgets-tab-ask" data-cswidgets-panel="ask" hidden>
								<?php do_settings_sections( 'chatsell-widgets-ask' ); ?>
							</section>

							<section class="cswidgets-panel" id="cswidgets-panel-product-discovery" role="tabpanel" aria-labelledby="cswidgets-tab-product-discovery" data-cswidgets-panel="product-discovery" hidden>
								<?php do_settings_sections( 'chatsell-widgets-product-discovery' ); ?>
							</section>
						</div>

						<aside class="cswidgets-preview" aria-label="<?php esc_attr_e( 'Live widget preview', 'chatsell-widgets' ); ?>">
							<div class="cswidgets-preview-head">
								<span><span class="dashicons dashicons-visibility" aria-hidden="true"></span><?php esc_html_e( 'Preview', 'chatsell-widgets' ); ?></span>
								<span class="cswidgets-live-badge"><i></i><?php esc_html_e( 'Live', 'chatsell-widgets' ); ?></span>
							</div>
							<div class="cswidgets-preview-browser">
								<div class="cswidgets-browser-bar" aria-hidden="true"><i></i><i></i><i></i><span><?php echo esc_html( wp_parse_url( home_url(), PHP_URL_HOST ) ); ?></span></div>
								<div class="cswidgets-preview-stage">
									<div class="cswidgets-preview-scene cswidgets-preview-overview" data-cswidgets-preview="general">
										<span class="dashicons dashicons-store" aria-hidden="true"></span>
										<p><?php esc_html_e( 'Your storefront is ready', 'chatsell-widgets' ); ?></p>
										<div><span><?php esc_html_e( 'Widget language', 'chatsell-widgets' ); ?></span><strong id="cswidgets-preview-locale"><?php echo esc_html( strtoupper( $widget_locale ?: 'auto' ) ); ?></strong></div>
										<div><span><?php esc_html_e( 'Real-time sync', 'chatsell-widgets' ); ?></span><strong id="cswidgets-preview-sync"><?php echo ! empty( $options['realtime_sync_enabled'] ) ? esc_html__( 'On', 'chatsell-widgets' ) : esc_html__( 'Off', 'chatsell-widgets' ); ?></strong></div>
									</div>

									<div class="cswidgets-preview-scene cswidgets-chat-preview" data-cswidgets-preview="chat" hidden>
										<div class="cswidgets-widget-card" id="cswidgets-chat-preview-card" data-position="<?php echo esc_attr( $options['chat_position'] ); ?>">
											<div class="cswidgets-widget-head" id="cswidgets-preview-chat-head" style="background-color:<?php echo esc_attr( $chat_color ); ?>">
												<span class="cswidgets-preview-avatar dashicons dashicons-format-chat" id="cswidgets-preview-avatar" aria-hidden="true"></span>
												<span><strong id="cswidgets-preview-chat-title"><?php echo esc_html( $options['chat_title'] ); ?></strong><small id="cswidgets-preview-chat-subtitle"><?php echo esc_html( $options['chat_subtitle'] ); ?></small></span>
											</div>
											<div class="cswidgets-widget-conversation"><p id="cswidgets-preview-chat-message"><?php echo esc_html( $options['chat_welcome_message'] ); ?></p></div>
											<div class="cswidgets-widget-input"><span id="cswidgets-preview-chat-placeholder"><?php echo esc_html( $options['chat_placeholder'] ); ?></span><i style="background-color:<?php echo esc_attr( $chat_color ); ?>">&#8593;</i></div>
										</div>
									</div>

									<div class="cswidgets-preview-scene cswidgets-search-preview" data-cswidgets-preview="search" hidden>
										<div class="cswidgets-search-preview-window">
											<div class="cswidgets-search-preview-bar"><span class="dashicons dashicons-search" aria-hidden="true"></span><span id="cswidgets-preview-search-placeholder"><?php echo esc_html( $options['search_placeholder'] ); ?></span></div>
											<p><?php esc_html_e( 'Suggested products', 'chatsell-widgets' ); ?></p>
											<div class="cswidgets-search-results" aria-hidden="true"><i></i><i></i><i></i></div>
											<button type="button" id="cswidgets-preview-catalog-button" style="background-color:<?php echo esc_attr( $search_color ); ?>"><?php echo esc_html( $options['search_catalog_button_label'] ); ?></button>
										</div>
									</div>

									<div class="cswidgets-preview-scene cswidgets-ask-preview" data-cswidgets-preview="ask" hidden>
										<div class="cswidgets-ask-preview-window">
											<span class="dashicons dashicons-lightbulb" aria-hidden="true" style="color:<?php echo esc_attr( $ask_color ); ?>"></span>
											<strong id="cswidgets-preview-ask-label"><?php echo esc_html( $options['ask_button_label'] ); ?></strong>
											<p><?php esc_html_e( 'Answers from your store knowledge', 'chatsell-widgets' ); ?></p>
											<div><span id="cswidgets-preview-ask-placeholder"><?php echo esc_html( $options['ask_placeholder'] ); ?></span><i style="background-color:<?php echo esc_attr( $ask_color ); ?>">&#8593;</i></div>
										</div>
									</div>

									<div class="cswidgets-preview-scene cswidgets-discovery-preview" data-cswidgets-preview="product-discovery" hidden>
										<span class="dashicons dashicons-filter" aria-hidden="true"></span>
										<p><?php esc_html_e( 'Product matching rules', 'chatsell-widgets' ); ?></p>
										<div><span><?php esc_html_e( 'Variants', 'chatsell-widgets' ); ?></span><strong id="cswidgets-preview-variants"><?php echo esc_html( ucfirst( $this->get_product_variants( $options ) ) ); ?></strong></div>
										<div><span><?php esc_html_e( 'Visitor filters', 'chatsell-widgets' ); ?></span><strong id="cswidgets-preview-filters"><?php echo $this->get_product_variant_filters( $options ) ? esc_html__( 'Configured', 'chatsell-widgets' ) : esc_html__( 'None', 'chatsell-widgets' ); ?></strong></div>
									</div>
								</div>
							</div>
							<p class="cswidgets-preview-note"><?php esc_html_e( 'Preview updates as you edit. Save changes to publish them.', 'chatsell-widgets' ); ?></p>
						</aside>
					</div>

					<div class="cswidgets-save-bar">
						<span><span class="dashicons dashicons-saved" aria-hidden="true"></span><?php esc_html_e( 'Changes apply to every automatically embedded widget.', 'chatsell-widgets' ); ?></span>
						<?php submit_button( __( 'Save changes', 'chatsell-widgets' ), 'primary', 'submit', false ); ?>
					</div>
				</div>
			</form>
		</div>
		<?php
	}

	public function enqueue_scripts() {
		if ( is_admin() ) {
			return;
		}

		$options      = $this->get_options();
		$frontend_url = $this->get_service_url( $options, 'frontend_url' );

		if ( $options['chat_enabled'] ) {
			wp_enqueue_script( 'cswidgets-widget', $frontend_url . '/widget.js', array(), CSWIDGETS_VERSION, true );
		}
		if ( $options['search_enabled'] ) {
			wp_enqueue_script( 'cswidgets-search', $frontend_url . '/product-search.js', array(), CSWIDGETS_VERSION, true );
		}
		if ( ! empty( $options['ask_enabled'] ) && empty( $options['search_enabled'] ) ) {
			wp_enqueue_script( 'cswidgets-ask', $frontend_url . '/kb-ask.js', array(), CSWIDGETS_VERSION, true );
		}
	}

	public function render_widgets() {
		$options = $this->get_options();

		if ( ! $options['site_id'] ) {
			return;
		}

		if ( $options['chat_enabled'] ) {
			$this->render_chat_widget( $options );
		}

		if ( $options['search_enabled'] ) {
			$this->render_search_widget( $options );
		}

		if ( ! empty( $options['ask_enabled'] ) && empty( $options['search_enabled'] ) ) {
			$this->render_ask_widget( $options );
		}
	}

	private function render_chat_widget( $options ) {
		$api_url = $this->get_service_url( $options, 'api_url' );
		$config = array(
			'siteId'       => $options['site_id'],
			'variants'     => $options['chat_variants'],
			'variantFilters' => $options['chat_variant_filters'],
			'primaryColor' => $options['chat_primary_color'],
			'hoverColor'   => ! empty( $options['chat_hover_color'] ) ? $options['chat_hover_color'] : null,
			'position'     => $options['chat_position'],
			'showAvatar'   => (bool) $options['chat_show_avatar'],
			'enableSound'  => (bool) $options['chat_enable_sound'],
			'apiUrl'       => $api_url,
		);

		$config = $this->apply_widget_locale( $config, $options );

		// Only pass texts the merchant actually changed from the (English)
		// defaults — untouched defaults are omitted so the widget falls back
		// to its translated catalog for the resolved locale.
		$defaults = $this->get_default_options();
		$text_map = array(
			'chat_title'           => 'title',
			'chat_subtitle'        => 'subtitle',
			'chat_welcome_message' => 'welcomeMessage',
			'chat_placeholder'     => 'placeholder',
		);
		foreach ( $text_map as $option_key => $config_key ) {
			if ( isset( $options[ $option_key ] ) && $this->is_customized_text( $options[ $option_key ], $defaults[ $option_key ] ) ) {
				$config[ $config_key ] = $options[ $option_key ];
			}
		}

		$dynamic_params = $this->parse_dynamic_params( $options['chat_dynamic_params'] );
		if ( ! empty( $dynamic_params ) ) {
			$config['variantFilterFromStorage'] = $dynamic_params;
		}

		wp_add_inline_script( 'cswidgets-widget', 'window.chatbotConfig = ' . $this->json_encode_for_script( $config ) . ';', 'before' );
	}

	private function render_search_widget( $options ) {
		$api_url = $this->get_service_url( $options, 'api_url' );
		$config = array(
			'siteId'             => $options['site_id'],
			'apiUrl'             => $api_url,
			'variants'           => $this->get_product_variants( $options ),
			'variantFilters'     => $this->get_product_variant_filters( $options ),
			'limit'              => (int) $options['search_limit'],
			'position'           => $options['search_position'],
			'primaryColor'       => $options['search_primary_color'],
			'hoverColor'         => ! empty( $options['search_hover_color'] ) ? $options['search_hover_color'] : null,
			'showCatalogButton'  => (bool) $options['search_show_catalog_button'],
			'showAskAiButton'    => ! empty( $options['ask_enabled'] ),
			'askAiPrimaryColor'  => $options['ask_primary_color'],
			'askAiScriptUrl'     => $this->get_service_url( $options, 'frontend_url' ) . '/kb-ask.js',
			'mountMode'          => $options['search_mount_mode'],
			'containerSelector'  => $options['search_container_selector'],
			'fullWidth'          => (bool) $options['search_full_width'],
		);

		// The search widget hands its locale down to the catalog and Ask AI
		// widgets it opens, so this covers all three.
		$config = $this->apply_product_display_settings( $config, $this->get_widget_locale( $options ) );

		$defaults = $this->get_default_options();
		$text_map = array(
			'search_placeholder'          => 'placeholder',
			'search_catalog_button_label' => 'catalogButtonLabel',
			'ask_button_label'            => 'askAiButtonLabel',
			'ask_placeholder'             => 'askAiPlaceholder',
		);
		foreach ( $text_map as $option_key => $config_key ) {
			if ( isset( $options[ $option_key ] ) && $this->is_customized_text( $options[ $option_key ], $defaults[ $option_key ] ) ) {
				$config[ $config_key ] = $options[ $option_key ];
			}
		}

		$dynamic_params = $this->parse_dynamic_params( $this->get_product_dynamic_params( $options ) );
		if ( ! empty( $dynamic_params ) ) {
			$config['variantFilterFromStorage'] = $dynamic_params;
		}

		$inline_js  = 'window.productSearchConfig = ' . $this->json_encode_for_script( $config ) . ';';
		$inline_js .= "\nwindow.productSearchConfigs = window.productSearchConfigs || [];";
		$inline_js .= "\nwindow.productSearchConfigs.push(window.productSearchConfig);";
		wp_add_inline_script( 'cswidgets-search', $inline_js, 'before' );
	}

	private function render_ask_widget( $options ) {
		$api_url = $this->get_service_url( $options, 'api_url' );
		$config = array(
			'siteId'            => $options['site_id'],
			'apiUrl'            => $api_url,
			'primaryColor'      => $options['ask_primary_color'],
			'position'          => $options['ask_position'],
			'mountMode'         => $options['ask_mount_mode'],
			'containerSelector' => $options['ask_container_selector'],
			'variants'          => $this->get_product_variants( $options ),
			'variantFilters'    => $this->get_product_variant_filters( $options ),
		);

		$config = $this->apply_widget_locale( $config, $options );

		$defaults = $this->get_default_options();
		$text_map = array(
			'ask_button_label' => 'buttonLabel',
			'ask_placeholder'  => 'placeholder',
		);
		foreach ( $text_map as $option_key => $config_key ) {
			if ( isset( $options[ $option_key ] ) && $this->is_customized_text( $options[ $option_key ], $defaults[ $option_key ] ) ) {
				$config[ $config_key ] = $options[ $option_key ];
			}
		}

		$dynamic_params = $this->parse_dynamic_params( $this->get_product_dynamic_params( $options ) );
		if ( ! empty( $dynamic_params ) ) {
			$config['variantFilterFromStorage'] = $dynamic_params;
		}

		$inline_js  = 'window.kbAskConfig = ' . $this->json_encode_for_script( $config ) . ';';
		$inline_js .= "\nwindow.kbAskConfigs = window.kbAskConfigs || [];";
		$inline_js .= "\nwindow.kbAskConfigs.push(window.kbAskConfig);";
		wp_add_inline_script( 'cswidgets-ask', $inline_js, 'before' );
	}

	public function chat_shortcode( $atts ) {
		$options = $this->get_options();
			$raw_atts = is_array( $atts ) ? $atts : array();
			$atts     = shortcode_atts(
			array(
				'locale'          => $this->get_widget_locale( $options ),
				'title'           => $options['chat_title'],
				'subtitle'        => $options['chat_subtitle'],
				'position'        => $options['chat_position'],
				'primary_color'   => $options['chat_primary_color'],
				'hover_color'     => isset( $options['chat_hover_color'] ) ? $options['chat_hover_color'] : '',
				'show_avatar'     => $options['chat_show_avatar'],
				'enable_sound'    => $options['chat_enable_sound'],
				'welcome_message' => $options['chat_welcome_message'],
				'placeholder'     => $options['chat_placeholder'],
				'variants'        => $options['chat_variants'],
				'variant_filters' => $options['chat_variant_filters'],
				'dynamic_params'  => $options['chat_dynamic_params'],
			),
			$atts
		);

		if ( ! $options['site_id'] ) {
			return '<p>' . esc_html__( 'ChatSell: Please configure your Site ID in the admin settings.', 'chatsell-widgets' ) . '</p>';
		}

		$config = array(
			'siteId'         => $options['site_id'],
			'variants'       => $atts['variants'],
			'variantFilters' => $atts['variant_filters'],
			'primaryColor'   => $atts['primary_color'],
			'hoverColor'     => ! empty( $atts['hover_color'] ) ? $atts['hover_color'] : null,
			'position'       => $atts['position'],
			'showAvatar'     => (bool) $atts['show_avatar'],
			'enableSound'    => (bool) $atts['enable_sound'],
			'apiUrl'         => $this->get_service_url( $options, 'api_url' ),
		);

		$config = $this->apply_locale( $config, $atts['locale'] );

		$defaults = $this->get_default_options();
		$text_map = array(
			'title'           => array( 'title', 'chat_title' ),
			'subtitle'        => array( 'subtitle', 'chat_subtitle' ),
			'welcome_message' => array( 'welcomeMessage', 'chat_welcome_message' ),
			'placeholder'     => array( 'placeholder', 'chat_placeholder' ),
		);
		foreach ( $text_map as $att_key => $keys ) {
			if ( $this->is_customized_text( $atts[ $att_key ], $defaults[ $keys[1] ] ) ) {
				$config[ $keys[0] ] = $atts[ $att_key ];
			}
		}

		$dynamic_params = $this->get_shortcode_dynamic_params( $raw_atts, $options['chat_dynamic_params'] );
		if ( ! empty( $dynamic_params ) ) {
			$config['variantFilterFromStorage'] = $dynamic_params;
		}

		$frontend_url = $this->get_service_url( $options, 'frontend_url' );
		wp_enqueue_script( 'cswidgets-widget', $frontend_url . '/widget.js', array(), CSWIDGETS_VERSION, true );
		wp_add_inline_script( 'cswidgets-widget', 'window.chatbotConfig = ' . $this->json_encode_for_script( $config ) . ';', 'before' );

		return '';
	}

	public function search_shortcode( $atts ) {
		$options = $this->get_options();
			$raw_atts = is_array( $atts ) ? $atts : array();
			$atts     = shortcode_atts(
			array(
				'locale'              => $this->get_widget_locale( $options ),
				'position'            => $options['search_position'],
				'primary_color'       => $options['search_primary_color'],
				'hover_color'         => isset( $options['search_hover_color'] ) ? $options['search_hover_color'] : '',
				'placeholder'         => $options['search_placeholder'],
				'limit'               => $options['search_limit'],
				'variants'            => $this->get_product_variants( $options ),
				'variant_filters'     => $this->get_product_variant_filters( $options ),
				'dynamic_params'      => $this->get_product_dynamic_params( $options ),
				'show_catalog_button' => $options['search_show_catalog_button'],
				'catalog_button_label' => $options['search_catalog_button_label'],
				'show_ask_ai_button'  => ! empty( $options['ask_enabled'] ) ? '1' : '0',
				'ask_ai_button_label' => $options['ask_button_label'],
				'ask_ai_placeholder'  => $options['ask_placeholder'],
				'mount_mode'          => '',
				'container_selector'  => '',
				'full_width'          => '',
			),
			$atts
		);

		if ( ! $options['site_id'] ) {
			return '<p>' . esc_html__( 'ChatSell: Please configure your Site ID in the admin settings.', 'chatsell-widgets' ) . '</p>';
		}

		$container_id = '';
		if ( empty( trim( (string) $atts['container_selector'] ) ) ) {
			$container_id              = 'cswidgets-search-' . uniqid();
			$atts['mount_mode']        = 'inline';
			$atts['container_selector'] = '#' . $container_id;
			$atts['full_width']        = '1';
		}

		$show_catalog_button = ! in_array( strtolower( trim( (string) $atts['show_catalog_button'] ) ), array( '0', 'false', 'no', 'off' ), true );
		$show_ask_ai_button  = ! in_array( strtolower( trim( (string) $atts['show_ask_ai_button'] ) ), array( '0', 'false', 'no', 'off' ), true );

		$config = array(
			'siteId'             => $options['site_id'],
			'apiUrl'             => $this->get_service_url( $options, 'api_url' ),
			'variants'           => $atts['variants'],
			'variantFilters'     => $atts['variant_filters'],
			'limit'              => (int) $atts['limit'],
			'position'           => $atts['position'],
			'primaryColor'       => $atts['primary_color'],
			'hoverColor'         => ! empty( $atts['hover_color'] ) ? $atts['hover_color'] : null,
			'showCatalogButton'  => $show_catalog_button,
			'showAskAiButton'    => $show_ask_ai_button,
			'askAiPrimaryColor'  => $options['ask_primary_color'],
			'askAiScriptUrl'     => $this->get_service_url( $options, 'frontend_url' ) . '/kb-ask.js',
			'mountMode'          => $atts['mount_mode'],
			'containerSelector'  => $atts['container_selector'],
			'fullWidth'          => '1' === $atts['full_width'] || 'true' === $atts['full_width'],
		);

		$config = $this->apply_product_display_settings( $config, $atts['locale'] );

		$defaults = $this->get_default_options();
		$text_map = array(
			'placeholder'          => array( 'placeholder', 'search_placeholder' ),
			'catalog_button_label' => array( 'catalogButtonLabel', 'search_catalog_button_label' ),
			'ask_ai_button_label'  => array( 'askAiButtonLabel', 'ask_button_label' ),
			'ask_ai_placeholder'   => array( 'askAiPlaceholder', 'ask_placeholder' ),
		);
		foreach ( $text_map as $att_key => $keys ) {
			if ( $this->is_customized_text( $atts[ $att_key ], $defaults[ $keys[1] ] ) ) {
				$config[ $keys[0] ] = trim( (string) $atts[ $att_key ] );
			}
		}

		$dynamic_params = $this->get_shortcode_dynamic_params( $raw_atts, $this->get_product_dynamic_params( $options ) );
		if ( ! empty( $dynamic_params ) ) {
			$config['variantFilterFromStorage'] = $dynamic_params;
		}

		$frontend_url = $this->get_service_url( $options, 'frontend_url' );
		wp_enqueue_script( 'cswidgets-search', $frontend_url . '/product-search.js', array(), CSWIDGETS_VERSION, true );

		$inline_js  = 'window.productSearchConfig = ' . $this->json_encode_for_script( $config ) . ';';
		$inline_js .= "\nwindow.productSearchConfigs = window.productSearchConfigs || [];";
		$inline_js .= "\nwindow.productSearchConfigs.push(window.productSearchConfig);";
		wp_add_inline_script( 'cswidgets-search', $inline_js, 'before' );

		if ( '' !== $container_id ) {
			return '<div id="' . esc_attr( $container_id ) . '" class="cswidgets-search-shortcode-container"></div>';
		}
		return '';
	}

	public function ask_shortcode( $atts ) {
		$options = $this->get_options();
		$raw_atts = is_array( $atts ) ? $atts : array();
		$atts    = shortcode_atts(
			array(
				'locale'             => $this->get_widget_locale( $options ),
				'position'           => $options['ask_position'],
				'primary_color'      => $options['ask_primary_color'],
				'button_label'       => $options['ask_button_label'],
				'placeholder'        => $options['ask_placeholder'],
				'variants'           => $this->get_product_variants( $options ),
				'variant_filters'    => $this->get_product_variant_filters( $options ),
				'dynamic_params'     => $this->get_product_dynamic_params( $options ),
				'mount_mode'         => '',
				'container_selector' => '',
			),
			$atts
		);

		if ( ! $options['site_id'] ) {
			return '<p>' . esc_html__( 'ChatSell: Please configure your Site ID in the admin settings.', 'chatsell-widgets' ) . '</p>';
		}

		$container_id = '';
		if ( empty( trim( (string) $atts['container_selector'] ) ) ) {
			$container_id              = 'cswidgets-ask-' . uniqid();
			$atts['mount_mode']        = 'inline';
			$atts['container_selector'] = '#' . $container_id;
		}

		$config = array(
			'siteId'            => $options['site_id'],
			'apiUrl'            => $this->get_service_url( $options, 'api_url' ),
			'primaryColor'      => $atts['primary_color'],
			'position'          => $atts['position'],
			'mountMode'         => $atts['mount_mode'],
			'containerSelector' => $atts['container_selector'],
			'variants'          => $atts['variants'],
			'variantFilters'    => $atts['variant_filters'],
		);

		$config = $this->apply_locale( $config, $atts['locale'] );

		$defaults = $this->get_default_options();
		$text_map = array(
			'button_label' => array( 'buttonLabel', 'ask_button_label' ),
			'placeholder'  => array( 'placeholder', 'ask_placeholder' ),
		);
		foreach ( $text_map as $att_key => $keys ) {
			if ( $this->is_customized_text( $atts[ $att_key ], $defaults[ $keys[1] ] ) ) {
				$config[ $keys[0] ] = $atts[ $att_key ];
			}
		}

		$dynamic_params = $this->get_shortcode_dynamic_params( $raw_atts, $this->get_product_dynamic_params( $options ) );
		if ( ! empty( $dynamic_params ) ) {
			$config['variantFilterFromStorage'] = $dynamic_params;
		}

		$frontend_url = $this->get_service_url( $options, 'frontend_url' );
		wp_enqueue_script( 'cswidgets-ask', $frontend_url . '/kb-ask.js', array(), CSWIDGETS_VERSION, true );

		$inline_js  = 'window.kbAskConfig = ' . $this->json_encode_for_script( $config ) . ';';
		$inline_js .= "\nwindow.kbAskConfigs = window.kbAskConfigs || [];";
		$inline_js .= "\nwindow.kbAskConfigs.push(window.kbAskConfig);";
		wp_add_inline_script( 'cswidgets-ask', $inline_js, 'before' );

		if ( '' !== $container_id ) {
			return '<div id="' . esc_attr( $container_id ) . '" class="cswidgets-ask-shortcode-container"></div>';
		}
		return '';
	}

	public function activate() {
		$this->migrate_legacy_options();

		if ( false === get_option( 'cswidgets_options', false ) ) {
			add_option( 'cswidgets_options', $this->get_default_options() );
		}
	}

	public function deactivate() {
		// The sync hooks schedule single events 2 seconds out. Without this they
		// outlive deactivation and fire against a plugin that is no longer loaded.
		wp_clear_scheduled_hook( 'cswidgets_sync_product_delayed' );
		wp_clear_scheduled_hook( 'cswidgets_sync_knowledge_source_delayed' );
	}

	public function sync_product_saved( $post_id, $post, $update ) {
		if ( 'product' !== $post->post_type ) {
			return;
		}
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}
		if ( wp_is_post_revision( $post_id ) ) {
			return;
		}

		$action = $update ? 'update' : 'create';
		wp_schedule_single_event( time() + 2, 'cswidgets_sync_product_delayed', array( $post_id, $action ) );
		$this->sync_product( $post_id, $action );
	}

	public function sync_product_delayed( $product_id, $action ) {
		$this->sync_product( $product_id, $action );
	}

	public function sync_product_variation_saved( $variation_id, $loop_index ) {
		$this->sync_parent_product_for_variation( $variation_id );
	}

	public function sync_product_variation_updated( $variation_id ) {
		$this->sync_parent_product_for_variation( $variation_id );
	}

	private function sync_parent_product_for_variation( $variation_id ) {
		$parent_id = wp_get_post_parent_id( $variation_id );
		if ( ! $parent_id && function_exists( 'wc_get_product' ) ) {
			$variation = wc_get_product( $variation_id );
			if ( $variation && method_exists( $variation, 'get_parent_id' ) ) {
				$parent_id = $variation->get_parent_id();
			}
		}

		if ( ! $parent_id || 'product' !== get_post_type( $parent_id ) ) {
			return;
		}

		if ( ! wp_next_scheduled( 'cswidgets_sync_product_delayed', array( $parent_id, 'update' ) ) ) {
			wp_schedule_single_event( time() + 2, 'cswidgets_sync_product_delayed', array( $parent_id, 'update' ) );
		}

		static $synced_parents = array();
		if ( isset( $synced_parents[ $parent_id ] ) ) {
			return;
		}
		$synced_parents[ $parent_id ] = true;

		$this->sync_product( $parent_id, 'update' );
	}

	public function sync_product_deleted( $post_id ) {
		if ( get_post_type( $post_id ) !== 'product' ) {
			return;
		}
		$this->sync_product( $post_id, 'delete' );
	}

	private function is_knowledge_source_post_type( $post_type ) {
		return in_array( $post_type, array( 'page', 'post' ), true );
	}

	private function get_current_knowledge_source_action( $post_id, $fallback_action = 'update', $post = null ) {
		$current_post = get_post( $post_id );
		if ( ! $current_post && $post ) {
			$current_post = $post;
		}
		if ( ! $current_post || 'publish' !== $current_post->post_status ) {
			return 'delete';
		}
		return ( 'create' === $fallback_action ) ? 'create' : 'update';
	}

	public function sync_knowledge_source_saved( $post_id, $post, $update ) {
		if ( ! $post || empty( $post->post_type ) || ! $this->is_knowledge_source_post_type( $post->post_type ) ) {
			return;
		}
		if ( defined( 'DOING_AUTOSAVE' ) && DOING_AUTOSAVE ) {
			return;
		}
		if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) ) {
			return;
		}

		$current_post = get_post( $post_id );
		if ( ! $current_post || empty( $current_post->post_type ) || ! $this->is_knowledge_source_post_type( $current_post->post_type ) ) {
			return;
		}

		$action = $this->get_current_knowledge_source_action( $post_id, $update ? 'update' : 'create', $current_post );
		if ( ! wp_next_scheduled( 'cswidgets_sync_knowledge_source_delayed', array( $post_id, $action ) ) ) {
			wp_schedule_single_event( time() + 2, 'cswidgets_sync_knowledge_source_delayed', array( $post_id, $action ) );
		}
		$this->sync_knowledge_source( $post_id, $action );
	}

	public function sync_knowledge_source_delayed( $post_id, $action ) {
		$action = $this->get_current_knowledge_source_action( $post_id, $action );
		$this->sync_knowledge_source( $post_id, $action );
	}

	public function sync_knowledge_source_deleted( $post_id ) {
		$post_type = get_post_type( $post_id );
		if ( ! $this->is_knowledge_source_post_type( $post_type ) ) {
			return;
		}
		$this->sync_knowledge_source( $post_id, 'delete', $post_type );
	}

	private function sync_knowledge_source( $post_id, $action, $post_type = '' ) {
		if ( ! in_array( $action, array( 'create', 'update', 'delete' ), true ) ) {
			return;
		}

		$post = get_post( $post_id );
		if ( $post && empty( $post_type ) ) {
			$post_type = $post->post_type;
		}
		if ( ! $this->is_knowledge_source_post_type( $post_type ) ) {
			return;
		}

		$post_status = $post ? $post->post_status : '';
		if ( 'delete' !== $action && 'publish' !== $post_status ) {
			$action = 'delete';
		}

		$payload = array(
			'action'      => $action,
			'object_type' => 'knowledge_source',
			'source_type' => $post_type,
			'source_id'   => intval( $post_id ),
			'post_status' => $post_status,
		);

		if ( 'delete' !== $action && $post ) {
			$source_url  = get_permalink( $post_id );
			$modified_at = get_post_modified_time( 'c', true, $post_id );
			$created_at  = get_post_time( 'c', true, $post_id );
			$content     = apply_filters( 'the_content', $post->post_content );
			$excerpt     = apply_filters( 'the_excerpt', get_the_excerpt( $post ) );

			$payload['title']       = get_the_title( $post_id );
			$payload['content']     = is_string( $content ) ? $content : '';
			$payload['excerpt']     = is_string( $excerpt ) ? $excerpt : '';
			$payload['source_url']  = $source_url ? $source_url : '';
			$payload['slug']        = $post->post_name;
			$payload['modified_at'] = $modified_at ? $modified_at : '';
			$payload['date']        = $created_at ? $created_at : '';
		}

		$this->send_realtime_sync_webhook( $payload, 'knowledge' );
	}

	private function sync_product( $product_id, $action ) {
		if ( ! class_exists( 'WooCommerce' ) ) {
			return;
		}
		if ( ! in_array( $action, array( 'create', 'update', 'delete' ), true ) ) {
			return;
		}

		$this->send_realtime_sync_webhook(
			array(
				'action'     => $action,
				'product_id' => intval( $product_id ),
			)
		);
	}

	private function send_realtime_sync_webhook( $payload, $endpoint = 'product', $options = null ) {
		if ( null === $options ) {
			$options = $this->get_options();
		}
		if ( ! in_array( $endpoint, array( 'product', 'knowledge' ), true ) ) {
			return false;
		}

		if ( empty( $options['realtime_sync_enabled'] ) ) {
			return false;
		}
		if ( empty( $options['site_id'] ) || empty( $options['api_url'] ) ) {
			return false;
		}

		$api_url = $this->resolve_api_base( $options );

		$webhook_url  = $api_url . '/api/public/sites/' . rawurlencode( $options['site_id'] ) . '/webhook/' . rawurlencode( $endpoint );
		if ( ! $this->is_safe_webhook_url( $webhook_url ) ) {
			return false;
		}

		$payload_json = wp_json_encode( $payload );

		if ( false === $payload_json ) {
			return false;
		}

		$headers = array(
			'Content-Type' => 'application/json',
			'User-Agent'   => 'ChatSell-WordPress-Plugin/' . CSWIDGETS_VERSION,
		);

		$webhook_secret = isset( $options['webhook_secret'] ) ? trim( $options['webhook_secret'] ) : '';
		if ( '' !== $webhook_secret ) {
			$headers['X-ChatSell-Signature'] = hash_hmac( 'sha256', $payload_json, $webhook_secret );
		}

		$parsed_host          = wp_parse_url( $webhook_url, PHP_URL_HOST );
		$is_local             = $parsed_host && $this->is_local_service_host( $parsed_host );
		wp_remote_post(
			$webhook_url,
			array(
				'method'             => 'POST',
				'timeout'            => 5,
				'headers'            => $headers,
				'body'               => $payload_json,
				'blocking'           => false,
				'redirection'        => 0,
				'reject_unsafe_urls' => ! $is_local,
			)
		);

		return true;
	}

	/* ---------------------------------------------------------------------
	 * Store connect
	 *
	 * Linking a store used to mean the merchant copying a site UUID and a
	 * webhook secret out of the ChatSell dashboard and a consumer key/secret
	 * out of WooCommerce. Every one of those failed silently when mistyped.
	 * Here the two sides negotiate instead: the merchant pastes one short
	 * pairing code, and everything else is exchanged over HTTPS.
	 * ------------------------------------------------------------------- */

	public function is_linked() {
		$options = $this->get_options();
		return ! empty( $options['site_id'] ) && ! empty( $options['webhook_secret'] );
	}

	const CONNECT_TRANSIENT = 'cswidgets_connect_handshake';

	/**
	 * Register the handshake route.
	 *
	 * Public by necessity — ChatSell calls it before any trust exists between
	 * the two sides. It therefore returns nothing that is not already public:
	 * the site name, whether WooCommerce is active, the plugin version, and one
	 * boolean saying whether the supplied state matches the connection this
	 * site just started. That boolean is the proof of domain ownership; it is
	 * false, harmlessly, for anyone guessing.
	 */
	public function register_rest_routes() {
		register_rest_route(
			'chatsell/v1',
			'/handshake',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'rest_handshake' ),
				'permission_callback' => '__return_true',
				'args'                => array(
					'state' => array(
						'type'              => 'string',
						'required'          => false,
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);
	}

	public function rest_handshake( $request ) {
		$state   = (string) $request->get_param( 'state' );
		$pending = get_transient( self::CONNECT_TRANSIENT );

		$state_match = false;
		if ( '' !== $state && is_array( $pending ) && ! empty( $pending['state'] ) ) {
			$state_match = hash_equals( (string) $pending['state'], $state );
		}

		$response = array(
			'chatsell'    => true,
			'state_match' => $state_match,
		);

		// Site name, plugin version and the WooCommerce flag are only for the
		// backend that started this handshake. This route is public and
		// unauthenticated, so anyone else gets nothing to fingerprint.
		if ( $state_match ) {
			$response['site_name']          = get_bloginfo( 'name' );
			$response['woocommerce_active'] = class_exists( 'WooCommerce' );
			$response['plugin_version']     = CSWIDGETS_VERSION;
		}

		return rest_ensure_response( $response );
	}

	/**
	 * Begin the redirect handshake: stash a state + PKCE verifier, then send the
	 * merchant to ChatSell to approve.
	 */
	public function handle_connect_start() {
		$this->guard_connection_request( 'cswidgets_connect' );

		$options  = $this->get_options();
		$verifier = wp_generate_password( 64, false, false );
		$state    = wp_generate_password( 32, false, false );

		set_transient(
			self::CONNECT_TRANSIENT,
			array(
				'state'    => $state,
				'verifier' => $verifier,
				'user_id'  => get_current_user_id(),
			),
			15 * MINUTE_IN_SECONDS
		);

		// PKCE S256: base64url(sha256(verifier)), unpadded.
		$challenge = rtrim( strtr( base64_encode( hash( 'sha256', $verifier, true ) ), '+/', '-_' ), '=' );

		$frontend_url = $this->get_service_url( $options, 'frontend_url' );
		if ( '' === $frontend_url ) {
			$this->set_admin_notice( 'error', __( 'The ChatSell address is not valid. Check the advanced settings.', 'chatsell-widgets' ) );
			$this->redirect_to_settings();
		}

		$target = $frontend_url . '/connect?' . http_build_query(
			array(
				'state'          => $state,
				'code_challenge' => $challenge,
				'return_url'     => admin_url( 'options-general.php?page=chatsell-widgets' ),
				'site_url'       => home_url(),
				'site_name'      => get_bloginfo( 'name' ),
				'plugin_version' => CSWIDGETS_VERSION,
			)
		);

		// wp_safe_redirect() would refuse this as an external host. The
		// destination is our own option, already forced to https by
		// sanitize_service_url(), so it is not attacker-controlled.
		wp_redirect( $target );
		exit;
	}

	/**
	 * Handle the bounce back from ChatSell.
	 *
	 * The code arrives in the query string, so it is treated as public: it is
	 * useless without the PKCE verifier held only in this site's transient, it
	 * is exchanged server-to-server, and the redirect below strips it from the
	 * address bar either way.
	 */
	public function maybe_handle_connect_callback() {
		// phpcs:disable WordPress.Security.NonceVerification.Recommended -- the
		// state transient below is the CSRF check; this is a redirect landing,
		// not a form submission, so there is no nonce to carry.
		if ( ! isset( $_GET['chatsell_code'] ) ) {
			return;
		}
		if ( ! current_user_can( 'manage_options' ) ) {
			return;
		}

		$code  = sanitize_text_field( wp_unslash( $_GET['chatsell_code'] ) );
		$state = isset( $_GET['state'] ) ? sanitize_text_field( wp_unslash( $_GET['state'] ) ) : '';
		// phpcs:enable WordPress.Security.NonceVerification.Recommended

		$pending = get_transient( self::CONNECT_TRANSIENT );
		delete_transient( self::CONNECT_TRANSIENT );

		// Every key is checked, not just state: a transient written by an older
		// version of this plugin may not carry them, and reading a missing one
		// would warn and then compare against an empty value.
		if ( ! is_array( $pending ) || empty( $pending['state'] ) || ! isset( $pending['user_id'], $pending['verifier'] )
			|| '' === $state || ! hash_equals( (string) $pending['state'], $state ) ) {
			$this->set_admin_notice( 'error', __( 'That connection link did not match a connection started on this site. Please try again.', 'chatsell-widgets' ) );
			$this->redirect_to_settings();
		}

		if ( (int) $pending['user_id'] !== get_current_user_id() ) {
			$this->set_admin_notice( 'error', __( 'That connection was started by a different user. Please start it again.', 'chatsell-widgets' ) );
			$this->redirect_to_settings();
		}

		$response = $this->request_exchange( $this->get_options(), $code, (string) $pending['verifier'] );

		if ( is_wp_error( $response ) ) {
			$this->record_connect_error( $response->get_error_message() );
			$this->set_admin_notice( 'error', $response->get_error_message() );
			$this->redirect_to_settings();
		}

		$this->store_bootstrap( $response );
		$this->finish_connection( __( 'Connected to ChatSell.', 'chatsell-widgets' ) );
	}

	/**
	 * Trade the one-time redirect code for our configuration. Server-side, so
	 * the webhook secret in the reply never touches the browser.
	 */
	private function request_exchange( $options, $code, $verifier ) {
		return $this->public_connect_request(
			$options,
			'/api/public/connect/exchange',
			array(
				'code'          => $code,
				'code_verifier' => $verifier,
				'site_url'      => home_url(),
			)
		);
	}

	public function advanced_section_callback() {
		echo '<p class="description">' . esc_html__(
			'These are filled in automatically when you connect. Change them only for a self-hosted ChatSell instance.',
			'chatsell-widgets'
		) . '</p>';
	}

	/**
	 * Resolve the ChatSell API base, rewriting loopback hosts when WordPress is
	 * itself containerised — inside Docker "localhost" is the container, not the
	 * developer's machine.
	 */
	private function resolve_api_base( $options ) {
		$api_url = $this->get_service_url( $options, 'api_url' );

		if ( ! file_exists( '/.dockerenv' ) ) {
			return $api_url;
		}

		// Only the host is considered, and only an exact match: a substring
		// rewrite would also mangle https://localhost.example.com and any path
		// or query that happens to contain the word.
		$host = wp_parse_url( $api_url, PHP_URL_HOST );
		if ( 'localhost' !== $host && '127.0.0.1' !== $host ) {
			return $api_url;
		}

		$parts          = wp_parse_url( $api_url );
		$rebuilt        = isset( $parts['scheme'] ) ? $parts['scheme'] . '://' : '';
		$rebuilt       .= 'host.docker.internal';
		$rebuilt       .= isset( $parts['port'] ) ? ':' . $parts['port'] : '';
		$rebuilt       .= isset( $parts['path'] ) ? $parts['path'] : '';
		$rebuilt       .= isset( $parts['query'] ) ? '?' . $parts['query'] : '';

		return $rebuilt;
	}

	private function set_admin_notice( $type, $message ) {
		set_transient( 'cswidgets_notice_' . get_current_user_id(), array(
			'type'    => in_array( $type, array( 'success', 'error', 'warning', 'info' ), true ) ? $type : 'info',
			'message' => (string) $message,
		), 60 );
	}

	private function render_admin_notice() {
		$key    = 'cswidgets_notice_' . get_current_user_id();
		$notice = get_transient( $key );
		if ( ! is_array( $notice ) || empty( $notice['message'] ) ) {
			return;
		}
		delete_transient( $key );

		printf(
			'<div class="notice notice-%1$s is-dismissible"><p>%2$s</p></div>',
			esc_attr( $notice['type'] ),
			esc_html( $notice['message'] )
		);
	}

	private function render_connection_panel() {
		$options   = $this->get_options();
		$is_linked = $this->is_linked();
		$status    = isset( $options['connection_status'] ) ? $options['connection_status'] : 'unlinked';
		?>
		<div class="card cswidgets-connection">
			<h2><?php esc_html_e( 'ChatSell connection', 'chatsell-widgets' ); ?></h2>

			<?php if ( $is_linked && 'linked' === $status ) : ?>
				<p>
					<span class="cswidgets-status cswidgets-status-ok">&#9679;</span>
					<strong><?php esc_html_e( 'Connected', 'chatsell-widgets' ); ?></strong>
					<?php
					if ( ! empty( $options['connected_site_name'] ) ) {
						echo ' &mdash; ' . esc_html( $options['connected_site_name'] );
						if ( ! empty( $options['connected_org_name'] ) ) {
							echo ' (' . esc_html( $options['connected_org_name'] ) . ')';
						}
					}
					?>
				</p>
				<?php if ( ! empty( $options['last_connect_error'] ) ) : ?>
					<p class="cswidgets-connection-error"><?php echo esc_html( $options['last_connect_error'] ); ?></p>
				<?php endif; ?>

				<div class="cswidgets-connection-actions">
					<?php $this->render_connection_button( 'cswidgets_sync', __( 'Sync now', 'chatsell-widgets' ), 'button button-primary' ); ?>
					<?php $this->render_connection_button( 'cswidgets_refresh', __( 'Refresh connection', 'chatsell-widgets' ), 'button' ); ?>
					<?php $this->render_connection_button( 'cswidgets_connect', __( 'Reconnect', 'chatsell-widgets' ), 'button' ); ?>
					<?php $this->render_connection_button( 'cswidgets_disconnect', __( 'Disconnect', 'chatsell-widgets' ), 'button button-link-delete' ); ?>
				</div>

				<div class="cswidgets-sync-progress" data-task="<?php echo esc_attr( isset( $options['sync_task_id'] ) ? $options['sync_task_id'] : '' ); ?>" hidden>
					<div class="cswidgets-progress-track"><div class="cswidgets-progress-bar" style="width:0%"></div></div>
					<p class="cswidgets-progress-label"></p>
				</div>

				<p class="description">
					<?php esc_html_e( 'Sync now imports this store\'s products into ChatSell. Refresh re-sends the store details (currency, country, language) and re-checks the WooCommerce connection.', 'chatsell-widgets' ); ?>
					<?php
					if ( ! empty( $options['last_sync_at'] ) ) {
						echo ' ';
						printf(
							/* translators: %s: human-readable time difference, e.g. "5 mins" */
							esc_html__( 'Last sync started %s ago.', 'chatsell-widgets' ),
							esc_html( human_time_diff( (int) $options['last_sync_at'], time() ) )
						);
					}
					?>
				</p>

			<?php elseif ( $is_linked ) : ?>
				<p>
					<span class="cswidgets-status cswidgets-status-warn">&#9679;</span>
					<strong><?php esc_html_e( 'Manually configured', 'chatsell-widgets' ); ?></strong>
				</p>
				<p class="description">
					<?php esc_html_e( 'This site has a Site ID and webhook secret entered by hand. Connect it properly to keep its details in sync automatically.', 'chatsell-widgets' ); ?>
				</p>
				<div class="cswidgets-connection-actions">
					<?php $this->render_connection_button( 'cswidgets_connect', __( 'Upgrade to a linked connection', 'chatsell-widgets' ), 'button button-primary' ); ?>
				</div>
				<details class="cswidgets-pair-fallback">
					<summary><?php esc_html_e( 'Have a pairing code instead?', 'chatsell-widgets' ); ?></summary>
					<?php $this->render_pair_form(); ?>
				</details>

			<?php else : ?>
				<p>
					<span class="cswidgets-status cswidgets-status-off">&#9679;</span>
					<strong><?php esc_html_e( 'Not connected', 'chatsell-widgets' ); ?></strong>
				</p>
				<?php if ( ! empty( $options['last_connect_error'] ) ) : ?>
					<p class="cswidgets-connection-error"><?php echo esc_html( $options['last_connect_error'] ); ?></p>
				<?php endif; ?>
				<p class="description">
					<?php esc_html_e( 'Connect this store to ChatSell. You will be asked to approve it, then sent straight back here — nothing to copy.', 'chatsell-widgets' ); ?>
				</p>
				<div class="cswidgets-connection-actions">
					<?php $this->render_connection_button( 'cswidgets_connect', __( 'Connect to ChatSell', 'chatsell-widgets' ), 'button button-primary' ); ?>
				</div>
				<details class="cswidgets-pair-fallback">
					<summary><?php esc_html_e( 'Have a pairing code instead?', 'chatsell-widgets' ); ?></summary>
					<?php $this->render_pair_form(); ?>
				</details>
			<?php endif; ?>
		</div>
		<?php
	}

	private function render_pair_form() {
		?>
		<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" class="cswidgets-pair-form">
			<input type="hidden" name="action" value="cswidgets_pair" />
			<?php wp_nonce_field( 'cswidgets_pair' ); ?>
			<label for="cswidgets-pairing-code"><strong><?php esc_html_e( 'Pairing code', 'chatsell-widgets' ); ?></strong></label>
			<input type="text" id="cswidgets-pairing-code" name="pairing_code" class="regular-text" placeholder="CS-XXXX-XXXX" autocomplete="off" spellcheck="false" required />
			<?php submit_button( __( 'Connect to ChatSell', 'chatsell-widgets' ), 'primary', 'submit', false ); ?>
		</form>
		<?php
	}

	private function render_connection_button( $action, $label, $class ) {
		?>
		<form method="post" action="<?php echo esc_url( admin_url( 'admin-post.php' ) ); ?>" style="display:inline-block;margin-right:6px;">
			<input type="hidden" name="action" value="<?php echo esc_attr( $action ); ?>" />
			<?php wp_nonce_field( $action ); ?>
			<button type="submit" class="<?php echo esc_attr( $class ); ?>"><?php echo esc_html( $label ); ?></button>
		</form>
		<?php
	}

	private function redirect_to_settings() {
		wp_safe_redirect( admin_url( 'options-general.php?page=chatsell-widgets' ) );
		exit;
	}

	private function guard_connection_request( $action ) {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_die( esc_html__( 'You do not have permission to change the ChatSell connection.', 'chatsell-widgets' ), '', array( 'response' => 403 ) );
		}
		check_admin_referer( $action );
	}

	public function handle_pair_code() {
		$this->guard_connection_request( 'cswidgets_pair' );

		$code = isset( $_POST['pairing_code'] ) ? sanitize_text_field( wp_unslash( $_POST['pairing_code'] ) ) : '';
		if ( '' === $code ) {
			$this->set_admin_notice( 'error', __( 'Enter the pairing code from your ChatSell dashboard.', 'chatsell-widgets' ) );
			$this->redirect_to_settings();
		}

		$options  = $this->get_options();
		$response = $this->request_redeem( $options, $code );

		if ( is_wp_error( $response ) ) {
			$this->record_connect_error( $response->get_error_message() );
			$this->set_admin_notice( 'error', $response->get_error_message() );
			$this->redirect_to_settings();
		}

		$this->store_bootstrap( $response );
		$this->finish_connection( __( 'Connected to ChatSell.', 'chatsell-widgets' ) );
	}

	public function handle_refresh_connection() {
		$this->guard_connection_request( 'cswidgets_refresh' );

		if ( ! $this->is_linked() ) {
			$this->set_admin_notice( 'error', __( 'This site is not connected yet.', 'chatsell-widgets' ) );
			$this->redirect_to_settings();
		}

		$this->finish_connection( __( 'Connection refreshed.', 'chatsell-widgets' ) );
	}

	/**
	 * Push the store profile and turn the result into a notice the merchant can
	 * act on. Shared by "connect" and "refresh" so both report identically.
	 */
	private function finish_connection( $success_prefix ) {
		$result = $this->push_store_profile();

		if ( is_wp_error( $result ) ) {
			$this->record_connect_error( $result->get_error_message() );
			$this->set_admin_notice(
				'error',
				/* translators: %s: error detail from the ChatSell API */
				sprintf( __( 'Connected, but the store details could not be sent: %s', 'chatsell-widgets' ), $result->get_error_message() )
			);
			$this->redirect_to_settings();
		}

		if ( empty( $result['ok'] ) ) {
			$message = ! empty( $result['message'] )
				? $result['message']
				: __( 'ChatSell could not verify this store.', 'chatsell-widgets' );
			$this->record_connect_error( $message );
			$this->set_admin_notice( 'error', $message );
			$this->redirect_to_settings();
		}

		$this->record_connect_error( '' );

		$message = $success_prefix;
		if ( isset( $result['products_visible'] ) && null !== $result['products_visible'] ) {
			$message .= ' ' . sprintf(
				/* translators: %d: number of products ChatSell can read */
				_n( '%d product is visible to ChatSell.', '%d products are visible to ChatSell.', (int) $result['products_visible'], 'chatsell-widgets' ),
				(int) $result['products_visible']
			);
		}
		if ( ! empty( $result['warnings'] ) && is_array( $result['warnings'] ) ) {
			$message .= ' ' . implode( ' ', array_map( 'strval', $result['warnings'] ) );
		}

		$this->set_admin_notice( 'success', $message );
		$this->redirect_to_settings();
	}

	/**
	 * Start a product sync. Resume-only by design: a single click must not be
	 * able to discard a large catalogue's index and rebuild from scratch.
	 */
	public function handle_sync_now() {
		$this->guard_connection_request( 'cswidgets_sync' );

		if ( ! $this->is_linked() ) {
			$this->set_admin_notice( 'error', __( 'This site is not connected yet.', 'chatsell-widgets' ) );
			$this->redirect_to_settings();
		}

		$options = $this->get_options();
		$path    = '/api/public/sites/' . rawurlencode( $options['site_id'] ) . '/connection/sync';
		$result  = $this->signed_request( $options, $path, array( 'nonce' => wp_generate_password( 12, false, false ) ) );

		if ( is_wp_error( $result ) ) {
			$this->set_admin_notice( 'error', $result->get_error_message() );
			$this->redirect_to_settings();
		}

		if ( empty( $result['ok'] ) ) {
			$message = ! empty( $result['message'] ) ? $result['message'] : __( 'Could not start the sync.', 'chatsell-widgets' );
			// An already-running sync is worth watching, not an error to bury.
			if ( isset( $result['error_code'] ) && 'already_running' === $result['error_code'] && ! empty( $result['task_id'] ) ) {
				$this->store_sync_task( $result['task_id'] );
				$this->set_admin_notice( 'info', $message );
				$this->redirect_to_settings();
			}
			$this->set_admin_notice( 'error', $message );
			$this->redirect_to_settings();
		}

		$this->store_sync_task( isset( $result['task_id'] ) ? $result['task_id'] : '' );
		$this->set_admin_notice( 'success', __( 'Sync started. Progress appears below.', 'chatsell-widgets' ) );
		$this->redirect_to_settings();
	}

	private function store_sync_task( $task_id ) {
		$options                  = $this->get_options();
		$options['sync_task_id']  = $this->sanitize_text_option( $task_id );
		$options['last_sync_at']  = time();
		$this->save_options( $options );
	}

	/**
	 * Poll target for the admin progress bar.
	 *
	 * The browser never sees the webhook secret: it asks WordPress, WordPress
	 * asks ChatSell with a signed request, and only the progress numbers come
	 * back out.
	 */
	public function ajax_sync_status() {
		if ( ! current_user_can( 'manage_options' ) ) {
			wp_send_json_error( array( 'message' => __( 'Not allowed.', 'chatsell-widgets' ) ), 403 );
		}
		check_ajax_referer( 'cswidgets_sync_status' );

		$options = $this->get_options();
		if ( ! $this->is_linked() ) {
			wp_send_json_error( array( 'message' => __( 'Not connected.', 'chatsell-widgets' ) ), 400 );
		}

		$task_id = isset( $options['sync_task_id'] ) ? (string) $options['sync_task_id'] : '';
		$path    = '/api/public/sites/' . rawurlencode( $options['site_id'] ) . '/connection/status';
		$result  = $this->signed_request( $options, $path, array( 'task_id' => $task_id, 'nonce' => wp_generate_password( 12, false, false ) ) );

		if ( is_wp_error( $result ) ) {
			wp_send_json_error( array( 'message' => $result->get_error_message() ), 502 );
		}

		// Stop polling once the job is over, so a stale id does not keep the
		// bar alive across page loads forever.
		if ( isset( $result['sync_status'] ) && in_array( $result['sync_status'], array( 'completed', 'failed' ), true ) ) {
			$options['sync_task_id'] = '';
			$this->save_options( $options );
		}

		wp_send_json_success(
			array(
				'sync_status'   => isset( $result['sync_status'] ) ? $result['sync_status'] : null,
				'sync_progress' => isset( $result['sync_progress'] ) ? $result['sync_progress'] : null,
				'sync_message'  => isset( $result['sync_message'] ) ? $result['sync_message'] : null,
				'sync_error'    => isset( $result['sync_error'] ) ? $result['sync_error'] : null,
				'status'        => isset( $result['status'] ) ? $result['status'] : null,
			)
		);
	}

	public function handle_disconnect() {
		$this->guard_connection_request( 'cswidgets_disconnect' );

		$options = $this->get_options();
		$path    = '/api/public/sites/' . rawurlencode( $options['site_id'] ) . '/connection/disconnect';
		$result  = $this->signed_request( $options, $path, array( 'nonce' => wp_generate_password( 12, false, false ) ) );

		// Revoke only the key we minted. A merchant's own WooCommerce keys, and
		// any key created by another integration, must survive this. This still
		// runs if ChatSell is unreachable: revoking the remote key immediately
		// prevents the retained backend credentials from reading the catalogue.
		if ( ! empty( $options['wc_key_id'] ) ) {
			$this->revoke_wc_key( $options['wc_key_id'] );
		}

		$this->clear_connection_state();
		if ( is_wp_error( $result ) || empty( $result['ok'] ) ) {
			$this->set_admin_notice(
				'warning',
				__( 'The local WooCommerce key was deleted, but ChatSell could not be notified. The dashboard may show this store as connected until you disconnect it there too.', 'chatsell-widgets' )
			);
			$this->redirect_to_settings();
		}
		$this->set_admin_notice(
			'success',
			__( 'Disconnected from ChatSell. The WooCommerce key this plugin created has been deleted.', 'chatsell-widgets' )
		);
		$this->redirect_to_settings();
	}

	private function record_connect_error( $message ) {
		$options                       = $this->get_options();
		$options['last_connect_error'] = $this->sanitize_text_option( $message );
		$this->save_options( $options );
	}

	/**
	 * Persist the configuration ChatSell handed us. This is the step that means
	 * a merchant never types a site ID, a secret, or a URL.
	 */
	private function store_bootstrap( $data ) {
		$options  = $this->get_options();
		$defaults = $this->get_default_options();

		$was_linked = $this->is_linked();
		$old_secret = isset( $options['webhook_secret'] ) ? (string) $options['webhook_secret'] : '';
		$new_secret = isset( $data['webhook_secret'] ) ? (string) $data['webhook_secret'] : '';

		// A changed secret means ChatSell revoked the previous connection (for
		// example from the dashboard). ChatSell also discarded its copy of the
		// WooCommerce credentials, so the one-time plaintext secret for our old
		// key is no longer recoverable. Delete it now and let the profile push
		// provision a fresh key instead of getting stuck on the existing-key path.
		if ( $was_linked && '' !== $old_secret && '' !== $new_secret && ! hash_equals( $old_secret, $new_secret ) ) {
			if ( ! empty( $options['wc_key_id'] ) ) {
				$this->revoke_wc_key( $options['wc_key_id'] );
			}
			$options['wc_key_id'] = 0;
		}

		if ( ! empty( $data['site_id'] ) ) {
			$options['site_id'] = $this->sanitize_text_option( $data['site_id'] );
		}
		if ( ! empty( $data['webhook_secret'] ) ) {
			$options['webhook_secret'] = $this->sanitize_text_option( $data['webhook_secret'] );
		}
		// The backend states its own addresses; a plugin build old enough to have
		// the wrong defaults compiled in still lands on the right endpoints.
		if ( ! empty( $data['api_url'] ) ) {
			$options['api_url'] = $this->sanitize_service_url( $data['api_url'], $defaults['api_url'] );
		}
		if ( ! empty( $data['frontend_url'] ) ) {
			$options['frontend_url'] = $this->sanitize_service_url( $data['frontend_url'], $defaults['frontend_url'] );
		}

		$options['connected_site_name'] = isset( $data['site_name'] ) ? $this->sanitize_text_option( $data['site_name'] ) : '';
		$options['connected_org_name']  = isset( $data['org_name'] ) ? $this->sanitize_text_option( $data['org_name'] ) : '';
		$options['connection_status']   = 'linked';
		$options['connected_at']        = time();
		$options['last_connect_error']  = '';

		// On a first connect, turn the chat widget on: the merchant just asked
		// for this store to be connected, and a connection that renders nothing
		// looks broken. Left alone on reconnects so an intentional "off" sticks.
		if ( ! $was_linked ) {
			$options['chat_enabled'] = 1;
		}

		$this->save_options( $options );
	}

	private function clear_connection_state() {
		$options = $this->get_options();

		// api_url and frontend_url are deliberately left alone: on a staging or
		// self-hosted install they point at a backend that is not the default,
		// and resetting them here would leave the site unable to reconnect.
		$options['site_id']             = '';
		$options['webhook_secret']      = '';
		$options['connection_status']   = 'unlinked';
		$options['connected_site_name'] = '';
		$options['connected_org_name']  = '';
		$options['connected_at']        = 0;
		$options['wc_key_id']           = 0;
		$options['last_connect_error']  = '';

		$this->save_options( $options );
	}

	/**
	 * Everything ChatSell would otherwise have had to ask the merchant for.
	 */
	private function collect_store_profile() {
		$wc_active = class_exists( 'WooCommerce' );

		$profile = array(
			'site_url'           => home_url(),
			'woocommerce_active' => $wc_active,
			'wp_version'         => get_bloginfo( 'version' ),
			'plugin_version'     => CSWIDGETS_VERSION,
			'site_name'          => get_bloginfo( 'name' ),
			'locale'             => get_locale(),
		);

		if ( ! $wc_active ) {
			return $profile;
		}

		$profile['wc_api_url'] = untrailingslashit( home_url() ) . '/wp-json/wc/v3/';

		if ( defined( 'WC_VERSION' ) ) {
			$profile['wc_version'] = WC_VERSION;
		}
		if ( function_exists( 'get_woocommerce_currency' ) ) {
			$profile['currency'] = get_woocommerce_currency();
		}
		if ( function_exists( 'WC' ) && isset( WC()->countries ) && WC()->countries ) {
			$base_country = WC()->countries->get_base_country();
			if ( $base_country ) {
				$profile['country'] = $base_country;
			}
		}

		$counts = wp_count_posts( 'product' );
		if ( isset( $counts->publish ) ) {
			$profile['product_count'] = (int) $counts->publish;
		}

		return $profile;
	}

	/**
	 * Create a read-only WooCommerce REST key for ChatSell.
	 *
	 * This is why the merchant never visits WooCommerce → Advanced → REST API.
	 * The key is written through WooCommerce's own table and helpers, so it
	 * appears in that screen like any other and can be revoked there.
	 *
	 * The plaintext secret is returned to the caller and deliberately never
	 * stored in wp_options — it is sent once and forgotten.
	 */
	private function provision_wc_key() {
		global $wpdb;

		if ( ! function_exists( 'wc_rand_hash' ) || ! function_exists( 'wc_api_hash' ) ) {
			return null;
		}

		$user_id = get_current_user_id();
		if ( ! $user_id ) {
			return null;
		}

		// Reaching here means we hold no usable key. Anything still in the table
		// under our description is dead weight — its plaintext secret existed
		// only in the request that made it — so clear it before minting rather
		// than leaving one more behind on every connect attempt.
		$this->purge_stale_wc_keys();

		$consumer_key    = 'ck_' . wc_rand_hash();
		$consumer_secret = 'cs_' . wc_rand_hash();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- WooCommerce exposes no API for creating keys.
		$inserted = $wpdb->insert(
			$wpdb->prefix . 'woocommerce_api_keys',
			array(
				'user_id'         => $user_id,
				'description'     => self::WC_KEY_DESCRIPTION,
				'permissions'     => 'read',
				'consumer_key'    => wc_api_hash( $consumer_key ),
				'consumer_secret' => $consumer_secret,
				'truncated_key'   => substr( $consumer_key, -7 ),
			),
			array( '%d', '%s', '%s', '%s', '%s', '%s' )
		);

		if ( ! $inserted ) {
			return null;
		}

		return array(
			'key_id'          => (int) $wpdb->insert_id,
			'consumer_key'    => $consumer_key,
			'consumer_secret' => $consumer_secret,
		);
	}

	/**
	 * Delete every key this plugin has previously minted.
	 *
	 * Scoped to our own description so a merchant's own keys, and any created by
	 * another integration, are never touched.
	 */
	private function purge_stale_wc_keys() {
		global $wpdb;

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- matches provision_wc_key().
		return (int) $wpdb->delete(
			$wpdb->prefix . 'woocommerce_api_keys',
			array( 'description' => self::WC_KEY_DESCRIPTION ),
			array( '%s' )
		);
	}

	/**
	 * Whether a key we recorded is still present.
	 *
	 * A merchant can delete it from WooCommerce → Advanced → REST API at any
	 * time. Without this check the stored id would keep us on the "ChatSell
	 * already has credentials" branch forever, and every sync would fail on a
	 * key that no longer exists.
	 */
	private function wc_key_exists( $key_id ) {
		global $wpdb;

		$key_id = absint( $key_id );
		if ( ! $key_id ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- matches provision_wc_key().
		return (bool) $wpdb->get_var(
			$wpdb->prepare(
				"SELECT key_id FROM {$wpdb->prefix}woocommerce_api_keys WHERE key_id = %d",
				$key_id
			)
		);
	}

	private function revoke_wc_key( $key_id ) {
		global $wpdb;

		$key_id = absint( $key_id );
		if ( ! $key_id ) {
			return false;
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- matches provision_wc_key().
		return (bool) $wpdb->delete(
			$wpdb->prefix . 'woocommerce_api_keys',
			array( 'key_id' => $key_id ),
			array( '%d' )
		);
	}

	private function push_store_profile() {
		$options = $this->get_options();
		$profile = $this->collect_store_profile();

		$provisioned_key_id = 0;

		if ( ! empty( $profile['woocommerce_active'] ) ) {
			// Only mint when we genuinely have nothing usable. A connected store
			// re-sending its profile keeps the key it already has, so "Reconnect"
			// and "Refresh" do not pile up credentials.
			if ( empty( $options['wc_key_id'] ) || ! $this->wc_key_exists( $options['wc_key_id'] ) ) {
				$key = $this->provision_wc_key();
				if ( null === $key ) {
					return new WP_Error(
						'cswidgets_wc_key_failed',
						__( 'Could not create a WooCommerce API key. Check that your user can manage WooCommerce.', 'chatsell-widgets' )
					);
				}
				$profile['consumer_key']    = $key['consumer_key'];
				$profile['consumer_secret'] = $key['consumer_secret'];
				$profile['wc_key_id']       = $key['key_id'];

				$provisioned_key_id   = (int) $key['key_id'];
				$options['wc_key_id'] = $key['key_id'];
				$this->save_options( $options );
			} else {
				// Re-sending a profile with an existing key: we no longer hold the
				// plaintext secret, so ChatSell keeps the credentials it has.
				$profile['wc_key_id'] = (int) $options['wc_key_id'];
			}
		}

		$path     = '/api/public/sites/' . rawurlencode( $options['site_id'] ) . '/connection/profile';
		$response = $this->signed_request( $options, $path, $profile );

		// The plaintext secret exists only inside this request. If the push
		// failed, ChatSell never received it and we can never send it again —
		// so the key has to go too, or a retry takes the "existing key" branch
		// and the connection can never complete.
		if ( $provisioned_key_id && ( is_wp_error( $response ) || empty( $response['ok'] ) ) ) {
			$this->revoke_wc_key( $provisioned_key_id );
			$options              = $this->get_options();
			$options['wc_key_id'] = 0;
			$this->save_options( $options );
		}

		return $response;
	}

	/**
	 * HMAC-signed, timestamped POST to ChatSell.
	 *
	 * Unlike send_realtime_sync_webhook() this blocks and reads the response:
	 * these calls have a merchant waiting on the result, and a silent failure
	 * here is exactly the class of bug the connect flow exists to remove. The
	 * timestamp is inside the signed string so a captured request cannot be
	 * replayed later.
	 */
	private function signed_request( $options, $path, $payload ) {
		if ( empty( $options['site_id'] ) ) {
			return new WP_Error( 'cswidgets_not_linked', __( 'This site is not linked to ChatSell yet.', 'chatsell-widgets' ) );
		}

		$secret = isset( $options['webhook_secret'] ) ? trim( $options['webhook_secret'] ) : '';
		if ( '' === $secret ) {
			return new WP_Error( 'cswidgets_no_secret', __( 'No webhook secret is configured. Reconnect to ChatSell.', 'chatsell-widgets' ) );
		}

		$api_url = $this->resolve_api_base( $options );
		if ( '' === $api_url ) {
			return new WP_Error( 'cswidgets_bad_api_url', __( 'The ChatSell API URL is not valid.', 'chatsell-widgets' ) );
		}

		$url = $api_url . $path;
		if ( ! $this->is_safe_webhook_url( $url ) ) {
			return new WP_Error( 'cswidgets_unsafe_url', __( 'The ChatSell API URL was rejected as unsafe.', 'chatsell-widgets' ) );
		}

		$body = wp_json_encode( $payload );
		if ( false === $body ) {
			return new WP_Error( 'cswidgets_encode_failed', __( 'Could not encode the store details.', 'chatsell-widgets' ) );
		}

		$timestamp = (string) time();
		$host      = wp_parse_url( $url, PHP_URL_HOST );
		$is_local  = $host && $this->is_local_service_host( $host );

		$response = wp_remote_post(
			$url,
			array(
				'method'             => 'POST',
				'timeout'            => 30,
				'blocking'           => true,
				'redirection'        => 0,
				'reject_unsafe_urls' => ! $is_local,
				'headers'            => array(
					'Content-Type'         => 'application/json',
					'User-Agent'           => 'ChatSell-WordPress-Plugin/' . CSWIDGETS_VERSION,
					'X-ChatSell-Signature' => hash_hmac( 'sha256', $timestamp . '.' . $body, $secret ),
					'X-ChatSell-Timestamp' => $timestamp,
				),
				'body'               => $body,
			)
		);

		return $this->parse_api_response( $response );
	}

	/**
	 * Redeem a pairing code. Unsigned by definition — the code is the credential,
	 * and this runs server-side so the secret in the reply never reaches a browser.
	 */
	private function request_redeem( $options, $code ) {
		return $this->public_connect_request(
			$options,
			'/api/public/connect/redeem',
			array(
				'code'           => $code,
				'site_url'       => home_url(),
				'site_name'      => get_bloginfo( 'name' ),
				'plugin_version' => CSWIDGETS_VERSION,
			)
		);
	}

	/**
	 * POST to an unauthenticated connect endpoint. Both entry points — pairing
	 * code and redirect exchange — trade a single-use code for the same
	 * configuration payload, so they share this.
	 */
	private function public_connect_request( $options, $path, $payload ) {
		$api_url = $this->resolve_api_base( $options );
		if ( '' === $api_url ) {
			return new WP_Error( 'cswidgets_bad_api_url', __( 'The ChatSell API URL is not valid.', 'chatsell-widgets' ) );
		}

		$url = $api_url . $path;
		if ( ! $this->is_safe_webhook_url( $url ) ) {
			return new WP_Error( 'cswidgets_unsafe_url', __( 'The ChatSell API URL was rejected as unsafe.', 'chatsell-widgets' ) );
		}

		$body = wp_json_encode( $payload );
		if ( false === $body ) {
			return new WP_Error( 'cswidgets_encode_failed', __( 'Could not encode the connection request.', 'chatsell-widgets' ) );
		}

		$host     = wp_parse_url( $url, PHP_URL_HOST );
		$is_local = $host && $this->is_local_service_host( $host );

		$response = wp_remote_post(
			$url,
			array(
				'method'             => 'POST',
				'timeout'            => 30,
				'blocking'           => true,
				'redirection'        => 0,
				'reject_unsafe_urls' => ! $is_local,
				'headers'            => array(
					'Content-Type' => 'application/json',
					'User-Agent'   => 'ChatSell-WordPress-Plugin/' . CSWIDGETS_VERSION,
				),
				'body'               => $body,
			)
		);

		return $this->parse_api_response( $response );
	}

	/**
	 * Turn a wp_remote_post() result into a decoded array or a WP_Error whose
	 * message is fit to show a merchant.
	 */
	private function parse_api_response( $response ) {
		if ( is_wp_error( $response ) ) {
			return new WP_Error( 'cswidgets_http_error', $response->get_error_message() );
		}

		$status = (int) wp_remote_retrieve_response_code( $response );
		$body   = wp_remote_retrieve_body( $response );
		$data   = json_decode( $body, true );

		if ( $status < 200 || $status >= 300 ) {
			$detail = '';
			if ( is_array( $data ) && isset( $data['detail'] ) ) {
				// FastAPI validation errors arrive as a list of objects.
				$detail = is_string( $data['detail'] ) ? $data['detail'] : wp_json_encode( $data['detail'] );
			}
			if ( '' === $detail ) {
				/* translators: %d: HTTP status code */
				$detail = sprintf( __( 'ChatSell returned an unexpected response (HTTP %d).', 'chatsell-widgets' ), $status );
			}
			return new WP_Error( 'cswidgets_api_error', $detail );
		}

		if ( ! is_array( $data ) ) {
			return new WP_Error( 'cswidgets_bad_response', __( 'ChatSell returned an unreadable response.', 'chatsell-widgets' ) );
		}

		return $data;
	}
}

CSWidgets_Plugin::get_instance();
