<?php

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

/**
 * Handles synchronization of categories between Zoho Inventory and WooCommerce.
 *
 * Provides methods to import, export, and manage categories and subcategories,
 * ensuring data consistency between Zoho Inventory and WooCommerce product categories.
 */
class CMBIRD_Categories_ZI {

	/**
	 * Configuration options for Zoho Inventory connection.
	 *
	 * @var array
	 */
	private $config;

	/**
	 * Lazy-initialised mapping helper.
	 *
	 * @var \CommerceBird\Admin\Mappings\ZohoCategoryMap|null
	 */
	private $map = null;

	/**
	 * Tracks whether the most recent Zoho categories fetch completed all pages.
	 *
	 * @var bool
	 */
	private $last_zoho_fetch_complete = false;

	/**
	 * @return \CommerceBird\Admin\Mappings\ZohoCategoryMap
	 */
	private function map(): \CommerceBird\Admin\Mappings\ZohoCategoryMap {
		if ( null === $this->map ) {
			$this->map = new \CommerceBird\Admin\Mappings\ZohoCategoryMap();
		}
		return $this->map;
	}

	/**
	 * Mark whether the latest Zoho fetch was complete.
	 */
	private function set_last_zoho_fetch_complete( bool $is_complete ): void {
		$this->last_zoho_fetch_complete = $is_complete;
	}

	/**
	 * Check whether the latest Zoho fetch completed all pages.
	 */
	private function is_last_zoho_fetch_complete(): bool {
		return true === $this->last_zoho_fetch_complete;
	}
	/**
	 * Class constructor.
	 *
	 * Retrieves the Zoho Inventory connection configuration and hooks for category
	 * synchronization actions.
	 */
	public function __construct() {
		$this->config = array(
			'ConnectZI' => array(
				'OID'    => get_option( 'cmbird_zoho_inventory_oid' ),
				'APIURL' => get_option( 'cmbird_zoho_inventory_url' ),
			),
		);
		add_action( 'wp_ajax_zoho_ajax_call_parent_categories', array( $this, 'cmbird_zi_parent_category_sync' ) );
		add_action( 'wp_ajax_zoho_ajax_call_subcategories', array( $this, 'cmbird_zi_subcategory_sync' ) );
		add_action( 'wp_ajax_zoho_ajax_call_remove_duplicates', array( $this, 'cmbird_zi_remove_duplicates_sync' ) );
		add_action( 'wp_ajax_zoho_ajax_call_check_category_mappings', array( $this, 'cmbird_zi_check_category_mappings' ) );
		add_action( 'wp_ajax_commercebird-app-check_category_mappings', array( $this, 'cmbird_zi_check_category_mappings' ) );

		// Async batch processing handlers.
		add_action( 'wp_ajax_zoho_ajax_call_subcategories_start', array( $this, 'cmbird_zi_subcategory_sync_start' ) );
		add_action( 'wp_ajax_zoho_ajax_call_subcategories_batch', array( $this, 'cmbird_zi_subcategory_sync_batch' ) );
		add_action( 'wp_ajax_zoho_ajax_call_subcategories_status', array( $this, 'cmbird_zi_subcategory_sync_status' ) );
		add_action( 'wp_ajax_zoho_ajax_call_duplicate_removal_batch', array( $this, 'cmbird_zi_duplicate_removal_batch' ) );
		add_action( 'wp_ajax_zoho_ajax_call_duplicate_removal_status', array( $this, 'cmbird_zi_duplicate_removal_status' ) );
	}

	/**
	 * Create response array based on data for Vue Table compatibility.
	 *
	 * @param mixed  $index_col - Index value error message.
	 * @param string $message - Response message.
	 * @param string $woo_id - Woo product ID.
	 *
	 * @return array
	 */
	private function cmbird_zi_response_message( $index_col, $message, $woo_id = '' ) {
		return array(
			'resp_id'     => $index_col,
			'message'     => $message,
			'woo_prod_id' => $woo_id,
		);
	}

	/**
	 * Check if Zoho API rate limit has been exceeded.
	 *
	 * @return bool True if rate limit exceeded, false if OK to proceed.
	 */
	private function is_zoho_rate_limit_exceeded() {
		$zoho_rate_limit_exceeded = get_option( 'cmbird_zoho_rate_limit_exceeded', false );

		if ( $zoho_rate_limit_exceeded ) {
			// Check if enough time has passed since the rate limit was hit.
			$rate_limit_time = get_option( 'cmbird_zoho_rate_limit_time', 0 );
			$current_time    = time();

			// Zoho typically resets limits every minute, so wait 65 seconds to be safe.
			if ( ( $current_time - $rate_limit_time ) < 65 ) {
				return true; // Rate limit is still active.
			} else {
				// Enough time has passed, reset the rate limit flag.
				update_option( 'cmbird_zoho_rate_limit_exceeded', false );
				delete_option( 'cmbird_zoho_rate_limit_time' );
				return false; // Rate limit has been reset.
			}
		}

		return false; // No rate limit active.
	}

	/**
	 * Track and limit Zoho API calls to respect 100 calls per minute limit.
	 *
	 * @return void
	 */
	private function respect_zoho_api_rate_limit() {
		global $wpdb;

		$current_time   = time();
		$current_minute = floor( $current_time / 60 ); // Get current minute as identifier.

		// Get the API call count for current minute.
		$api_calls_key  = 'cmbird_zoho_api_calls_' . $current_minute;
		$api_call_count = get_option( $api_calls_key, 0 );

		// Clean up old minute counters (keep only current and previous minute).
		$previous_minute = $current_minute - 1;
		$option_prefix   = 'cmbird_zoho_api_calls_';
		$option_names    = $wpdb->get_col(
			$wpdb->prepare(
				"SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s",
				$wpdb->esc_like( $option_prefix ) . '%'
			)
		);

		foreach ( $option_names as $option_name ) {
			$minute_suffix = str_replace( $option_prefix, '', $option_name );
			if ( is_numeric( $minute_suffix ) && (int) $minute_suffix < $previous_minute ) {
				delete_option( $option_name );
			}
		}

		// If we've hit 95 calls, wait for the next minute to be safe.
		if ( $api_call_count >= 95 ) {
			$seconds_to_wait = 60 - ( $current_time % 60 ) + 2; // +2 for safety margin.
			sleep( $seconds_to_wait );

			// Update to new minute.
			$current_minute = floor( time() / 60 );
			$api_calls_key  = 'cmbird_zoho_api_calls_' . $current_minute;
			$api_call_count = 0;
		}

		// Increment the counter for current minute.
		update_option( $api_calls_key, $api_call_count + 1, false );

		// Add a minimal delay between calls (0.7 seconds to stay under 100/minute).
		// This allows ~85 calls per minute with safety margin.
		usleep( 700000 ); // 0.7 seconds in microseconds.
	}

	/**
	 * Check API response for rate limit error and set rate limit flag if detected.
	 *
	 * @param object $json_response The JSON response from Zoho API.
	 * @return bool True if rate limit detected, false otherwise.
	 */
	private function check_and_handle_rate_limit_error( $json_response ) {
		if ( ! $json_response ) {
			return false;
		}

		// Check for rate limit error messages from Zoho.
		if ( isset( $json_response->message ) ) {
			$message = strtolower( $json_response->message );
			if ( strpos( $message, 'rate limit' ) !== false ||
				strpos( $message, 'exceeded the maximum number of requests' ) !== false ||
				strpos( $message, 'too many requests' ) !== false ) {

				// Set rate limit flag.
				update_option( 'cmbird_zoho_rate_limit_exceeded', true );
				update_option( 'cmbird_zoho_rate_limit_time', time() );

				return true;
			}
		}

		return false;
	}

	/**
	 * Check if category mappings exist in the database.
	 *
	 * @return bool True if any active category mappings exist, false otherwise.
	 */
	private function has_category_mappings() {
		return ! empty( $this->map()->list( 0, 1, 'active' ) );
	}

	/**
	 * AJAX handler to check if category mappings exist.
	 *
	 * Returns JSON response with has_mappings flag.
	 */
	public function cmbird_zi_check_category_mappings() {
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
		$security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
		if ( ! wp_verify_nonce( $security_token, 'commercebird-app' ) ) {
			wp_send_json_error( array( 'message' => 'Security check failed' ) );
			return;
		}

		$has_mappings = $this->has_category_mappings();

		wp_send_json_success(
			array(
				'has_mappings' => $has_mappings,
				'message'      => $has_mappings ? 'Category mappings found' : 'No category mappings found',
			)
		);
	}

	/**
	 * Parent category sync - handles only root/parent categories bidirectionally.
	 */
	public function cmbird_zi_parent_category_sync() {
		// Check Zoho API rate limit before proceeding.
		if ( $this->is_zoho_rate_limit_exceeded() ) {
			wp_send_json_error(
				array(
					'message' => 'Zoho API rate limit exceeded. Please wait a minute and try again.',
					'data'    => array(),
				)
			);
			return;
		}

		$response   = array();
		$response[] = $this->cmbird_zi_response_message( '-', '-', '--- Parent Categories Sync (Root Level Only) ---' );

		// Get all categories from Zoho and WooCommerce.
		$zoho_categories = $this->cmbird_get_zoho_item_categories();
		$all_categories  = $this->get_all_categories_via_wc_api();

		if ( ! is_array( $zoho_categories ) ) {
			$response[]      = $this->cmbird_zi_response_message( '-', 'Warning: Invalid Zoho categories response; continuing with empty Zoho set', '-' );
			$zoho_categories = array();
		}

		if ( ! is_array( $all_categories ) ) {
			$response[]     = $this->cmbird_zi_response_message( '-', 'Warning: Invalid WooCommerce categories response; continuing with empty WooCommerce set', '-' );
			$all_categories = array();
		}

		if ( empty( $zoho_categories ) && empty( $all_categories ) ) {
			$response[] = $this->cmbird_zi_response_message( '-', 'No parent categories found in Zoho or WooCommerce; nothing to sync', '-' );
			wp_send_json_success(
				array(
					'message' => 'Parent category sync completed - nothing to sync',
					'data'    => $response,
				)
			);
			return;
		}

		// Filter only parent categories (level 0) from WooCommerce.
		$parent_categories = array_filter(
			$all_categories,
			function ( $category ) {
				return $category['parent'] === 0 && 'uncategorized' !== $category['slug'];
			}
		);

		// Filter only parent categories from Zoho (parent_category_id = '-1').
		$zoho_parent_categories = array_filter(
			$zoho_categories,
			function ( $category ) {
				return empty( $category['parent_category_id'] ) || '-1' === $category['parent_category_id'];
			}
		);

		$response[] = $this->cmbird_zi_response_message( '-', sprintf( 'Found %d WooCommerce parent categories and %d Zoho parent categories', count( $parent_categories ), count( $zoho_parent_categories ) ), '-' );

		// Debug: Show available Zoho parent categories for reference.
		if ( ! empty( $zoho_parent_categories ) ) {
			$zoho_names = array_map(
				function ( $cat ) {
					return $cat['name'] ?? $cat['category_name'] ?? '-';
				},
				array_slice( $zoho_parent_categories, 0, 5 )
			);
			$more_count = count( $zoho_parent_categories ) > 5 ? ' (+' . ( count( $zoho_parent_categories ) - 5 ) . ' more)' : '';
			$response[] = $this->cmbird_zi_response_message( '-', '📋 Zoho parent categories: ' . implode( ', ', $zoho_names ) . $more_count, '-' );
		}

		// Process WC to Zoho sync for parent categories.
		$exported_count = 0;
		foreach ( $parent_categories as $category ) {
			// Check existing mapping first WITHOUT validation to avoid deleting valid mappings.
			$existing_mapping = (string) ( $this->map()->zoho_id_for_term( (int) $category['id'] ) ?? '' );

			if ( ! empty( $existing_mapping ) ) {
				// Verify the mapping exists in Zoho categories and points to a ROOT Zoho category.
				$mapped_zoho_category = null;
				foreach ( $zoho_categories as $zoho_category ) {
					if ( $zoho_category['category_id'] === $existing_mapping ) {
						$mapped_zoho_category = $zoho_category;
						break;
					}
				}

				if ( $mapped_zoho_category && $this->is_zoho_root_category( $mapped_zoho_category ) ) {
					$response[] = $this->cmbird_zi_response_message( '-', "✓ VERIFIED: Parent category '{$category['name']}' properly mapped to Zoho (ID: {$existing_mapping})", $category['id'] );
					continue;
				} else {
					$invalid_reason = $mapped_zoho_category
						? 'mapped Zoho category is not root'
						: "Zoho category doesn't exist";
					$response[]     = $this->cmbird_zi_response_message( '-', "⚠️  INVALID: Mapping found but {$invalid_reason}, will remap '{$category['name']}'", $category['id'] );
					$this->map()->forget_term( (int) $category['id'] );
				}
			}

			// Check if category already exists in Zoho by name (case-insensitive).
			$existing_zoho_category = $this->find_zoho_category_by_name( $category['name'], '', $zoho_categories );

			if ( $existing_zoho_category ) {
				// Category exists in Zoho, create the mapping.
				$this->map()->claim_auto( (int) $category['id'], (string) $existing_zoho_category['category_id'] );
				$existing_zoho_name = $existing_zoho_category['name'] ?? $existing_zoho_category['category_name'] ?? '';
				$response[]         = $this->cmbird_zi_response_message( '-', "✓ MAPPED: Found existing Zoho parent category '{$category['name']}' → '{$existing_zoho_name}' (ID: {$existing_zoho_category['category_id']})", $category['id'] );

				// Verify the mapping was saved correctly.
				$saved_mapping = (string) ( $this->map()->zoho_id_for_term( (int) $category['id'] ) ?? '' );
				if ( $saved_mapping !== $existing_zoho_category['category_id'] ) {
					$response[] = $this->cmbird_zi_response_message( '-', "⚠️  WARNING: Mapping save verification failed for '{$category['name']}' - Expected: {$existing_zoho_category['category_id']}, Got: {$saved_mapping}", $category['id'] );
				}
			} else {
				// Category doesn't exist in Zoho, create it.
				$response[] = $this->cmbird_zi_response_message( '-', "🔍 NOT FOUND: WC category '{$category['name']}' not found in Zoho, creating new category...", $category['id'] );

				$term_obj          = new stdClass();
				$term_obj->term_id = $category['id'];
				$term_obj->name    = $category['name'];
				$term_obj->slug    = $category['slug'];
				$term_obj->parent  = 0; // Ensure it's marked as parent.

				$export_result  = $this->cmbird_zi_category_export( $term_obj->name, $term_obj->term_id, '' );
				$decoded_result = json_decode( $export_result, true );

				// Check if export was successful and mapping was created.
				if ( $decoded_result && isset( $decoded_result['code'] ) && ( '0' === $decoded_result['code'] || 0 === $decoded_result['code'] ) ) {
					$response[] = $decoded_result;
					++$exported_count;
				} else {
					$response[] = $this->cmbird_zi_response_message( '-', "ERROR: Failed to export parent category '{$category['name']}' to Zoho", $category['id'] );
				}
			}
		}

		// Process Zoho to WC sync for parent categories.
		$imported_count = 0;
		foreach ( $zoho_parent_categories as $zoho_category ) {
			// Zoho /categories API returns 'name', not 'category_name' (that field is on /items).
			$zoho_cat_name = $zoho_category['name'] ?? $zoho_category['category_name'] ?? '';
			if ( empty( $zoho_cat_name ) ) {
				continue;
			}
			$category_slug = sanitize_title( $zoho_cat_name );
			$existing_term = $this->find_wc_term_by_slug_and_parent( $category_slug, 0 );

			if ( ! $existing_term ) {
				// Create new parent category in WooCommerce.
				$new_term = wp_insert_term(
					$zoho_cat_name,
					'product_cat',
					array( 'parent' => 0 )
				);

				if ( ! is_wp_error( $new_term ) ) {
					// Store the mapping.
					$this->map()->claim_auto( (int) $new_term['term_id'], (string) $zoho_category['category_id'] );
					$response[] = $this->cmbird_zi_response_message( '-', "IMPORT: Created parent category '{$zoho_cat_name}' in WooCommerce", $new_term['term_id'] );
					++$imported_count;
				} else {
					$response[] = $this->cmbird_zi_response_message( '-', "ERROR: Failed to create parent category '{$zoho_cat_name}': " . $new_term->get_error_message(), '-' );
				}
			} else {
				// Update mapping if missing.
				$existing_mapping = (string) ( $this->map()->zoho_id_for_term( (int) $existing_term->term_id ) ?? '' );
				if ( empty( $existing_mapping ) ) {
					$this->map()->claim_auto( (int) $existing_term->term_id, (string) $zoho_category['category_id'] );
					$response[] = $this->cmbird_zi_response_message( '-', "MAPPED: Linked existing parent category '{$existing_term->name}' to Zoho", $existing_term->term_id );
				} else {
					$response[] = $this->cmbird_zi_response_message( '-', "SKIP: Parent category '{$existing_term->name}' already properly mapped", $existing_term->term_id );
				}
			}
		}

		// Reconcile only when we have a complete Zoho snapshot for this run.
		if ( $this->is_last_zoho_fetch_complete() ) {
			$seen_zoho_ids = array_values( array_filter( array_column( array_values( $zoho_categories ), 'category_id' ) ) );
			if ( ! empty( $seen_zoho_ids ) ) {
				$reconcile = $this->map()->reconcile_zoho_presence( array_map( 'strval', $seen_zoho_ids ) );
				if ( $reconcile->orphaned_manual_rows > 0 ) {
					$response[] = $this->cmbird_zi_response_message(
						'-',
						"ℹ️ {$reconcile->orphaned_manual_rows} manually-mapped category(s) flagged as orphan because Zoho did not return them in this sync. Resolve via the Categories admin tab.",
						''
					);
				}
			}
		} else {
			$response[] = $this->cmbird_zi_response_message( '-', 'ℹ️ Skipped mapping reconciliation because Zoho category snapshot was incomplete.', '' );
		}

		// Check if WooCommerce hierarchy enforcement is enabled.
		$cron_settings = get_option( 'cmbird_zoho_inventory_cron', array() );
		if ( is_string( $cron_settings ) ) {
			$cron_settings = json_decode( $cron_settings, true );
		}
		$use_wc_hierarchy = isset( $cron_settings['use_wc_hierarchy'] ) ? $cron_settings['use_wc_hierarchy'] : true; // Default to true.

		if ( $use_wc_hierarchy ) {
			$response[] = $this->cmbird_zi_response_message( '-', '🔧 Enforcing WooCommerce hierarchy in Zoho...', '-' );

			// Get fresh data for hierarchy enforcement.
			$fresh_zoho_categories = $this->cmbird_get_zoho_item_categories();
			$fresh_wc_categories   = $this->get_all_categories_via_wc_api();

			if ( $fresh_zoho_categories && $fresh_wc_categories ) {
				$response = $this->enforce_woocommerce_hierarchy_in_zoho( $fresh_wc_categories, $fresh_zoho_categories, $response );
			}
		}

		$response[] = $this->cmbird_zi_response_message( '-', "✅ PARENT SYNC COMPLETE: Exported {$exported_count} to Zoho, Imported {$imported_count} from Zoho", '-' );

		wp_send_json_success(
			array(
				'message' => 'Parent category sync completed successfully',
				'data'    => $response,
			)
		);
	}

	/**
	 * Subcategory sync - handles only child/subcategories bidirectionally.
	 */
	public function cmbird_zi_subcategory_sync() {
		// Check Zoho API rate limit before proceeding.
		if ( $this->is_zoho_rate_limit_exceeded() ) {
			wp_send_json_error(
				array(
					'message' => 'Zoho API rate limit exceeded. Please wait a minute and try again.',
					'data'    => array(),
				)
			);
			return;
		}

		$response   = array();
		$response[] = $this->cmbird_zi_response_message( '-', '-', '--- Subcategories Sync (Child Categories Only) ---' );

		// Get all categories from Zoho and WooCommerce.
		$zoho_categories = $this->cmbird_get_zoho_item_categories();
		$all_categories  = $this->get_all_categories_via_wc_api();

		if ( ! is_array( $zoho_categories ) ) {
			$response[]      = $this->cmbird_zi_response_message( '-', 'Warning: Invalid Zoho categories response; continuing with empty Zoho set', '-' );
			$zoho_categories = array();
		}

		if ( ! is_array( $all_categories ) ) {
			$response[]     = $this->cmbird_zi_response_message( '-', 'Warning: Invalid WooCommerce categories response; continuing with empty WooCommerce set', '-' );
			$all_categories = array();
		}

		if ( empty( $zoho_categories ) && empty( $all_categories ) ) {
			$response[] = $this->cmbird_zi_response_message( '-', 'No subcategories found in Zoho or WooCommerce; nothing to sync', '-' );
			wp_send_json_success(
				array(
					'message' => 'Subcategory sync completed - nothing to sync',
					'data'    => $response,
				)
			);
			return;
		}

		// Filter only subcategories (level > 0) from WooCommerce.
		$subcategories = array_filter(
			$all_categories,
			function ( $category ) {
				return $category['parent'] > 0 && 'uncategorized' !== $category['slug'];
			}
		);

		// Filter only subcategories from Zoho (has parent_category_id that's not '-1').
		$zoho_subcategories = array_filter(
			$zoho_categories,
			function ( $category ) {
				return ! empty( $category['parent_category_id'] ) && '-1' !== $category['parent_category_id'];
			}
		);

		$response[] = $this->cmbird_zi_response_message( '-', sprintf( 'Found %d WooCommerce subcategories and %d Zoho subcategories', count( $subcategories ), count( $zoho_subcategories ) ), '-' );

		// Process WC to Zoho sync for subcategories using hierarchical approach.
		$exported_count = 0;

		// Organize subcategories by hierarchy level for proper sequential processing.
		$subcategory_levels = $this->organize_categories_by_hierarchy( $subcategories );
		$response[]         = $this->cmbird_zi_response_message( '-', 'Processing subcategories in hierarchical order (level 1, 2, 3...)', '-' );

		// Process each hierarchy level sequentially.
		foreach ( $subcategory_levels as $level => $level_categories ) {
			if ( 0 === $level ) {
				continue; // Skip level 0 (already processed as parent categories).
			}

			$response[] = $this->cmbird_zi_response_message( '-', "Processing hierarchy level {$level} (" . count( $level_categories ) . ' categories)', '-' );

			foreach ( $level_categories as $category ) {
				// First, validate existing mapping.
				$valid_zoho_id = $this->validate_and_fix_category_mapping( $category['id'], $zoho_categories );

				if ( $valid_zoho_id ) {
					$response[] = $this->cmbird_zi_response_message( '-', "✓ VERIFIED: Subcategory '{$category['name']}' properly mapped to Zoho (ID: {$valid_zoho_id})", $category['id'] );
					continue;
				}

				// Get parent category's Zoho ID.
				$parent_zoho_id = '';
				if ( $category['parent'] > 0 ) {
					$parent_zoho_id = (string) ( $this->map()->zoho_id_for_term_any_status( (int) $category['parent'] ) ?? '' );

					// If parent doesn't have a Zoho mapping, try to resolve it automatically.
					if ( empty( $parent_zoho_id ) ) {
						// Get parent category details from WooCommerce.
						$parent_term = get_term( $category['parent'], 'product_cat' );
						if ( ! is_wp_error( $parent_term ) && $parent_term ) {
							$response[] = $this->cmbird_zi_response_message( '-', "⚠️  RESOLVING: Parent category '{$parent_term->name}' (ID: {$category['parent']}) not mapped, attempting to resolve...", $category['id'] );

							// Try to find and map the parent category.
							$existing_parent_zoho = $this->find_zoho_category_by_name( $parent_term->name, '', $zoho_categories );
							if ( $existing_parent_zoho ) {
								// Found parent in Zoho, create mapping.
								$this->map()->claim_auto( (int) $category['parent'], (string) $existing_parent_zoho['category_id'] );
								$parent_zoho_id = $existing_parent_zoho['category_id'];
								$response[]     = $this->cmbird_zi_response_message( '-', "✓ RESOLVED: Mapped parent category '{$parent_term->name}' to Zoho (ID: {$existing_parent_zoho['category_id']})", $category['parent'] );
							} else {
								// Parent not found in Zoho, need to create it first.
								$response[]           = $this->cmbird_zi_response_message( '-', "🔍 CREATING: Parent category '{$parent_term->name}' not found in Zoho, creating...", $category['parent'] );
								$parent_export_result = $this->cmbird_zi_category_export( $parent_term->name, $category['parent'], '' );
								$parent_decoded       = json_decode( $parent_export_result, true );
								if ( $parent_decoded && isset( $parent_decoded['code'] ) && ( '0' === $parent_decoded['code'] || 0 === $parent_decoded['code'] ) ) {
									$parent_zoho_id = (string) ( $this->map()->zoho_id_for_term_any_status( (int) $category['parent'] ) ?? '' );
									$response[]     = $this->cmbird_zi_response_message( '-', "✅ CREATED: Parent category '{$parent_term->name}' created and mapped (ID: {$parent_zoho_id})", $category['parent'] );
									// Add to local cache so sibling subcategories can find it without re-fetching Zoho.
									if ( ! empty( $parent_zoho_id ) ) {
										$zoho_categories[] = array(
											'category_id' => $parent_zoho_id,
											'name'        => $parent_term->name,
											'category_name' => $parent_term->name,
											'parent_category_id' => '-1',
											'url'         => '',
										);
									}
								} else {
									$response[] = $this->cmbird_zi_response_message( '-', "❌ FAILED: Could not create parent category '{$parent_term->name}' in Zoho", $category['parent'] );
									continue;
								}
							}
						} else {
							$response[] = $this->cmbird_zi_response_message( '-', "ERROR: Parent category (ID: {$category['parent']}) not found in WooCommerce for '{$category['name']}'", $category['id'] );
							continue;
						}
					}

					// Final check - if we still don't have a parent mapping, skip this subcategory.
					if ( empty( $parent_zoho_id ) ) {
						$response[] = $this->cmbird_zi_response_message( '-', "ERROR: Could not resolve parent mapping for subcategory '{$category['name']}'", $category['id'] );
						continue;
					}
				}

				// Check if category already exists in Zoho by name and parent.
				$existing_zoho_category = $this->find_zoho_category_by_name( $category['name'], $parent_zoho_id, $zoho_categories );

				if ( $existing_zoho_category ) {
					// Category exists in Zoho, create the mapping.
					$this->map()->claim_auto( (int) $category['id'], (string) $existing_zoho_category['category_id'] );
					$response[] = $this->cmbird_zi_response_message( '-', "✓ MAPPED: Found existing Zoho subcategory '{$category['name']}' (ID: {$existing_zoho_category['category_id']})", $category['id'] );
				} else {
					// Category doesn't exist in Zoho, create it.
					$term_obj          = new stdClass();
					$term_obj->term_id = $category['id'];
					$term_obj->name    = $category['name'];
					$term_obj->slug    = $category['slug'];
					$term_obj->parent  = $category['parent'];

					$export_result = $this->cmbird_zi_category_export( $term_obj->name, $term_obj->term_id, $parent_zoho_id );
					$response[]    = json_decode( $export_result, true );
					++$exported_count;
					// Add to local cache so deeper-level items in the same run can use it as a parent.
					$new_zoho_id = (string) ( $this->map()->zoho_id_for_term( (int) $term_obj->term_id ) ?? '' );
					if ( ! empty( $new_zoho_id ) ) {
						$zoho_categories[] = array(
							'category_id'        => $new_zoho_id,
							'name'               => $term_obj->name,
							'category_name'      => $term_obj->name,
							'parent_category_id' => ! empty( $parent_zoho_id ) ? $parent_zoho_id : '-1',
							'url'                => '',
						);
					}
				}
			}
		}

		// Process Zoho to WC sync for subcategories.
		$imported_count    = 0;
		$zoho_lookup_by_id = $this->build_zoho_category_lookup( $zoho_categories );
		foreach ( $zoho_subcategories as $zoho_category ) {
			// Zoho /categories API returns 'name', not 'category_name'.
			$zoho_cat_name = $zoho_category['name'] ?? $zoho_category['category_name'] ?? '';
			if ( empty( $zoho_cat_name ) ) {
				continue;
			}

			// Resolve parent chain strictly before matching/creating this subcategory.
			$parent_wc_id = 0;
			if ( ! empty( $zoho_category['parent_category_id'] ) && '-1' !== (string) $zoho_category['parent_category_id'] ) {
				$resolved_parent = $this->resolve_wc_parent_term_for_zoho_category(
					(string) $zoho_category['parent_category_id'],
					$zoho_lookup_by_id,
					$response
				);

				if ( null === $resolved_parent ) {
					$response[] = $this->cmbird_zi_response_message( '-', "ERROR: Skipping '{$zoho_cat_name}' because parent chain could not be resolved", '-' );
					continue;
				}

				$parent_wc_id = (int) $resolved_parent;
			}

			$category_slug = sanitize_title( $zoho_cat_name );
			$existing_term = $this->find_wc_term_by_slug_and_parent( $category_slug, $parent_wc_id );

			if ( ! $existing_term ) {
				// Create new subcategory in WooCommerce.
				$new_term = wp_insert_term(
					$zoho_cat_name,
					'product_cat',
					array( 'parent' => $parent_wc_id )
				);

				if ( ! is_wp_error( $new_term ) ) {
					// Store the mapping.
					$this->map()->claim_auto( (int) $new_term['term_id'], (string) $zoho_category['category_id'] );
						$parent_info = $parent_wc_id ? " (under parent ID: {$parent_wc_id})" : ' (as root)';
					$response[]      = $this->cmbird_zi_response_message( '-', "IMPORT: Created subcategory '{$zoho_cat_name}'{$parent_info}", $new_term['term_id'] );
					++$imported_count;
				} else {
					$response[] = $this->cmbird_zi_response_message( '-', "ERROR: Failed to create subcategory '{$zoho_cat_name}': " . $new_term->get_error_message(), '-' );
				}
			} else {
				// Update mapping if missing.
				$existing_mapping = (string) ( $this->map()->zoho_id_for_term( (int) $existing_term->term_id ) ?? '' );
				if ( empty( $existing_mapping ) ) {
					$this->map()->claim_auto( (int) $existing_term->term_id, (string) $zoho_category['category_id'] );
					$response[] = $this->cmbird_zi_response_message( '-', "MAPPED: Linked existing subcategory '{$existing_term->name}' to Zoho", $existing_term->term_id );
				} else {
					$response[] = $this->cmbird_zi_response_message( '-', "SKIP: Subcategory '{$existing_term->name}' already properly mapped", $existing_term->term_id );
				}
			}
		}

		// Reconcile only when we have a complete Zoho snapshot for this run.
		if ( $this->is_last_zoho_fetch_complete() ) {
			$seen_zoho_ids = array_values( array_filter( array_column( array_values( $zoho_categories ), 'category_id' ) ) );
			if ( ! empty( $seen_zoho_ids ) ) {
				$reconcile = $this->map()->reconcile_zoho_presence( array_map( 'strval', $seen_zoho_ids ) );
				if ( $reconcile->orphaned_manual_rows > 0 ) {
					$response[] = $this->cmbird_zi_response_message(
						'-',
						"ℹ️ {$reconcile->orphaned_manual_rows} manually-mapped category(s) flagged as orphan because Zoho did not return them in this sync. Resolve via the Categories admin tab.",
						''
					);
				}
			}
		} else {
			$response[] = $this->cmbird_zi_response_message( '-', 'ℹ️ Skipped mapping reconciliation because Zoho category snapshot was incomplete.', '' );
		}

		// Check if WooCommerce hierarchy enforcement is enabled.
		$cron_settings = get_option( 'cmbird_zoho_inventory_cron', array() );
		if ( is_string( $cron_settings ) ) {
			$cron_settings = json_decode( $cron_settings, true );
		}
		$use_wc_hierarchy = isset( $cron_settings['use_wc_hierarchy'] ) ? $cron_settings['use_wc_hierarchy'] : true; // Default to true.

		if ( $use_wc_hierarchy ) {
			$response[] = $this->cmbird_zi_response_message( '-', '🔧 Enforcing WooCommerce hierarchy in Zoho...', '-' );

			// Get fresh data for hierarchy enforcement.
			$fresh_zoho_categories = $this->cmbird_get_zoho_item_categories();
			$fresh_wc_categories   = $this->get_all_categories_via_wc_api();

			if ( $fresh_zoho_categories && $fresh_wc_categories ) {
				$response = $this->enforce_woocommerce_hierarchy_in_zoho( $fresh_wc_categories, $fresh_zoho_categories, $response );
			}
		}

		$response[] = $this->cmbird_zi_response_message( '-', "✅ SUBCATEGORY SYNC COMPLETE: Exported {$exported_count} to Zoho, Imported {$imported_count} from Zoho", '-' );

		wp_send_json_success(
			array(
				'message' => 'Subcategory sync completed successfully',
				'data'    => $response,
			)
		);
	}

	/**
	 * AJAX handler for removing duplicate categories from both WooCommerce and Zoho.
	 */
	public function cmbird_zi_remove_duplicates_sync() {
		// Check Zoho API rate limit before proceeding.
		if ( $this->is_zoho_rate_limit_exceeded() ) {
			wp_send_json_error(
				array(
					'message' => 'Zoho API rate limit exceeded. Please wait a minute and try again.',
					'data'    => array(),
				)
			);
			return;
		}

		$response   = array();
		$response[] = $this->cmbird_zi_response_message( '-', '-', '--- Removing Duplicate Categories (WooCommerce & Zoho) ---' );

		// Remove duplicates from WooCommerce first.
		$response[] = $this->cmbird_zi_response_message( '-', 'Removing duplicate WooCommerce categories...', '-' );
		$this->cmbird_remove_duplicate_woocommerce_categories();
		$response[] = $this->cmbird_zi_response_message( '-', '✅ WooCommerce duplicate removal completed', '-' );

		// Remove duplicates from Zoho using new async batch processing.
		$zoho_duplicate_results = $this->cmbird_remove_duplicate_zoho_categories();

		// Check if batch processing is required (new async method).
		if ( isset( $zoho_duplicate_results['requires_batching'] ) && $zoho_duplicate_results['requires_batching'] ) {
			// Return session data for async processing.
			wp_send_json_success(
				array(
					'message'          => 'Async duplicate removal started successfully',
					'session_id'       => $zoho_duplicate_results['session_id'],
					'total_pages'      => $zoho_duplicate_results['total_pages'],
					'total_categories' => $zoho_duplicate_results['total_categories'],
					'requires_async'   => true,
				)
			);
		} else {
			// Old synchronous method fallback.
			$response = array_merge( $response, $zoho_duplicate_results );
			wp_send_json_success(
				array(
					'message' => 'Duplicate category cleanup completed successfully',
					'data'    => $response,
				)
			);
		}
	}

	/**
	 * Remove duplicate WooCommerce categories based on Zoho category IDs.
	 */
	public function cmbird_get_zoho_item_categories( $auto_remove_duplicates = false ) {
		// $fd = fopen( __DIR__ . '/cmbird_get_zoho_item_categories.txt', 'a+' );

		// First remove duplicates from WooCommerce categories.
		// $this->cmbird_remove_duplicate_woocommerce_categories();

		// Optionally remove duplicates from Zoho categories (only when explicitly requested).
		if ( $auto_remove_duplicates ) {
			$this->cmbird_remove_duplicate_zoho_categories();
		}

		$zoho_inventory_oid = $this->config['ConnectZI']['OID'];
		$zoho_inventory_url = $this->config['ConnectZI']['APIURL'];

		$execute_curl_call_handle = new CMBIRD_API_Handler_Zoho();
		$all_categories           = array();
		$page                     = 1;
		$is_complete              = false;

		do {
			// Respect Zoho API rate limit before making the call.
			$this->respect_zoho_api_rate_limit();

			$url  = $zoho_inventory_url . 'inventory/v1/categories/?organization_id=' . $zoho_inventory_oid . '&page=' . $page;
			$json = $execute_curl_call_handle->execute_curl_call_get( $url );
			// Log the json response for debugging.
			// fwrite( $fd, print_r( "Page {$page} response: " . print_r( $json, true ) . "\n", true ) );

			// Check for rate limit errors and handle them.
			if ( $this->check_and_handle_rate_limit_error( $json ) ) {
				break;
			}

			// Check if the API call was successful and we got a valid response.
			if ( ! $json || is_wp_error( $json ) ) {
				break;
			}

			$code = isset( $json->code ) ? $json->code : null;

			if ( '0' === $code || 0 === $code ) {
				if ( isset( $json->categories ) && is_array( $json->categories ) ) {
					$categories_on_page = $json->categories;
					$all_categories     = array_merge( $all_categories, $categories_on_page );
					// fwrite( $fd, print_r( 'Fetched ' . count( $categories_on_page ) . ' categories from page ' . $page . "\n", true ) );
				}

				// Check if we have more pages.
				$has_more_page = isset( $json->page_context ) && isset( $json->page_context->has_more_page ) ? $json->page_context->has_more_page : false;

				if ( ! $has_more_page ) {
					$is_complete = true;
					break;
				}
				++$page;
			} else {
				// Error occurred, break the loop.
				break;
			}
		} while ( $page <= 50 ); // Safety limit to prevent infinite loops.

		$this->set_last_zoho_fetch_complete( $is_complete );

		if ( ! empty( $all_categories ) ) {
			$response = $all_categories;
			// Initialize an array to store unique categories.
			$unique_categories = array();

			// Show ALL categories for selection - no filtering by name duplicates.
			// Users should be able to see and select from all available Zoho categories.
			foreach ( $response as $category ) {
				if ( is_object( $category ) && isset( $category->category_id ) ) {
					// Only skip the default -1 category_id.
					// if ( '-1' === $category->category_id ) {.
					// continue;
					// }.

					// Include all categories - let users see everything available in Zoho.
					$unique_categories[] = $category;
				}
			}

			// Reset keys to have a sequential array.
			$unique_categories = array_values( $unique_categories );

		} else {
			$response = array();
			return $response;
		}

		// Debug JSON encoding.
		$response = wp_json_encode( $unique_categories );
		// fwrite( $fd, print_r( $response, true ) );
		// fclose( $fd );

		return json_decode( $response, true );
	}

	/**
	 * Create woocommerce category in zoho inventory.
	 *
	 * @param string $cat_name Category name.
	 * @param string $term_id Term ID.
	 * @param string $pid Parent ID.
	 * @return string JSON encoded response.
	 */
	public function cmbird_zi_category_export( $cat_name, $term_id = '0', $pid = '' ) {

		// Ensure $cat_name is a string (in case an object is passed by mistake).
		if ( is_object( $cat_name ) ) {
			if ( isset( $cat_name->name ) ) {
				$cat_name = $cat_name->name;
			} else {
				return false; // Invalid category name.
			}
		}
		$cat_name = (string) $cat_name;

		$zoho_inventory_oid = $this->config['ConnectZI']['OID'];
		$zoho_inventory_url = $this->config['ConnectZI']['APIURL'];

		if ( ! empty( $pid ) || $pid > 0 ) {
			$zidata = '"name" : "' . $cat_name . '","parent_category_id" : "' . $pid . '",';
		} else {
			$zidata = '"name" : "' . $cat_name . '",';
		}

		$data = array(
			'JSONString' => '{' . $zidata . '}',
		);

		$url = $zoho_inventory_url . 'inventory/v1/categories/?organization_id=' . $zoho_inventory_oid;

		// Respect Zoho API rate limit before making the call.
		$this->respect_zoho_api_rate_limit();

		$execute_curl_call_handle = new CMBIRD_API_Handler_Zoho();
		$json                     = $execute_curl_call_handle->execute_curl_call_post( $url, $data );

		// Check for rate limit errors and handle them.
		if ( $this->check_and_handle_rate_limit_error( $json ) ) {
			return false;
		}

		// Check if the response is valid and has a code property.
		if ( ! $json || ! isset( $json->code ) ) {
			return false;
		}

		$code = $json->code;

		if ( '0' === $code || 0 === $code ) {
			$category_id_saved = false;

			// Handle different response formats.
			if ( isset( $json->category ) ) {
				if ( is_object( $json->category ) ) {
					// Object format - iterate through properties.
					foreach ( $json->category as $key => $value ) {
						if ( 'category_id' === $key ) {
							$this->map()->claim_auto( (int) $term_id, (string) $value );
							$category_id_saved = true;
						}
					}
				} elseif ( is_array( $json->category ) && isset( $json->category['category_id'] ) ) {
					// Array format - direct access.
					$this->map()->claim_auto( (int) $term_id, (string) $json->category['category_id'] );
					$category_id_saved = true;
				}
			}

			// If no category_id was saved, try direct access to category_id.
			if ( ! $category_id_saved && isset( $json->category_id ) ) {
				$this->map()->claim_auto( (int) $term_id, (string) $json->category_id );
				$category_id_saved = true;
			}

			$response_msg = $category_id_saved ? 'Category created and mapped successfully' : 'Category created but mapping failed';
			if ( isset( $json->message ) ) {
				$response_msg = $json->message . ( $category_id_saved ? ' (mapped)' : ' (not mapped)' );
			}
		} else {
			$response_msg = isset( $json->message ) ? $json->message : 'Unknown error response';
		}

		$return = $this->cmbird_zi_response_message( $code, $response_msg, $term_id );
		return wp_json_encode( $return );
	}

	/**
	 * Remove duplicate WooCommerce categories.
	 */
	public function cmbird_remove_duplicate_woocommerce_categories() {
		// Get all product categories, including empty ones.
		$terms = get_terms(
			array(
				'taxonomy'   => 'product_cat',
				'hide_empty' => false, // Include categories with zero product count.
			)
		);

		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return; // No terms found or an error occurred.
		}

		// Create an array to store category names and their associated terms.
		$categories_by_name = array();

		foreach ( $terms as $term ) {
			$name = strtolower( $term->name ); // Normalize the name for comparison.

			// Check if a category with this name is already processed.
			if ( isset( $categories_by_name[ $name ] ) ) {
				$existing_term = $categories_by_name[ $name ];

				if ( 0 < $term->count && 0 === $existing_term->count ) {
					// Keep the current term, remove the existing one.
					wp_delete_term( $existing_term->term_id, 'product_cat' );
					$categories_by_name[ $name ] = $term;
				} elseif ( 0 === $term->count ) {
					// Remove the current term as it has zero product count.
					wp_delete_term( $term->term_id, 'product_cat' );
				}
			} else {
				// Add the term to the array if not already processed.
				$categories_by_name[ $name ] = $term;
			}
		}
	}

	/**
	 * Remove duplicate categories from Zoho Inventory.
	 *
	 * Detects categories with the same name in the same parent hierarchy level
	 * and removes duplicates, keeping the first occurrence.
	 *
	 * @return array Array of results showing what duplicates were found and removed.
	 */
	public function cmbird_remove_duplicate_zoho_categories() {
		// Initialize page-by-page processing for duplicate removal (starting from the end).
		$session_id = 'cmbird_duplicate_removal_' . time();

		// Get all categories and page info.
		$page_info = $this->get_zoho_categories_page_info();

		if ( ! $page_info || ! isset( $page_info['all_categories'] ) ) {
			return array(
				$this->cmbird_zi_response_message( '-', 'Failed to get categories from Zoho API', '-' ),
			);
		}

		$all_categories   = $page_info['all_categories'];
		$total_categories = count( $all_categories );
		$per_page         = $page_info['per_page'];
		$total_pages      = max( 1, ceil( $total_categories / $per_page ) );

		if ( $total_categories <= 0 ) {
			return array(
				$this->cmbird_zi_response_message( '-', 'No categories found in Zoho', '-' ),
			);
		}

		// Split categories into pages (working backwards from end).
		$category_pages = array_chunk( $all_categories, $per_page );
		$category_pages = array_reverse( $category_pages ); // Start from last page.

		// Initialize page-by-page processing session.
		$batch_data = array(
			'total_pages'          => $total_pages,
			'total_categories'     => $total_categories,
			'current_page'         => $total_pages, // Start from last page.
			'processed_pages'      => 0,
			'processed_categories' => 0,
			'removed_successfully' => 0,
			'removal_errors'       => 0,
			'session_id'           => $session_id,
			'start_time'           => time(),
			'category_pages'       => $category_pages, // Store pre-split categories.
		);

		// Store session data.
		set_transient( $session_id, $batch_data, 3600 ); // 1 hour expiry.

		return array(
			'session_id'        => $session_id,
			'total_pages'       => $total_pages,
			'total_categories'  => $total_categories,
			'requires_batching' => true,
		);
	}



	/**
	 * Delete a category from Zoho Inventory.
	 *
	 * @param string $category_id Zoho category ID to delete.
	 * @param string $category_name Category name for logging purposes.
	 * @return array Result with success status and message.
	 */
	private function delete_zoho_category( $category_id, $category_name ) {
		$zoho_inventory_oid = $this->config['ConnectZI']['OID'];
		$zoho_inventory_url = $this->config['ConnectZI']['APIURL'];

		$execute_curl_call_handle = new CMBIRD_API_Handler_Zoho();
		$url                      = $zoho_inventory_url . 'inventory/v1/categories/' . $category_id . '?organization_id=' . $zoho_inventory_oid;

		try {
			// Respect Zoho API rate limit before making the call.
			$this->respect_zoho_api_rate_limit();

			$json = $execute_curl_call_handle->execute_curl_call_delete( $url );

			if ( isset( $json->code ) && ( '0' === $json->code || 0 === $json->code ) ) {
				return array(
					'success' => true,
					'message' => 'Category deleted successfully from Zoho',
				);
			} else {
				return array(
					'success' => false,
					'message' => isset( $json->message ) ? $json->message : 'Unknown error deleting category from Zoho',
				);
			}
		} catch ( Exception $e ) {
			return array(
				'success' => false,
				'message' => 'Exception: ' . $e->getMessage(),
			);
		}
	}

	/**
	 * Clean up WooCommerce mappings that point to a deleted Zoho category.
	 *
	 * @param string $zoho_category_id The Zoho category ID that was deleted.
	 */
	private function cleanup_wc_mappings_for_zoho_category( $zoho_category_id ) {
		$term_id = $this->map()->term_id_for_zoho_id_any_status( (string) $zoho_category_id );
		if ( null !== $term_id ) {
			$this->map()->forget_term( $term_id );
		}
	}

	/**
	 * Get page information for Zoho categories using existing raw categories data.
	 * This avoids additional API calls since we already fetch all categories.
	 *
	 * @return array|false Page info or false on error.
	 */
	private function get_zoho_categories_page_info() {
		// Get all categories using existing function that already loops through all pages.
		$all_categories = $this->cmbird_get_zoho_item_categories_raw();

		if ( empty( $all_categories ) || ! is_array( $all_categories ) ) {
			return false;
		}

		$total_categories = count( $all_categories );
		$per_page         = 25; // Standard page size.
		$total_pages      = max( 1, ceil( $total_categories / $per_page ) );

		return array(
			'total_pages'      => $total_pages,
			'total_categories' => $total_categories,
			'per_page'         => $per_page,
			'has_more_page'    => $total_pages > 1,
			'all_categories'   => $all_categories, // Include the actual data.
		);
	}



	/**
	 * Get categories from Zoho without duplicate removal (raw data).
	 * This prevents infinite loops when calling from duplicate removal methods.
	 *
	 * @return array Raw categories from Zoho API.
	 */
	private function cmbird_get_zoho_item_categories_raw() {
		$zoho_inventory_oid = $this->config['ConnectZI']['OID'];
		$zoho_inventory_url = $this->config['ConnectZI']['APIURL'];

		$execute_curl_call_handle = new CMBIRD_API_Handler_Zoho();
		$all_categories           = array();
		$page                     = 1;
		$is_complete              = false;

		do {
			// Respect Zoho API rate limit before making the call.
			$this->respect_zoho_api_rate_limit();

			$url  = $zoho_inventory_url . 'inventory/v1/categories/?organization_id=' . $zoho_inventory_oid . '&page=' . $page;
			$json = $execute_curl_call_handle->execute_curl_call_get( $url );

			// Check if the API call was successful and we got a valid response.
			if ( ! $json || is_wp_error( $json ) ) {
				break;
			}

			$code = isset( $json->code ) ? $json->code : null;

			if ( '0' === $code || 0 === $code ) {
				if ( isset( $json->categories ) && is_array( $json->categories ) ) {
					$categories_on_page = $json->categories;
					$all_categories     = array_merge( $all_categories, $categories_on_page );
				}

				// Check if we have more pages.
				$has_more_page = isset( $json->page_context ) && isset( $json->page_context->has_more_page ) ? $json->page_context->has_more_page : false;

				if ( ! $has_more_page ) {
					$is_complete = true;
					break;
				}
				++$page;
			} else {
				// Error occurred, break the loop.
				break;
			}
		} while ( $page <= 50 ); // Safety limit to prevent infinite loops.

		$this->set_last_zoho_fetch_complete( $is_complete );

		if ( ! empty( $all_categories ) ) {
			// Convert objects to arrays for consistency.
			$response = array();
			foreach ( $all_categories as $category ) {
				if ( is_object( $category ) && isset( $category->category_id ) ) {
					// Zoho /categories API uses 'name', not 'category_name' (that field is on /items).
					$cat_name   = isset( $category->name ) ? $category->name : '';
					$response[] = array(
						'category_id'        => $category->category_id,
						'name'               => $cat_name,
						'category_name'      => $cat_name,
						'parent_category_id' => isset( $category->parent_category_id ) ? $category->parent_category_id : '-1',
						'url'                => isset( $category->url ) ? $category->url : '',
					);
				}
			}
			return $response;
		} else {
			return array();
		}
	}

	/**
	 * Get all categories using WooCommerce REST API.
	 *
	 * @return array Array of categories with hierarchical information.
	 */
	private function get_all_categories_via_wc_api() {
		$endpoint = '/wc/v3/products/categories';
		add_filter( 'woocommerce_rest_check_permissions', '__return_true' );
		$request = new \WP_REST_Request( 'GET', $endpoint );
		$request->set_query_params(
			array(
				'per_page' => 100, // Get up to 100 categories per page.
				'orderby'  => 'name',
				'order'    => 'asc',
			)
		);

		$categories = array();
		$page       = 1;

		do {
			$request->set_query_params(
				array(
					'per_page' => 100,
					'page'     => $page,
					'orderby'  => 'name',
					'order'    => 'asc',
				)
			);

			$response = rest_do_request( $request );

			if ( $response->is_error() ) {
				break;
			}

			$data = $response->get_data();

			if ( empty( $data ) ) {
				break;
			}

			// Process each category and add hierarchy information.
			foreach ( $data as $category ) {
				// Skip uncategorized.
				if ( 'uncategorized' === $category['slug'] ) {
					continue;
				}

				$categories[] = array(
					'id'          => $category['id'],
					'name'        => $category['name'],
					'slug'        => $category['slug'],
					'parent'      => $category['parent'],
					'parent_name' => $this->get_parent_category_name( $category['parent'] ),
					'level'       => $this->calculate_category_level( $category['parent'] ),
				);
			}

			++$page;
			$data_count = count( $data );
		} while ( 100 === $data_count ); // Continue if we got a full page.
		remove_filter( 'woocommerce_rest_check_permissions', '__return_true' );
		return $categories;
	}

	/**
	 * Calculate category level based on parent hierarchy.
	 *
	 * @param int $parent_id Parent category ID.
	 * @return int Category level (0 for root, 1+ for subcategories).
	 */
	private function calculate_category_level( $parent_id ) {
		if ( 0 === $parent_id ) {
			return 0; // Root category.
		}

		$level      = 1;
		$current_id = $parent_id;

		// Walk up the hierarchy to count levels.
		while ( $current_id > 0 ) {
			$parent_term = get_term( $current_id, 'product_cat' );
			if ( is_wp_error( $parent_term ) || ! $parent_term ) {
				break;
			}

			if ( 0 === $parent_term->parent ) {
				break;
			}

			$current_id = $parent_term->parent;
			++$level;
		}

		return $level;
	}

	/**
	 * Get parent category name by ID.
	 *
	 * @param int $parent_id Parent category ID.
	 * @return string|null Parent category name or null if no parent.
	 */
	private function get_parent_category_name( $parent_id ) {
		if ( 0 === $parent_id ) {
			return null;
		}

		$parent_term = get_term( $parent_id, 'product_cat' );
		if ( is_wp_error( $parent_term ) || ! $parent_term ) {
			return null;
		}

		return $parent_term->name;
	}

	/**
	 * Enforce WooCommerce hierarchy in Zoho by updating parent-child relationships.
	 *
	 * @param array $wc_categories WooCommerce categories from API.
	 * @param array $zoho_categories Zoho categories.
	 * @param array $response Response array for logging.
	 * @return array Updated response array.
	 */
	private function enforce_woocommerce_hierarchy_in_zoho( $wc_categories, $zoho_categories, $response ) {
		$zoho_by_id = array();
		foreach ( $zoho_categories as $zoho_cat ) {
			$zoho_id = isset( $zoho_cat['category_id'] ) ? (string) $zoho_cat['category_id'] : '';
			if ( '' === $zoho_id ) {
				continue;
			}
			$zoho_by_id[ $zoho_id ] = $zoho_cat;
		}

		$hierarchy_fixes  = 0;
		$hierarchy_errors = 0;
		$hierarchy_skips  = 0;

		foreach ( $wc_categories as $wc_cat ) {
			$wc_term_id  = (int) ( $wc_cat['id'] ?? 0 );
			$zoho_cat_id = (string) ( $this->map()->zoho_id_for_term_any_status( $wc_term_id ) ?? '' );

			if ( '' === $zoho_cat_id ) {
				++$hierarchy_skips;
				continue;
			}

			if ( ! isset( $zoho_by_id[ $zoho_cat_id ] ) ) {
				$response[] = $this->cmbird_zi_response_message(
					$zoho_cat_id,
					"WARNING: Zoho category ID {$zoho_cat_id} mapped for '{$wc_cat['name']}' was not found in current Zoho snapshot",
					$wc_term_id
				);
				++$hierarchy_errors;
				continue;
			}

			$zoho_cat                = $zoho_by_id[ $zoho_cat_id ];
			$expected_parent_zoho_id = '-1'; // Default to root.

			// If WooCommerce category has a parent, find the corresponding Zoho parent ID.
			if ( (int) $wc_cat['parent'] > 0 ) {
				$expected_parent_zoho_id = (string) ( $this->map()->zoho_id_for_term_any_status( (int) $wc_cat['parent'] ) ?? '' );
				if ( '' === $expected_parent_zoho_id ) {
					$response[] = $this->cmbird_zi_response_message(
						$zoho_cat_id,
						"WARNING: Missing mapped Zoho parent for '{$wc_cat['name']}' (WC parent term {$wc_cat['parent']})",
						$wc_term_id
					);
					++$hierarchy_errors;
					continue;
				}
			}

			$current_parent = isset( $zoho_cat['parent_category_id'] ) && '' !== (string) $zoho_cat['parent_category_id']
				? (string) $zoho_cat['parent_category_id']
				: '-1';

			// Check if hierarchy needs fixing.
			if ( $current_parent !== $expected_parent_zoho_id ) {
				$response[] = $this->cmbird_zi_response_message(
					$zoho_cat_id,
					"HIERARCHY MISMATCH: '{$wc_cat['name']}' - Current parent: {$current_parent}, Expected: {$expected_parent_zoho_id}",
					$wc_term_id
				);

				// Update the category hierarchy in Zoho.
				$result = $this->update_zoho_category_parent( $zoho_cat_id, $expected_parent_zoho_id );

				if ( $result['success'] ) {
					$response[] = $this->cmbird_zi_response_message(
						$zoho_cat_id,
						"✅ HIERARCHY FIXED: '{$wc_cat['name']}' now has correct parent",
						$wc_term_id
					);
					++$hierarchy_fixes;
				} else {
					$response[] = $this->cmbird_zi_response_message(
						$zoho_cat_id,
						"❌ HIERARCHY FIX FAILED: '{$wc_cat['name']}' - {$result['message']}",
						$wc_term_id
					);
					++$hierarchy_errors;
				}
			}
		}

		$response[] = $this->cmbird_zi_response_message(
			'-',
			"HIERARCHY ENFORCEMENT COMPLETE: {$hierarchy_fixes} fixed, {$hierarchy_errors} errors, {$hierarchy_skips} skipped",
			'-'
		);

		return $response;
	}

	/**
	 * Update a Zoho category's parent relationship.
	 *
	 * @param string $category_id    Zoho category ID.
	 * @param string $new_parent_id  New parent category ID ('-1' for root).
	 * @return array Result with success status and message.
	 */
	private function update_zoho_category_parent( $category_id, $new_parent_id ) {
		$zoho_inventory_oid       = $this->config['ConnectZI']['OID'];
		$zoho_inventory_url       = $this->config['ConnectZI']['APIURL'];
		$execute_curl_call_handle = new CMBIRD_API_Handler_Zoho();

		// Prepare the update data.
		$json_data = array();

		if ( '-1' === $new_parent_id ) {
			$json_data['parent_category_id'] = '';  // Empty string makes it root category.
		} else {
			$json_data['parent_category_id'] = $new_parent_id;
		}

		$update_data = array(
			'JSONString' => wp_json_encode( $json_data ),
		);

		$url = $zoho_inventory_url . 'inventory/v1/categories/' . $category_id . '?organization_id=' . $zoho_inventory_oid;

		try {
			// Respect Zoho API rate limit before making the call.
			$this->respect_zoho_api_rate_limit();

			$json = $execute_curl_call_handle->execute_curl_call_put( $url, $update_data );

			if ( isset( $json->code ) && ( '0' === $json->code || 0 === $json->code ) ) {
				return array(
					'success' => true,
					'message' => 'Category hierarchy updated successfully',
				);
			} else {
				return array(
					'success' => false,
					'message' => isset( $json->message ) ? $json->message : 'Unknown error updating category',
				);
			}
		} catch ( Exception $e ) {
			return array(
				'success' => false,
				'message' => 'Exception: ' . $e->getMessage(),
			);
		}
	}

	/**
	 * Recursively ensure a parent category and its ancestors exist in Zoho.
	 *
	 * @param int $parent_term_id The WooCommerce term ID of the parent category.
	 * @return array Result array with 'success' boolean and 'messages' array.
	 */
	private function ensure_parent_category_exists_in_zoho( $parent_term_id ) {
		$result = array(
			'success'  => false,
			'messages' => array(),
		);

		// Get the parent term.
		$parent_term = get_term( $parent_term_id, 'product_cat' );
		if ( ! $parent_term || is_wp_error( $parent_term ) ) {
			return $result;
		}

		// Check if this parent already exists in Zoho.
		$parent_zoho_id = (string) ( $this->map()->zoho_id_for_term_any_status( (int) $parent_term_id ) ?? '' );
		if ( ! empty( $parent_zoho_id ) ) {
			$result['success'] = true;
			return $result;
		}

		// If the parent has its own parent, ensure that exists first (recursive).
		$grandparent_zoho_id = '';
		if ( $parent_term->parent > 0 ) {
			$grandparent_result = $this->ensure_parent_category_exists_in_zoho( $parent_term->parent );
			if ( ! $grandparent_result['success'] ) {
				return $grandparent_result; // Propagate failure up the chain.
			}
			$result['messages']  = array_merge( $result['messages'], $grandparent_result['messages'] );
			$grandparent_zoho_id = (string) ( $this->map()->zoho_id_for_term_any_status( (int) $parent_term->parent ) ?? '' );
		}

		// Now create this parent category with proper parent ID.
		$parent_export_result = $this->cmbird_zi_category_export( $parent_term->name, $parent_term->term_id, $grandparent_zoho_id );
		$parent_result_data   = json_decode( $parent_export_result, true );

		if ( $parent_result_data ) {
			$result['messages'][] = $parent_result_data;
			$result['success']    = true;
		}

		return $result;
	}

	/**
	 * Start async subcategory sync process.
	 * Prepares the data and creates a progress tracking session.
	 */
	public function cmbird_zi_subcategory_sync_start() {
		// Security check.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
		$security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
		if ( ! wp_verify_nonce( $security_token, 'commercebird-app' ) ) {
			wp_send_json_error( array( 'message' => 'Security check failed' ) );
			return;
		}

		// Check Zoho API rate limit before proceeding.
		if ( $this->is_zoho_rate_limit_exceeded() ) {
			wp_send_json_error(
				array(
					'message' => 'Zoho API rate limit exceeded. Please wait a minute and try again.',
				)
			);
			return;
		}

		// Get all categories from Zoho and WooCommerce.
		$zoho_categories = $this->cmbird_get_zoho_item_categories();
		$all_categories  = $this->get_all_categories_via_wc_api();

		if ( ! is_array( $zoho_categories ) ) {
			$zoho_categories = array();
		}

		if ( ! is_array( $all_categories ) ) {
			$all_categories = array();
		}

		if ( empty( $zoho_categories ) && empty( $all_categories ) ) {
			wp_send_json_success(
				array(
					'session_id' => '',
					'total_wc'   => 0,
					'total_zoho' => 0,
					'message'    => 'No subcategories found in Zoho or WooCommerce; nothing to sync',
				)
			);
			return;
		}

		// Filter subcategories for processing.
		$wc_subcategories_raw = array_filter(
			$all_categories,
			function ( $category ) {
				return $category['parent'] > 0 && 'uncategorized' !== $category['slug'];
			}
		);

		$zoho_subcategories = array_filter(
			$zoho_categories,
			function ( $category ) {
				return ! empty( $category['parent_category_id'] ) && '-1' !== $category['parent_category_id'];
			}
		);

		// Organize WC subcategories by hierarchy for proper processing order.
		$subcategory_levels = $this->organize_categories_by_hierarchy( $wc_subcategories_raw );

		// Flatten hierarchical categories into processing order (level 1, then 2, then 3, etc.).
		$wc_subcategories = array();
		foreach ( $subcategory_levels as $level => $level_categories ) {
			if ( 0 === $level ) {
				continue; // Skip level 0 (parent categories).
			}
			$wc_subcategories = array_merge( $wc_subcategories, $level_categories );
		}

		// Prepare data for batch processing.
		$batch_data = array(
			'wc_subcategories'   => $wc_subcategories,
			'zoho_subcategories' => $zoho_subcategories,
			'zoho_categories'    => $zoho_categories,
			'total_wc'           => count( $wc_subcategories ),
			'total_zoho'         => count( $zoho_subcategories ),
			'processed_wc'       => 0,
			'processed_zoho'     => 0,
			'exported_count'     => 0,
			'imported_count'     => 0,
			'current_step'       => 'wc_to_zoho',
			'batch_size'         => 5, // Process 5 categories per batch to avoid timeouts.
			'messages'           => array(),
		);

		// Store batch data in WordPress options with expiration (1 hour).
		$session_id = 'subcategory_sync_' . time() . '_' . wp_generate_password( 8, false );
		set_transient( $session_id, $batch_data, 3600 );

		wp_send_json_success(
			array(
				'session_id' => $session_id,
				'total_wc'   => $batch_data['total_wc'],
				'total_zoho' => $batch_data['total_zoho'],
				'message'    => 'Async subcategory sync initialized',
			)
		);
	}

	/**
	 * Process a batch of subcategories async.
	 */
	public function cmbird_zi_subcategory_sync_batch() {
		// Security check.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
		$security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
		if ( ! wp_verify_nonce( $security_token, 'commercebird-app' ) ) {
			wp_send_json_error( array( 'message' => 'Security check failed' ) );
			return;
		}
		$session_id = sanitize_text_field( wp_unslash( $_POST['session_id'] ?? '' ) );
		if ( empty( $session_id ) ) {
			wp_send_json_error( array( 'message' => 'Invalid session ID' ) );
			return;
		}

		// Get batch data.
		$batch_data = get_transient( $session_id );
		if ( false === $batch_data ) {
			wp_send_json_error( array( 'message' => 'Session expired or invalid' ) );
			return;
		}

		$processed_this_batch = 0;
		$batch_messages       = array();

		// Process WC to Zoho sync.
		if ( 'wc_to_zoho' === $batch_data['current_step'] ) {
			$remaining_wc = array_slice( $batch_data['wc_subcategories'], $batch_data['processed_wc'] );
			$batch_items  = array_slice( $remaining_wc, 0, $batch_data['batch_size'] );

			// Fetch Zoho categories once per batch to avoid repeated API calls and prevent.
			// rate-limited partial results from causing false "not found" checks that delete.
			// valid mappings and trigger duplicate category creation.
			$fresh_zoho_categories = $this->cmbird_get_zoho_item_categories_raw();

			foreach ( $batch_items as $category ) {
				// Validate existing mapping using the shared batch snapshot.
				$valid_zoho_id = $this->validate_and_fix_category_mapping( $category['id'], $fresh_zoho_categories );

				if ( $valid_zoho_id ) {
					$batch_messages[] = $this->cmbird_zi_response_message( '-', "✓ VERIFIED: Subcategory '{$category['name']}' properly mapped to Zoho (ID: {$valid_zoho_id})", $category['id'] );
					++$processed_this_batch;
					continue;
				}

				// Get parent category's Zoho ID.
				$parent_zoho_id = '';
				if ( $category['parent'] > 0 ) {
					$parent_zoho_id = (string) ( $this->map()->zoho_id_for_term_any_status( (int) $category['parent'] ) ?? '' );

					// If parent doesn't have a Zoho mapping, try to resolve it automatically.
					if ( empty( $parent_zoho_id ) ) {
						// Get parent category details from WooCommerce.
						$parent_term = get_term( $category['parent'], 'product_cat' );
						if ( ! is_wp_error( $parent_term ) && $parent_term ) {
							$batch_messages[] = $this->cmbird_zi_response_message( '-', "⚠️ RESOLVING: Parent category '{$parent_term->name}' (ID: {$category['parent']}) not mapped, attempting to resolve...", $category['id'] );

							// Try to find and map the parent category.
							$existing_parent_zoho = $this->find_zoho_category_by_name( $parent_term->name, '', $fresh_zoho_categories );
							if ( $existing_parent_zoho ) {
								// Found parent in Zoho, create mapping.
								$this->map()->claim_auto( (int) $category['parent'], (string) $existing_parent_zoho['category_id'] );
								$parent_zoho_id   = $existing_parent_zoho['category_id'];
								$batch_messages[] = $this->cmbird_zi_response_message( '-', "✓ RESOLVED: Mapped parent category '{$parent_term->name}' to Zoho (ID: {$existing_parent_zoho['category_id']})", $category['parent'] );
							} else {
								// Parent not found in Zoho, need to create it first.
								$batch_messages[]     = $this->cmbird_zi_response_message( '-', "🔍 CREATING: Parent category '{$parent_term->name}' not found in Zoho, creating...", $category['parent'] );
								$parent_export_result = $this->cmbird_zi_category_export( $parent_term->name, $category['parent'], '' );
								$parent_decoded       = json_decode( $parent_export_result, true );
								if ( $parent_decoded && isset( $parent_decoded['code'] ) && ( '0' === $parent_decoded['code'] || 0 === $parent_decoded['code'] ) ) {
									$parent_zoho_id   = (string) ( $this->map()->zoho_id_for_term_any_status( (int) $category['parent'] ) ?? '' );
									$batch_messages[] = $this->cmbird_zi_response_message( '-', "✅ CREATED: Parent category '{$parent_term->name}' created and mapped (ID: {$parent_zoho_id})", $category['parent'] );
									// Add to local cache so sibling subcategories in this batch can find it without re-fetching.
									if ( ! empty( $parent_zoho_id ) ) {
										$fresh_zoho_categories[] = array(
											'category_id' => $parent_zoho_id,
											'name'        => $parent_term->name,
											'category_name' => $parent_term->name,
											'parent_category_id' => '-1',
											'url'         => '',
										);
									}
								} else {
									$batch_messages[] = $this->cmbird_zi_response_message( '-', "❌ FAILED: Could not create parent category '{$parent_term->name}' in Zoho", $category['parent'] );
									++$processed_this_batch;
									continue;
								}
							}
						} else {
							$batch_messages[] = $this->cmbird_zi_response_message( '-', "ERROR: Parent category (ID: {$category['parent']}) not found in WooCommerce for '{$category['name']}'", $category['id'] );
							++$processed_this_batch;
							continue;
						}
					}

					// Final check - if we still don't have a parent mapping, skip this subcategory.
					if ( empty( $parent_zoho_id ) ) {
						$batch_messages[] = $this->cmbird_zi_response_message( '-', "ERROR: Could not resolve parent mapping for subcategory '{$category['name']}'", $category['id'] );
						++$processed_this_batch;
						continue;
					}
				}

				// Check if category already exists in Zoho by name and parent.
				$existing_zoho_category = $this->find_zoho_category_by_name( $category['name'], $parent_zoho_id, $fresh_zoho_categories );

				if ( $existing_zoho_category ) {
					// Category exists in Zoho, create the mapping.
					$this->map()->claim_auto( (int) $category['id'], (string) $existing_zoho_category['category_id'] );
					$batch_messages[] = $this->cmbird_zi_response_message( '-', "✓ MAPPED: Found existing Zoho subcategory '{$category['name']}' (ID: {$existing_zoho_category['category_id']})", $category['id'] );
				} else {
					// Category doesn't exist in Zoho, create it.
					$term_obj          = new stdClass();
					$term_obj->term_id = $category['id'];
					$term_obj->name    = $category['name'];
					$term_obj->slug    = $category['slug'];
					$term_obj->parent  = $category['parent'];

					$export_result    = $this->cmbird_zi_category_export( $term_obj->name, $term_obj->term_id, $parent_zoho_id );
					$batch_messages[] = json_decode( $export_result, true );
					++$batch_data['exported_count'];
					// Add to local cache so deeper-level items in this batch can use it as a parent.
					$new_zoho_id = (string) ( $this->map()->zoho_id_for_term( (int) $term_obj->term_id ) ?? '' );
					if ( ! empty( $new_zoho_id ) ) {
						$fresh_zoho_categories[] = array(
							'category_id'        => $new_zoho_id,
							'name'               => $term_obj->name,
							'category_name'      => $term_obj->name,
							'parent_category_id' => ! empty( $parent_zoho_id ) ? $parent_zoho_id : '-1',
							'url'                => '',
						);
					}
				}

				++$processed_this_batch;
			}

			$batch_data['processed_wc'] += $processed_this_batch;

			// Check if WC to Zoho sync is complete.
			if ( $batch_data['processed_wc'] >= $batch_data['total_wc'] ) {
				$batch_data['current_step'] = 'zoho_to_wc';
			}
		} elseif ( 'zoho_to_wc' === $batch_data['current_step'] ) {
			$remaining_zoho    = array_slice( $batch_data['zoho_subcategories'], $batch_data['processed_zoho'] );
			$batch_items       = array_slice( $remaining_zoho, 0, $batch_data['batch_size'] );
			$zoho_lookup_by_id = $this->build_zoho_category_lookup( is_array( $batch_data['zoho_categories'] ?? null ) ? $batch_data['zoho_categories'] : $batch_data['zoho_subcategories'] );

			foreach ( $batch_items as $zoho_category ) {
				// Zoho /categories API returns 'name', not 'category_name'.
				$zoho_cat_name = $zoho_category['name'] ?? $zoho_category['category_name'] ?? '';
				if ( empty( $zoho_cat_name ) ) {
					++$processed_this_batch;
					continue;
				}

				// Resolve parent chain strictly before matching/creating this subcategory.
				$parent_wc_id = 0;
				if ( ! empty( $zoho_category['parent_category_id'] ) && '-1' !== (string) $zoho_category['parent_category_id'] ) {
					$resolved_parent = $this->resolve_wc_parent_term_for_zoho_category(
						(string) $zoho_category['parent_category_id'],
						$zoho_lookup_by_id,
						$batch_messages
					);

					if ( null === $resolved_parent ) {
						$batch_messages[] = $this->cmbird_zi_response_message( '-', "ERROR: Skipping '{$zoho_cat_name}' because parent chain could not be resolved", '-' );
						++$processed_this_batch;
						continue;
					}

					$parent_wc_id = (int) $resolved_parent;
				}

				$category_slug = sanitize_title( $zoho_cat_name );
				$existing_term = $this->find_wc_term_by_slug_and_parent( $category_slug, $parent_wc_id );

				if ( ! $existing_term ) {
					// Create new subcategory in WooCommerce.
					$new_term = wp_insert_term(
						$zoho_cat_name,
						'product_cat',
						array( 'parent' => $parent_wc_id )
					);

					if ( ! is_wp_error( $new_term ) ) {
						// Store the mapping.
						$this->map()->claim_auto( (int) $new_term['term_id'], (string) $zoho_category['category_id'] );
						$parent_info      = $parent_wc_id ? " (under parent ID: {$parent_wc_id})" : ' (as root)';
						$batch_messages[] = $this->cmbird_zi_response_message( '-', "IMPORT: Created subcategory '{$zoho_cat_name}'{$parent_info}", $new_term['term_id'] );
						++$batch_data['imported_count'];
					} else {
						$batch_messages[] = $this->cmbird_zi_response_message( '-', "ERROR: Failed to create subcategory '{$zoho_cat_name}': " . $new_term->get_error_message(), '-' );
					}
				} else {
					// Update mapping if missing.
					$existing_mapping = (string) ( $this->map()->zoho_id_for_term( (int) $existing_term->term_id ) ?? '' );
					if ( empty( $existing_mapping ) ) {
						$this->map()->claim_auto( (int) $existing_term->term_id, (string) $zoho_category['category_id'] );
						$batch_messages[] = $this->cmbird_zi_response_message( '-', "MAPPED: Linked existing subcategory '{$existing_term->name}' to Zoho", $existing_term->term_id );
					} else {
						$batch_messages[] = $this->cmbird_zi_response_message( '-', "SKIP: Subcategory '{$existing_term->name}' already properly mapped", $existing_term->term_id );
					}
				}

				++$processed_this_batch;
			}

			$batch_data['processed_zoho'] += $processed_this_batch;

			// Check if Zoho to WC sync is complete.
			if ( $batch_data['processed_zoho'] >= $batch_data['total_zoho'] ) {
				$batch_data['current_step'] = 'hierarchy_enforcement';
			}
		} elseif ( 'hierarchy_enforcement' === $batch_data['current_step'] ) {
			// Check if WooCommerce hierarchy enforcement is enabled.
			$cron_settings = get_option( 'cmbird_zoho_inventory_cron', array() );
			if ( is_string( $cron_settings ) ) {
				$cron_settings = json_decode( $cron_settings, true );
			}
			$use_wc_hierarchy = isset( $cron_settings['use_wc_hierarchy'] ) ? $cron_settings['use_wc_hierarchy'] : true;

			// Get fresh data for hierarchy enforcement and reconcile.
			$fresh_zoho_categories = $this->cmbird_get_zoho_item_categories();
			$fresh_wc_categories   = $this->get_all_categories_via_wc_api();

			if ( $use_wc_hierarchy ) {
				$batch_messages[] = $this->cmbird_zi_response_message( '-', '🔧 Enforcing WooCommerce hierarchy in Zoho...', '-' );

				if ( $fresh_zoho_categories && $fresh_wc_categories ) {
					$hierarchy_messages = $this->enforce_woocommerce_hierarchy_in_zoho( $fresh_wc_categories, $fresh_zoho_categories, array() );
					$batch_messages     = array_merge( $batch_messages, $hierarchy_messages );
				}
			}

			// Reconcile only when we have a complete Zoho snapshot for this run.
			if ( $this->is_last_zoho_fetch_complete() && is_array( $fresh_zoho_categories ) && ! empty( $fresh_zoho_categories ) ) {
				$seen_zoho_ids = array_values( array_filter( array_column( array_values( $fresh_zoho_categories ), 'category_id' ) ) );
				if ( ! empty( $seen_zoho_ids ) ) {
					$reconcile = $this->map()->reconcile_zoho_presence( array_map( 'strval', $seen_zoho_ids ) );
					if ( $reconcile->orphaned_manual_rows > 0 ) {
						$batch_messages[] = $this->cmbird_zi_response_message(
							'-',
							"ℹ️ {$reconcile->orphaned_manual_rows} manually-mapped category(s) flagged as orphan because Zoho did not return them in this sync. Resolve via the Categories admin tab.",
							''
						);
					}
				}
			} else {
				$batch_messages[] = $this->cmbird_zi_response_message( '-', 'ℹ️ Skipped mapping reconciliation because Zoho category snapshot was incomplete.', '' );
			}

			$batch_data['current_step'] = 'completed';
		}

		// Add batch messages to overall messages.
		$batch_data['messages'] = array_merge( $batch_data['messages'], $batch_messages );

		// Update batch data.
		set_transient( $session_id, $batch_data, 3600 );

		// Calculate progress.
		$total_operations = $batch_data['total_wc'] + $batch_data['total_zoho'];
		$completed        = $batch_data['processed_wc'] + $batch_data['processed_zoho'];
		$progress         = $total_operations > 0 ? ( $completed / $total_operations ) * 100 : 100;

		// Add hierarchy enforcement to progress if needed.
		if ( 'hierarchy_enforcement' === $batch_data['current_step'] ) {
			$progress = 95; // Almost complete.
		} elseif ( 'completed' === $batch_data['current_step'] ) {
			$progress = 100;
		}

		wp_send_json_success(
			array(
				'progress'       => round( $progress, 1 ),
				'current_step'   => $batch_data['current_step'],
				'processed_wc'   => $batch_data['processed_wc'],
				'processed_zoho' => $batch_data['processed_zoho'],
				'total_wc'       => $batch_data['total_wc'],
				'total_zoho'     => $batch_data['total_zoho'],
				'exported_count' => $batch_data['exported_count'],
				'imported_count' => $batch_data['imported_count'],
				'messages'       => $batch_messages,
				'is_complete'    => 'completed' === $batch_data['current_step'],
			)
		);
	}

	/**
	 * Get the status of async subcategory sync.
	 */
	public function cmbird_zi_subcategory_sync_status() {
		// Security check.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
		$security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
		if ( ! wp_verify_nonce( $security_token, 'commercebird-app' ) ) {
			wp_send_json_error( array( 'message' => 'Security check failed' ) );
			return;
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Unslashed before sanitization below.
		$session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
		if ( empty( $session_id ) ) {
			wp_send_json_error( array( 'message' => 'Invalid session ID' ) );
			return;
		}

		// Get batch data.
		$batch_data = get_transient( $session_id );
		if ( false === $batch_data ) {
			wp_send_json_error( array( 'message' => 'Session expired or invalid' ) );
			return;
		}

		// Calculate progress.
		$total_operations = $batch_data['total_wc'] + $batch_data['total_zoho'];
		$completed        = $batch_data['processed_wc'] + $batch_data['processed_zoho'];
		$progress         = $total_operations > 0 ? ( $completed / $total_operations ) * 100 : 100;

		if ( 'hierarchy_enforcement' === $batch_data['current_step'] ) {
			$progress = 95;
		} elseif ( 'completed' === $batch_data['current_step'] ) {
			$progress = 100;

			// Clean up session data on completion.
			delete_transient( $session_id );
		}

		wp_send_json_success(
			array(
				'progress'       => round( $progress, 1 ),
				'current_step'   => $batch_data['current_step'],
				'processed_wc'   => $batch_data['processed_wc'],
				'processed_zoho' => $batch_data['processed_zoho'],
				'total_wc'       => $batch_data['total_wc'],
				'total_zoho'     => $batch_data['total_zoho'],
				'exported_count' => $batch_data['exported_count'],
				'imported_count' => $batch_data['imported_count'],
				'all_messages'   => $batch_data['messages'],
				'is_complete'    => 'completed' === $batch_data['current_step'],
			)
		);
	}

	/**
	 * Build lookup map for Zoho categories by category_id.
	 *
	 * @param array $zoho_categories Zoho categories list.
	 * @return array<string,array>
	 */
	private function build_zoho_category_lookup( array $zoho_categories ): array {
		$lookup = array();

		foreach ( $zoho_categories as $zoho_category ) {
			$zoho_id = isset( $zoho_category['category_id'] ) ? (string) $zoho_category['category_id'] : '';
			if ( '' === $zoho_id ) {
				continue;
			}

			$lookup[ $zoho_id ] = $zoho_category;
		}

		return $lookup;
	}

	/**
	 * Resolve parent WC term from Zoho parent chain; create missing parent terms first.
	 *
	 * @param string $zoho_parent_id Zoho parent category id.
	 * @param array  $zoho_lookup Zoho category lookup by id.
	 * @param array  $messages Sync response messages, passed by reference.
	 * @param array  $visited Guard set for recursion loops.
	 * @return int|null Parent WC term id, 0 for root, null when chain cannot be resolved.
	 */
	private function resolve_wc_parent_term_for_zoho_category( string $zoho_parent_id, array $zoho_lookup, array &$messages, array $visited = array() ): ?int {
		$normalized_parent_id = trim( $zoho_parent_id );
		if ( '' === $normalized_parent_id || '-1' === $normalized_parent_id ) {
			return 0;
		}

		if ( in_array( $normalized_parent_id, $visited, true ) ) {
			$messages[] = $this->cmbird_zi_response_message( '-', "ERROR: Circular Zoho parent chain detected at {$normalized_parent_id}", '-' );
			return null;
		}

		$existing_parent_wc_id = $this->map()->term_id_for_zoho_id_any_status( $normalized_parent_id );
		if ( null !== $existing_parent_wc_id ) {
			return (int) $existing_parent_wc_id;
		}

		if ( ! isset( $zoho_lookup[ $normalized_parent_id ] ) ) {
			$messages[] = $this->cmbird_zi_response_message( '-', "ERROR: Zoho parent category {$normalized_parent_id} not present in snapshot", '-' );
			return null;
		}

		$parent_zoho_cat = $zoho_lookup[ $normalized_parent_id ];
		$parent_name     = (string) ( $parent_zoho_cat['name'] ?? $parent_zoho_cat['category_name'] ?? '' );
		if ( '' === $parent_name ) {
			$messages[] = $this->cmbird_zi_response_message( '-', "ERROR: Zoho parent category {$normalized_parent_id} has empty name", '-' );
			return null;
		}

		$visited[]       = $normalized_parent_id;
		$grandparent_id  = isset( $parent_zoho_cat['parent_category_id'] ) ? (string) $parent_zoho_cat['parent_category_id'] : '-1';
		$resolved_parent = $this->resolve_wc_parent_term_for_zoho_category( $grandparent_id, $zoho_lookup, $messages, $visited );
		if ( null === $resolved_parent ) {
			return null;
		}

		$existing_term = get_term_by( 'slug', sanitize_title( $parent_name ), 'product_cat' );
		if ( $existing_term ) {
			$this->map()->claim_auto( (int) $existing_term->term_id, $normalized_parent_id );
			return (int) $existing_term->term_id;
		}

		$new_term = wp_insert_term(
			$parent_name,
			'product_cat',
			array( 'parent' => (int) $resolved_parent )
		);

		if ( is_wp_error( $new_term ) ) {
			$messages[] = $this->cmbird_zi_response_message( '-', "ERROR: Failed creating parent '{$parent_name}' in WooCommerce: " . $new_term->get_error_message(), '-' );
			return null;
		}

		$created_term_id = (int) $new_term['term_id'];
		$this->map()->claim_auto( $created_term_id, $normalized_parent_id );
		$messages[] = $this->cmbird_zi_response_message( '-', "IMPORT: Created missing parent '{$parent_name}' in WooCommerce", (string) $created_term_id );

		return $created_term_id;
	}

	/**
	 * Organize WooCommerce categories by hierarchy level for proper sequential processing.
	 *
	 * @param array $categories All WooCommerce categories.
	 * @return array Categories organized by hierarchy level [level => [categories]].
	 */
	private function organize_categories_by_hierarchy( $categories ) {
		$levels = array();

		// Create a lookup for parent-child relationships.
		$category_lookup = array();
		foreach ( $categories as $category ) {
			$category_lookup[ $category['id'] ] = $category;
		}

		// Function to calculate hierarchy level.
		$get_hierarchy_level = function ( $category_id, $visited = array() ) use ( &$get_hierarchy_level, $category_lookup ) {
			// Prevent infinite loops.
			if ( in_array( $category_id, $visited, true ) ) {
				return 0;
			}

			if ( ! isset( $category_lookup[ $category_id ] ) ) {
				return 0;
			}

			$category = $category_lookup[ $category_id ];
			if ( 0 === $category['parent'] || empty( $category['parent'] ) ) {
				return 0; // Root level.
			}

			$visited[] = $category_id;
			return 1 + $get_hierarchy_level( $category['parent'], $visited );
		};

		// Organize categories by their hierarchy level.
		foreach ( $categories as $category ) {
			if ( 'uncategorized' === $category['slug'] ) {
				continue; // Skip uncategorized.
			}

			$level = $get_hierarchy_level( $category['id'] );
			if ( ! isset( $levels[ $level ] ) ) {
				$levels[ $level ] = array();
			}
			$levels[ $level ][] = $category;
		}

		// Sort levels in ascending order (0, 1, 2, ...).
		ksort( $levels );

		return $levels;
	}

	/**
	 * Check whether a Zoho category is a root category.
	 *
	 * @param array $zoho_category Zoho category row.
	 * @return bool True when the category is root level.
	 */
	private function is_zoho_root_category( array $zoho_category ): bool {
		$parent_id = isset( $zoho_category['parent_category_id'] )
			? (string) $zoho_category['parent_category_id']
			: '';

		return '' === $parent_id || '-1' === $parent_id;
	}

	/**
	 * Find a WooCommerce category term by slug and expected parent term ID.
	 *
	 * @param string $slug Category slug.
	 * @param int    $parent_term_id Expected parent term ID.
	 * @return \WP_Term|false
	 */
	private function find_wc_term_by_slug_and_parent( string $slug, int $parent_term_id ) {
		$terms = get_terms(
			array(
				'taxonomy'   => 'product_cat',
				'hide_empty' => false,
				'slug'       => $slug,
				'parent'     => $parent_term_id,
				'number'     => 1,
			)
		);

		if ( is_wp_error( $terms ) || empty( $terms ) ) {
			return false;
		}

		$term = reset( $terms );
		return $term instanceof \WP_Term ? $term : false;
	}

	/**
	 * Find a Zoho category by name and parent ID.
	 *
	 * @param string $category_name The category name to search for.
	 * @param string $parent_zoho_id The parent Zoho category ID (empty for root categories).
	 * @param array  $zoho_categories All Zoho categories.
	 * @return array|null The matching Zoho category or null if not found.
	 */
	private function find_zoho_category_by_name( $category_name, $parent_zoho_id, $zoho_categories ) {
		// Normalize the search name for comparison.
		$normalized_search_name = trim( strtolower( $category_name ) );

		foreach ( $zoho_categories as $zoho_category ) {
			// Zoho /categories API returns 'name'; the raw fetcher also sets 'category_name' as alias.
			$normalized_zoho_name = trim( strtolower( $zoho_category['name'] ?? $zoho_category['category_name'] ?? '' ) );

			// Match by normalized name and parent.
			if ( $normalized_zoho_name === $normalized_search_name ) {
				$zoho_parent_id = $zoho_category['parent_category_id'] ?? '-1';

				// For root categories, both should be empty or '-1'.
				if ( empty( $parent_zoho_id ) ) {
					if ( empty( $zoho_parent_id ) || '-1' === $zoho_parent_id ) {
						return $zoho_category;
					}
				} elseif ( $zoho_parent_id === $parent_zoho_id ) {
					// For subcategories, parent IDs should match.
					return $zoho_category;
				} else {
					continue; // Parent mismatch.
				}
			}
		}
		return null;
	}

	/**
	 * Validate and fix category mapping, ensuring the Zoho category still exists.
	 *
	 * @param int   $wc_category_id WooCommerce category ID.
	 * @param array $zoho_categories All Zoho categories.
	 * @return string|false Valid Zoho category ID or false if invalid/not found.
	 */
	private function validate_and_fix_category_mapping( $wc_category_id, $zoho_categories ) {
		$existing_mapping = (string) ( $this->map()->zoho_id_for_term( (int) $wc_category_id ) ?? '' );

		if ( empty( $existing_mapping ) ) {
			return false; // No mapping exists.
		}

		// Check if the mapped Zoho category still exists.
		foreach ( $zoho_categories as $zoho_category ) {
			if ( $zoho_category['category_id'] === $existing_mapping ) {
				return $existing_mapping; // Valid mapping.
			}
		}

		if ( ! $this->is_last_zoho_fetch_complete() ) {
			// Snapshot is partial, so do not delete mappings based on absence alone.
			return $existing_mapping;
		}

		// Invalid mapping - remove it.
		$this->map()->forget_term( (int) $wc_category_id );
		return false;
	}

	/**
	 * Identify duplicate categories and sort them by depth (deepest first).
	 *
	 * @param array $zoho_categories All Zoho categories.
	 * @return array Array of duplicate categories sorted by depth.
	 */
	private function identify_duplicate_categories_by_depth( $zoho_categories ) {
		$duplicate_categories = array();
		foreach ( $zoho_categories as $category ) {
			if ( '-1' === $category['category_id'] ) {
				continue; // Skip placeholder category.
			}

			// Check if this category is a duplicate based on various patterns.
			$is_duplicate     = false;
			$duplicate_reason = '';

			// Get category properties for enhanced duplicate detection.
			$has_active_items = isset( $category['has_active_items'] ) ? (bool) $category['has_active_items'] : true;
			$category_url     = $category['url'] ?? '';

			// Check if this category is mapped in the new table.
			$category_exists_in_wp = $this->map()->term_id_for_zoho_id_any_status( (string) $category['category_id'] );

			// If category is not mapped in wp_options, it should be removed.
			if ( null === $category_exists_in_wp ) {
				$is_duplicate     = true;
				$duplicate_reason = 'Category not mapped in WooCommerce';
			} elseif ( ! $has_active_items && ! empty( $category_url ) && preg_match( '/_\d+$/', $category_url ) ) {
				// Primary check: Parent categories with number suffix URL and no active items.
				$is_duplicate     = true;
				$duplicate_reason = 'Parent category with URL suffix and no active items';
			} elseif ( preg_match( '/_\d+$/', $category['name'] ) ) {
				$is_duplicate     = true;
				$duplicate_reason = 'Name ends with number suffix';
			} elseif ( preg_match( '/^Copy of |Copy \d+$|\(\d+\)$/', $category['name'] ) ) {
				$is_duplicate     = true;
				$duplicate_reason = 'Name contains copy pattern';
			}

			if ( $is_duplicate ) {
				$duplicate_categories[] = array(
					'category' => $category,
					'depth'    => isset( $category['depth'] ) ? $category['depth'] : 0,
					'reason'   => $duplicate_reason,
				);
			}
		}

		// Sort by depth (highest first) - categories with depth 2, 3, etc. will be processed before depth 0, 1.
		usort(
			$duplicate_categories,
			function ( $a, $b ) {
				return $b['depth'] <=> $a['depth']; // Descending order (highest depth first).
			}
		);

		return $duplicate_categories;
	}

	/**
	 * Process duplicate removal page by page (AJAX handler).
	 */
	public function cmbird_zi_duplicate_removal_batch() {
		// Security check.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
		$security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
		if ( ! wp_verify_nonce( $security_token, 'commercebird-app' ) ) {
			wp_send_json_error( array( 'message' => 'Security check failed' ) );
			return;
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Unslashed before sanitization below.
		$session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : '';
		if ( empty( $session_id ) ) {
			wp_send_json_error( array( 'message' => 'Invalid session ID' ) );
			return;
		}

		// Get batch data.
		$batch_data = get_transient( $session_id );
		if ( false === $batch_data ) {
			wp_send_json_error( array( 'message' => 'Session expired or invalid' ) );
			return;
		}

		$batch_messages = array();

		// Process current page (starting from the end).
		$current_page = $batch_data['current_page'];

		if ( $current_page > 0 && isset( $batch_data['category_pages'] ) ) {
			// Get categories from pre-split pages (no additional API call needed).
			$page_index      = $batch_data['total_pages'] - $current_page; // Convert page number to array index.
			$page_categories = $batch_data['category_pages'][ $page_index ] ?? array();

			if ( ! empty( $page_categories ) ) {
				$batch_messages[] = $this->cmbird_zi_response_message(
					'-',
					"📄 Processing page {$current_page} with " . count( $page_categories ) . ' categories (identifying duplicates by depth)',
					'-'
				);

				// Identify duplicates in this page using depth-based logic.
				$duplicate_categories = $this->identify_duplicate_categories_by_depth( $page_categories );

				if ( ! empty( $duplicate_categories ) ) {
					// Sort duplicates by depth (highest depth first - deepest categories).
					usort(
						$duplicate_categories,
						function ( $a, $b ) {
							return $b['depth'] <=> $a['depth'];
						}
					);

					$batch_messages[] = $this->cmbird_zi_response_message(
						'-',
						'🔍 Found ' . count( $duplicate_categories ) . ' duplicate categories on page ' . $current_page . ' (starting with highest depth)',
						'-'
					);

					// Process duplicates starting with highest depth.
					foreach ( $duplicate_categories as $duplicate ) {
						$category = $duplicate['category'];
						$depth    = $duplicate['depth'];
						$reason   = $duplicate['reason'];

						if ( '-1' === $category['category_id'] ) {
							continue; // Skip placeholder category.
						}

						// Attempt to delete the duplicate category.
						$delete_result = $this->delete_zoho_category( $category['category_id'], $category['name'] );

						if ( $delete_result['success'] ) {
							++$batch_data['removed_successfully'];
							$batch_messages[] = $this->cmbird_zi_response_message(
								$category['category_id'],
								"✅ REMOVED DUPLICATE: '{$category['name']}' (ID: {$category['category_id']}, Depth: {$depth}, Reason: {$reason})",
								'-'
							);

							// Remove any WooCommerce mappings to this deleted Zoho category.
							$this->cleanup_wc_mappings_for_zoho_category( $category['category_id'] );
						} else {
							++$batch_data['removal_errors'];
							$batch_messages[] = $this->cmbird_zi_response_message(
								$category['category_id'],
								"❌ FAILED DUPLICATE: '{$category['name']}' (ID: {$category['category_id']}, Depth: {$depth}) - {$delete_result['message']}",
								'-'
							);
						}

						++$batch_data['processed_categories'];
					}
				} else {
					$batch_messages[] = $this->cmbird_zi_response_message(
						'-',
						"📄 No duplicates found on page {$current_page} - skipping",
						'-'
					);
				}
			}

			// Move to previous page.
			--$batch_data['current_page'];
			++$batch_data['processed_pages'];
		}

		// Check if we're done (no more pages).
		$is_complete = $batch_data['current_page'] <= 0;

		if ( $is_complete ) {
			// Mark as completed but don't delete session yet - let status endpoint handle cleanup.
			$batch_data['is_completed']    = true;
			$batch_data['completion_time'] = time();

			$batch_messages[] = $this->cmbird_zi_response_message(
				'-',
				"🎉 CATEGORY REMOVAL COMPLETE: Processed {$batch_data['processed_pages']} pages, removed {$batch_data['removed_successfully']} categories, errors: {$batch_data['removal_errors']}",
				'-'
			);
		}

		// Always update session data (will be cleaned up by status endpoint or expiry).
		set_transient( $session_id, $batch_data, 3600 );

		wp_send_json_success(
			array(
				'messages'             => $batch_messages,
				'processed_pages'      => $batch_data['processed_pages'],
				'total_pages'          => $batch_data['total_pages'],
				'current_page'         => $batch_data['current_page'],
				'processed_categories' => $batch_data['processed_categories'],
				'removed_successfully' => $batch_data['removed_successfully'],
				'removal_errors'       => $batch_data['removal_errors'],
				'is_complete'          => $is_complete,
				'progress'             => $batch_data['processed_pages'] > 0 ? round( ( $batch_data['processed_pages'] / $batch_data['total_pages'] ) * 100 ) : 0,
				'status'               => $is_complete ? 'completed' : 'processing',
			)
		);
	}

	/**
	 * Get duplicate removal batch processing status (AJAX handler).
	 */
	public function cmbird_zi_duplicate_removal_status() {
		// Security check.
		// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Nonce verified below.
		$security_token = isset( $_REQUEST['security_token'] ) ? wp_unslash( $_REQUEST['security_token'] ) : ( isset( $_GET['security_token'] ) ? wp_unslash( $_GET['security_token'] ) : '' );
		if ( ! wp_verify_nonce( $security_token, 'commercebird-app' ) ) {
			wp_send_json_error( array( 'message' => 'Security check failed' ) );
			return;
		}

		// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Unslashed before sanitization below.
		$session_id = isset( $_POST['session_id'] ) ? sanitize_text_field( wp_unslash( $_POST['session_id'] ) ) : ( isset( $_GET['session_id'] ) ? sanitize_text_field( wp_unslash( $_GET['session_id'] ) ) : '' );
		if ( empty( $session_id ) ) {
			wp_send_json_error( array( 'message' => 'Invalid session ID' ) );
			return;
		}

		// Get batch data.
		$batch_data = get_transient( $session_id );
		if ( false === $batch_data ) {
			wp_send_json_error( array( 'message' => 'Session expired or not found' ) );
			return;
		}

		$is_complete = $batch_data['current_page'] <= 0 || isset( $batch_data['is_completed'] );

		// If completed and this is a status check (not the first completion response), clean up session.
		if ( $is_complete && isset( $batch_data['completion_time'] ) && ( time() - $batch_data['completion_time'] ) > 5 ) {
			// Clean up after 5 seconds to allow frontend to get completion status.
			delete_transient( $session_id );
		}

		wp_send_json_success(
			array(
				'processed_pages'      => $batch_data['processed_pages'],
				'total_pages'          => $batch_data['total_pages'],
				'current_page'         => $batch_data['current_page'],
				'processed_categories' => $batch_data['processed_categories'],
				'removed_successfully' => $batch_data['removed_successfully'],
				'removal_errors'       => $batch_data['removal_errors'],
				'is_complete'          => $is_complete,
				'progress'             => $batch_data['processed_pages'] > 0 ? round( ( $batch_data['processed_pages'] / $batch_data['total_pages'] ) * 100 ) : 0,
			)
		);
	}
}
