<?php
// Exit if accessed directly.
defined( 'ABSPATH' ) || exit;

/**
 * Class WC_Appointments_Cache_Availability
 *
 * @package Woocommerce/Appointments
 */

/**
 * WC Appointments Availability Cache: Stored in Custom table.
 *
 * @since 5.0.0
 */
class WC_Appointments_Cache_Availability {

	public const CACHE_TABLE    = 'wc_appointments_availability_cache';
	public const ROLLING_MONTHS = 3;
	public const BACKFILL_FLAG  = 'wc_appointments_availability_cache_backfilled';

	// How many days to retain availability/appointment cache rows before pruning.
	public const INDEX_RETENTION_DAYS = 30;

	// Tolerance for horizon check: active rules can be a couple of days off horizon, but not more than this.
	// Rules must reach within this many days of the horizon at all times for reliable index operation.
	public const HORIZON_TOLERANCE_DAYS = 2;

	// New: constrain per-pass work to avoid timeouts/memory spikes.
	public const NON_RRULE_MAX_DAYS_PER_PASS       = 7;    // days per rule per pass during backfill
	public const NON_RRULE_MAX_ROWS_PER_PASS       = 600;  // rows per rule per pass during backfill
	public const RRULE_MAX_OCCURRENCES_PER_PASS    = 800;  // occurrences per rule per pass during backfill
	public const RRULE_MAX_ROWS_PER_PASS           = 600;  // rows per rule per pass during backfill
    /**
     * Ensure we invalidate front-end caches only once per request.
     */
    private static bool $did_invalidate_cache = false;

	public static function is_index_enabled(): bool {
		return 'yes' === get_option( 'wc_appointments_use_indexed_cache', 'no' );
	}

	/**
	 * Constructor.
	 *
	 * Initializes hooks for availability and appointment updates to maintain the cache.
	 */
	public function __construct() {
		// Instant updates for Availability rules (from data store hooks).
		add_action( 'woocommerce_after_appointments_availability_object_save', [ $this, 'on_availability_saved' ] );
		add_action( 'woocommerce_after_appointments_availability_object_delete', [ $this, 'delete_availability' ] );

		// Instant updates for Appointments.
		add_action( 'woocommerce_after_appointment_object_save', [ $this, 'on_appointment_saved' ], 10, 3 );
		add_action( 'before_delete_post', [ $this, 'delete_appointment_from_post' ] );
		add_action( 'wp_trash_post', [ $this, 'delete_appointment_from_post' ] );
		// When an appointment is restored from trash, re-index it.
		add_action( 'untrash_post', [ $this, 'reindex_appointment_from_untrash' ] );

		// Also, listen to key appointment status events to ensure coverage.
		$statuses = [
			'unpawas-in-id',
			'paid',
			'pending-confirmation',
			'confirmed',
			'complete',
			'in-cart',
			'cancelled',
			'cart',
		];

		foreach ( $statuses as $status ) {
			// Accept both ($appointment_id, $appointment) which the core hook emits.
			add_action( 'woocommerce_appointment_' . $status, [ $this, 'on_appointment_status_event' ], 10, 2 );
		}

		// Bridge: when payment completes for an order, index all its appointments.
		add_action( 'woocommerce_pre_payment_complete', [ $this, 'index_order_appointments' ], 10, 1 );
		// Also run after payment complete (fires for most gateways) and after checkout order creation.
		add_action( 'woocommerce_payment_complete', [ $this, 'index_order_appointments' ], 20, 1 );
		add_action( 'woocommerce_checkout_order_processed', [ $this, 'index_order_appointments' ], 20, 1 );

		// Daily cron to extend RRULE occurrences rolling window.
		add_action( 'woocommerce_appointments_daily_cleanup', [ $this, 'extend_rrule_horizon' ] );

		// One-off async backfill to index existing future rules and appointments.
		add_action( 'init', [ $this, 'maybe_schedule_backfill' ], 20 );
		add_action( 'wc_appointments_availability_cache_backfill', [ $this, 'run_backfill_batches' ] );

		// Register appointment backfill batch worker and per-appointment worker (scheduled via Action Scheduler).
		add_action( 'wc_appointments_backfill_appointments_batch', [ $this, 'run_backfill_appointments_batch' ], 10, 1 );
		add_action( 'wc_appointments_index_appointment', [ $this, 'index_appointment_from_id' ], 10, 1 );

		// Per-rule async indexing after interactive saves.
		add_action( 'wc_appointments_index_availability_rule', [ $this, 'run_rule_index_pass' ], 10, 1 );

		// When an appointment is removed from the cart, delete its index rows (simple path).
		add_action( 'woocommerce_remove_cart_item', [ $this, 'delete_appointment_from_cart' ], 10, 2 );

		// When the entire cart is emptied, remove all related in-cart appointment rows from the index.
		// Use the "before" hook to still have access to cart items.
		add_action( 'woocommerce_before_cart_emptied', [ $this, 'delete_appointments_from_cart_items' ], 10, 1 );
		// After emptying, invalidate the frontend cache once.
		add_action( 'woocommerce_cart_emptied', function(): void {
			$this->invalidate_frontend_cache();
		}, 10, 0 );

		// Optional: update progress incrementally when a per-rule pass runs in the background.
		add_action( 'wc_appointments_index_availability_rule', [ $this, 'note_rule_scheduled' ], 5, 1 );
	}

	/**
	 * Ensure table exists.
	 *
	 * Checks if the cache table exists and creates it if missing.
	 *
	 * @return bool True if table exists or was created.
	 */
	public function ensure_table_exists(): bool {
		global $wpdb;
		$table = $this->table();

		// Quick probe
		$exists = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table ) );
		if ( $exists === $table ) {
			return true;
		}

		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$charset_collate = $wpdb->get_charset_collate();
		// Schema matching the installation table creation
		$sql = "CREATE TABLE {$table} (
			id bigint(20) unsigned NOT NULL auto_increment,
			source enum('availability','appointment') NOT NULL,
			source_id bigint(20) unsigned NOT NULL,
			product_id bigint(20) unsigned NOT NULL default 0,
			staff_id bigint(20) unsigned NOT NULL default 0,
			scope enum('global','product','staff') NOT NULL default 'global',
			appointable varchar(5) NOT NULL default 'yes',
			priority int(11) NOT NULL default 10,
			ordering int(11) NOT NULL default 0,
			qty bigint(20) NOT NULL default 0,
			start_ts bigint(20) NOT NULL,
			end_ts bigint(20) NOT NULL,
			range_type varchar(60) NULL,
			rule_kind varchar(100) NULL,
			status varchar(100) NULL,
			date_created datetime NULL default NULL,
			date_modified datetime NULL default NULL,
			PRIMARY KEY (id),
			KEY src (source, source_id),
			KEY time_idx (start_ts, end_ts),
			KEY product_staff (product_id, staff_id),
			KEY avail_global_lookup (source, scope, start_ts, end_ts, staff_id, priority),
			KEY avail_product_lookup (source, scope, product_id, start_ts, end_ts, staff_id, priority),
			KEY avail_staff_lookup (source, scope, staff_id, start_ts, end_ts, priority),
			KEY priority_sort (priority DESC),
			UNIQUE KEY uniq_occurrence (source, source_id, product_id, staff_id, start_ts, end_ts)
		) {$charset_collate}";

		dbDelta( $sql );

		$exists_after = $wpdb->get_var( $wpdb->prepare( "SHOW TABLES LIKE %s", $table ) );
		return ( $exists_after === $table );
	}


	/**
	 * Update the last successful indexing timestamp for automatic rule indexing.
	 * This tracks when individual rules are indexed automatically (not during manual re-index).
	 *
	 * @param int $rules_count Number of rules indexed in this operation.
	 * @param int $appointments_count Number of appointments indexed in this operation.
	 */
	private function update_last_successful_indexing( int $rules_count = 1, int $appointments_count = 0 ): void {
		// Get existing last completed info
		$last_completed = get_option( 'wc_appointments_last_index_completed', [] );
		$existing_rules = (int) ( $last_completed['total_rules'] ?? 0 );
		$existing_appointments = (int) ( $last_completed['total_appointments'] ?? 0 );

		// Update with new totals
		update_option( 'wc_appointments_last_index_completed', [
			'timestamp' => time(),
			'total_rules' => $existing_rules + $rules_count,
			'total_appointments' => $existing_appointments + $appointments_count,
			'current_run_rules' => $rules_count,
			'current_run_appointments' => $appointments_count,
			'auto_indexed' => true, // Flag to indicate this was automatic indexing
		], false );
	}


	/**
     * Optional: when a background per-rule job enqueues (or runs), record a tick.
     * This keeps progress moving even if UI polls later.
     */
    public function note_rule_scheduled( int $availability_id ): void {
		$progress = get_option( 'wc_appointments_index_progress', [] );
		if ( empty( $progress ) ) {
			return;
		}
		// Only note activity by updating the timestamp; do not mutate counters here.
		$progress['updated_at'] = time();
		update_option( 'wc_appointments_index_progress', $progress, false );
	}

	/**
	 * Centralized, idempotent cache invalidation for any availability/appointment write.
	 * Keeps front-end transient/object caches in sync with minimal overhead.
	 */
	private function invalidate_frontend_cache(): void {
		if ( self::$did_invalidate_cache ) {
			return;
		}
		self::$did_invalidate_cache = true;

		// Prefer lightweight group invalidation; fall back to full clear when needed.
		if ( class_exists( 'WC_Appointments_Cache' ) ) {
			// Invalidate the main appointments cache version to avoid stale reads.
			if ( method_exists( 'WC_Appointments_Cache', 'invalidate_cache_group' ) ) {
				// Known groups used around availability rendering.
				WC_Appointments_Cache::invalidate_cache_group( 'appointments' );
				WC_Appointments_Cache::invalidate_cache_group( 'schedule_ts' );
				WC_Appointments_Cache::invalidate_cache_group( 'schedule_dr' );
				WC_Appointments_Cache::invalidate_cache_group( 'schedule_fo' );
				WC_Appointments_Cache::invalidate_cache_group( 'staff_ps' );
				WC_Appointments_Cache::invalidate_cache_group( 'schedule_staff_ids' );
			} else {
				// Fallback to full clear if group invalidation isn't available.
				WC_Appointments_Cache::clear_cache();
			}
		}
	}

	public function maybe_schedule_backfill(): void {
		// Only schedule if not already backfilled (idempotent) and not already scheduled.
		if ( get_option( self::BACKFILL_FLAG ) ) {
			return;
		}
		if ( ! as_next_scheduled_action( 'wc_appointments_availability_cache_backfill' ) ) {
			as_schedule_single_action( time() + 60, 'wc_appointments_availability_cache_backfill' );
		}
	}

	/**
	 * Mark backfill complete.
	 *
	 * Sets the option flag indicating backfill has completed.
	 */
	private function mark_backfill_complete(): void {
		update_option( self::BACKFILL_FLAG, 1, false );
	}

	private function table(): string {
		global $wpdb;
		return $wpdb->prefix . self::CACHE_TABLE;
	}

	/**
	 * Get horizon timestamp (rolling months ahead in UTC).
	 * 
	 * @return int Timestamp (strtotime returns int for valid date strings).
	 */
	private function horizon_ts(): int {
		// Rolling months ahead (UTC) - configurable via admin settings.
		$months = wc_appointments_get_cache_horizon_months();
		$ts = strtotime( '+' . $months . ' months UTC' );
		// strtotime can return false on failure, but with this format it should always succeed.
		return $ts ?: time();
	}

	private function now_ts(): int {
		// UTC timestamp.
		return current_time( 'timestamp', true );
	}

	/**
	 * Check if an end timestamp should be indexed based on retention period and indexing mode.
	 * 
	 * @param int $end_ts The end timestamp to check.
	 * @return bool True if should be indexed, false otherwise.
	 */
	private function should_index_end_ts( int $end_ts ): bool {
		$now = $this->now_ts();
		$force_full_index = (bool) get_option( 'wc_appointments_manual_reindex_full_indexing', false );

		if ( ! $force_full_index ) {
			// Automatic indexing: only index future occurrences
			return $end_ts > $now;
		}
        // Manual re-indexing: include occurrences within retention period
        $retention_days = self::INDEX_RETENTION_DAYS;
        $retention_cut = strtotime( '-' . $retention_days . ' days UTC', $now );
        return $end_ts >= $retention_cut;
	}

	/**
     * Fetch the latest cached end_ts for an availability rule.
     * Returns 0 if nothing cached yet.
     */
    private function get_availability_last_cached_end_ts( int $availability_id ): int {
		if ( ! class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			return 0;
		}

		$data_store = new WC_Appointments_Availability_Cache_Data_Store();
		$rows = $data_store->get_items([
			'source' => 'availability',
			'source_id' => $availability_id,
			'order_by' => 'end_ts',
			'order' => 'DESC',
			'limit' => 1,
		]);

		if ( empty( $rows ) ) {
			return 0;
		}

		return max( 0, (int) $rows[0]['end_ts'] );
	}

	/**
     * Log a message to WooCommerce status logs with consistent source.
     *
     * @param string $message The log message.
     * @param string $level The log level (error, warning, info, debug).
     */
    private function log_index_message( string $message, string $level = 'info' ): void {
		if ( ! function_exists( 'wc_get_logger' ) ) {
			return;
		}

		$logger = wc_get_logger();
		$context = [ 'source' => 'wc-appointments-availability-index' ];

		switch ( $level ) {
			case 'error':
				$logger->error( $message, $context );
				break;
			case 'warning':
				$logger->warning( $message, $context );
				break;
			case 'debug':
				$logger->debug( $message, $context );
				break;
			case 'info':
			default:
				$logger->info( $message, $context );
				break;
		}
	}

	/**
	 * Check if a rule's last cached end timestamp is within tolerance of the horizon.
	 * Rules must reach within HORIZON_TOLERANCE_DAYS of the horizon at all times for reliable index operation.
	 *
	 * @param int $last_cached_end_ts The last cached end timestamp for the rule.
	 * @param int|null $horizon Optional. Horizon timestamp. If not provided, uses current horizon.
	 * @return bool True if the rule is within tolerance of the horizon, false otherwise.
	 */
	private function is_rule_within_horizon_tolerance( int $last_cached_end_ts, ?int $horizon = null ): bool {
		if ( 0 >= $last_cached_end_ts ) {
			return false;
		}

		if ( null === $horizon ) {
			$horizon = $this->horizon_ts();
		}

		$tolerance_seconds = self::HORIZON_TOLERANCE_DAYS * DAY_IN_SECONDS;
		$min_acceptable_end_ts = $horizon - $tolerance_seconds;

		return $last_cached_end_ts >= $min_acceptable_end_ts;
	}

	/**
	 * Check if a rule has constraints (to_date or RRULE UNTIL) that prevent it from reaching the horizon.
	 * If a rule has such constraints, it cannot be extended beyond them, so we should skip it in periodic checks.
	 *
	 * @param int $availability_id The availability rule ID.
	 * @param int|null $horizon Optional. Horizon timestamp. If not provided, uses current horizon.
	 * @return array{has_constraint: bool, max_possible_end_ts: int, constraint_type: string} Information about constraints.
	 */
	private function check_rule_constraints( int $availability_id, ?int $horizon = null ): array {
		if ( null === $horizon ) {
			$horizon = $this->horizon_ts();
		}

		$result = [
			'has_constraint' => false,
			'max_possible_end_ts' => $horizon,
			'constraint_type' => '',
		];

		try {
			$availability = get_wc_appointments_availability( $availability_id );
		} catch ( \Exception $e ) {
			return $result;
		}

		if ( ! $availability || ! is_object( $availability ) ) {
			return $result;
		}

		$range_type = trim( (string) $availability->get_range_type() );
		$to_date = trim( (string) $availability->get_to_date() );
		$to_date_ts = false;
		if ( '' !== $to_date ) {
			$to_date_ts = strtotime( $to_date . ' 23:59:59 UTC' );
		}

		$constraint_ts = false;
		$constraint_type = '';

		// Check for custom range_type rules with date constraints (e.g., specific date ranges like 2026-01-22 to 2026-01-23)
		if ( 'custom' === $range_type ) {
			$from_range = trim( (string) $availability->get_from_range() );
			$to_range = trim( (string) $availability->get_to_range() );

			if ( '' !== $from_range && '' !== $to_range ) {
				// Try to parse to_range as a date string (e.g., '2026-01-23')
				$to_range_ts = strtotime( $to_range . ' 23:59:59 UTC' );
				if ($to_range_ts && $to_range_ts < $horizon && (!$constraint_ts || $to_range_ts < $constraint_ts)) {
                    $constraint_ts = $to_range_ts;
                    $constraint_type = 'custom_date_range';
                }
			}
		}

		// Check for seasonal months rules (e.g., only applies to January each year)
		if ( 'months' === $range_type ) {
			$from_range = trim( (string) $availability->get_from_range() );
			$to_range = trim( (string) $availability->get_to_range() );

			if ( '' !== $from_range && '' !== $to_range ) {
				$from_month = (int) $from_range;
				$to_month = (int) $to_range;
				$now = $this->now_ts();
				$current_year = (int) gmdate( 'Y', $now );
				$horizon_month = (int) gmdate( 'n', $horizon );
				$horizon_year = (int) gmdate( 'Y', $horizon );

				// Find the last occurrence of this seasonal pattern before or at the horizon
				$last_occurrence_end_ts = false;

				// Check each year from current to horizon year
				for ( $year = $current_year; $year <= $horizon_year; $year++ ) {
					$year_end_month = ( $year === $horizon_year ) ? $horizon_month : 12;

					// Check if any month in this year's range falls within the seasonal pattern
					for ( $month = 1; $month <= $year_end_month; $month++ ) {
						if ( $this->is_month_in_range( $month, $from_month, $to_month ) ) {
							// Calculate end of this month occurrence
							$next_month = $month + 1;
							$next_year = $year;
							if ( 12 < $next_month ) {
								$next_month = 1;
								$next_year++;
							}
							$month_end_ts = strtotime( sprintf( '%d-%02d-01 00:00:00 UTC', $next_year, $next_month ) ) - 1;
							if ( $month_end_ts <= $horizon ) {
								$last_occurrence_end_ts = $month_end_ts;
							}
						}
					}
				}

				// If we found a last occurrence and it's before the horizon, this is a constraint
				if ($last_occurrence_end_ts && $last_occurrence_end_ts < $horizon && (!$constraint_ts || $last_occurrence_end_ts < $constraint_ts)) {
                    $constraint_ts = $last_occurrence_end_ts;
                    $constraint_type = 'seasonal_months';
                }
			}
		}

		// Check for to_date constraint
		if ($to_date_ts && $to_date_ts < $horizon && (!$constraint_ts || $to_date_ts < $constraint_ts)) {
            $constraint_ts = $to_date_ts;
            $constraint_type = 'to_date';
        }

		// Check for RRULE UNTIL constraint
		$rrule = trim( (string) $availability->get_rrule() );
		$until_ts = false;
		if ('' !== $rrule && stripos( $rrule, 'UNTIL' ) !== false && preg_match( '/(?:^|;)\s*UNTIL=([0-9]{8}(T[0-9]{6}Z?)?)/i', $rrule, $m )) {
            $until_raw = $m[1];
            if ( strlen( $until_raw ) === 8 ) {
					$until_str = substr( $until_raw, 0, 4 ) . '-' . substr( $until_raw, 4, 2 ) . '-' . substr( $until_raw, 6, 2 ) . ' 23:59:59 UTC';
					$until_ts = strtotime( $until_str );
				} else {
					$ymd = substr( $until_raw, 0, 8 );
					$hms = preg_replace( '/[^0-9]/', '', substr( $until_raw, 9 ) );
					if ( strlen( $hms ) >= 6 ) {
						$until_str = substr( $ymd, 0, 4 ) . '-' . substr( $ymd, 4, 2 ) . '-' . substr( $ymd, 6, 2 ) .
							' ' . substr( $hms, 0, 2 ) . ':' . substr( $hms, 2, 2 ) . ':' . substr( $hms, 4, 2 ) . ' UTC';
						$until_ts = strtotime( $until_str );
					}
				}
        }

		if ( $until_ts && $until_ts < $horizon && ( ! $constraint_ts || $until_ts < $constraint_ts ) ) {
			$constraint_ts = $until_ts;
			$constraint_type = 'rrule_until';
		}

		if ( $constraint_ts ) {
			$result['has_constraint'] = true;
			$result['max_possible_end_ts'] = $constraint_ts;
			$result['constraint_type'] = $constraint_type;
		}

		return $result;
	}


	/**
     * Unified public API: index an availability rule by ID or object.
     * - If $availability is int, loads rule and indexes it.
     * - If the object provided, preserves existing behavior.
     *
     * @param int|object $availability
     */
    public function index_availability( $availability ): void {
		// Accept the previous signature (object) to remain BC.
		if ( is_object( $availability ) ) {
			// The existing implementation below.
			if ( ! $availability ) {
				return;
			}
			$id = (int) $availability->get_id();
			if ( 0 === $id ) {
				return;
			}
			// Clear existing cache rows for this source.
			$this->delete_rows( 'availability', $id );
			$rrule = (string) $availability->get_rrule();
			if ( '' !== $rrule ) {
				$this->expand_rrule_availability( $availability, $rrule, [
					'max_occurrences' => PHP_INT_MAX,
					'max_rows'        => PHP_INT_MAX,
				] );
			} else {
				$this->index_non_rrule_availability( $availability, [
					'max_days' => PHP_INT_MAX,
					'max_rows' => PHP_INT_MAX,
				] );
			}
			$this->schedule_rule_index( $id );
			$this->invalidate_frontend_cache();
			return;
		}
		// New: integer entry point.
		$availability_id = (int) $availability;
		if ( 0 >= $availability_id ) {
		 return;
		}
		// Load via helper to get a WC_Appointments_Availability object.
		try {
			$availability_obj = get_wc_appointments_availability( $availability_id );
		} catch ( \Exception $e ) {
			return;
		}
		if ( is_object( $availability_obj ) ) {
			$this->index_availability( $availability_obj );
			$this->invalidate_frontend_cache();
		}
	}

	/**
     * Schedule a per-rule index continuation in the background.
     */
    private function schedule_rule_index( int $availability_id ): void {
		// Avoid duplicate schedules for the same rule.
		if ( ! as_next_scheduled_action( 'wc_appointments_index_availability_rule', [ $availability_id ] ) ) {
			as_schedule_single_action( time() + 15, 'wc_appointments_index_availability_rule', [ $availability_id ] );
		}
	}

	/**
     * Background pass: index one availability rule in constrained batches.
     * Reschedules itself until the rule reaches the rolling horizon.
     */
    public function run_rule_index_pass( int $availability_id ): void {
		// Check if indexed availability is enabled
		if ( ! self::is_index_enabled() ) {
			return;
		}

		if ( 0 >= $availability_id ) {
			return;
		}

		// Prevent PHP timeouts during indexing operations
		if ( ! function_exists( 'ini_get' ) || ! ini_get( 'safe_mode' ) ) {
			@set_time_limit( 0 );
		}
		@ini_set( 'max_execution_time', 0 );

		$force_full_index = (bool) get_option( 'wc_appointments_manual_reindex_full_indexing', false );
		$horizon = $this->horizon_ts();
		$this->get_availability_last_cached_end_ts( $availability_id );

		try {
			$availability = get_wc_appointments_availability( $availability_id );
		} catch ( \Exception $e ) {
			return;
		}
		if ( ! $availability || ! is_object( $availability ) ) {
			return;
		}

		$rrule = (string) $availability->get_rrule();
		$rows_inserted = 0;

		// Even with force_full_index, use larger but still reasonable limits to avoid timeouts
		// This ensures we can process large rules without hitting PHP execution time limits
		$rrule_max_occurrences = $force_full_index ? 5000 : self::RRULE_MAX_OCCURRENCES_PER_PASS;
		$rrule_max_rows = $force_full_index ? 5000 : self::RRULE_MAX_ROWS_PER_PASS;
		$non_rrule_max_days = $force_full_index ? 365 : self::NON_RRULE_MAX_DAYS_PER_PASS;
		$non_rrule_max_rows = $force_full_index ? 5000 : self::NON_RRULE_MAX_ROWS_PER_PASS;

		try {
			if ( '' !== $rrule ) {
				$rows_inserted = $this->expand_rrule_availability( $availability, $rrule, [
					'max_occurrences' => $rrule_max_occurrences,
					'max_rows'        => $rrule_max_rows,
				] );
			} else {
				$rows_inserted = $this->index_non_rrule_availability( $availability, [
					'max_days' => $non_rrule_max_days,
					'max_rows' => $non_rrule_max_rows,
				] );
			}
		} catch ( \Exception $e ) {
			return;
		}

		$after_index_end = $this->get_availability_last_cached_end_ts( $availability_id );
		$is_within_tolerance = $this->is_rule_within_horizon_tolerance( $after_index_end, $horizon );

		if ( ! $is_within_tolerance ) {
			$this->schedule_rule_index( $availability_id );
		}

		// Update last successful indexing timestamp for automatic rule indexing
		$this->update_last_successful_indexing( 1, 0 );
	}

	/**
     * When an appointment item is removed from the cart, delete its cached "appointment" rows.
     * Keep it simple: if the cart item carries an appointment ID, use it; otherwise skip.
     *
     * @param string  $cart_item_key
     * @param WC_Cart $cart
     */
    public function delete_appointment_from_cart( $cart_item_key, $cart ): void {
 		if ( ! $cart || ! is_object( $cart ) || ! method_exists( $cart, 'get_cart_item' ) ) {
 			return;
 		}

		$cart_item = $cart->get_cart_item( $cart_item_key );
		if ( empty( $cart_item ) || ! wc_appointments_validate_cart_item( $cart_item ) ) {
			return;
		}

 		$appt = $cart_item['appointment'];

 		// Common keys to carry the appointment ID in cart meta (keep this minimal).
 		$appointment_id = 0;
 		if ( isset( $appt['_appointment_id'] ) ) {
 			$appointment_id = (int) $appt['_appointment_id'];
 		} elseif ( isset( $appt['_id'] ) ) {
 			$appointment_id = (int) $appt['_id'];
 		}

 		if ( 0 < $appointment_id ) {
 			$this->delete_appointment( $appointment_id );
 			// Cache invalidation happens inside delete_appointment().
 		}
 	}

	/**
     * Bulk delete all appointment rows originating from items in the cart being emptied.
     * Runs before WooCommerce clears the cart so we can inspect items.
     *
     * @param WC_Cart $cart
     */
    public function delete_appointments_from_cart_items( $cart ): void {
		// Ensure wp_loaded has been fired before accessing the cart to avoid WooCommerce warnings.
		if ( ! did_action( 'wp_loaded' ) ) {
			return;
		}

 		// Some integrations call this hook with a boolean (true). Normalize to a cart instance.
 		$cart_obj = ( is_object( $cart ) && method_exists( $cart, 'get_cart' ) ) ? $cart : ( WC()->cart ?? null );
 		if ( ! is_object( $cart_obj ) || ! method_exists( $cart_obj, 'get_cart' ) ) {
 			return;
 		}

 		$items = $cart_obj->get_cart();
 		if ( empty( $items ) || ! is_array( $items ) ) {
 			return;
 		}

		foreach ( $items as $cart_item ) {
			if ( ! wc_appointments_validate_cart_item( $cart_item ) ) {
				continue;
			}
             $appt       = $cart_item['appointment'];
			$appt_id    = (int) ( $appt['_appointment_id'] ?? $appt['_id'] ?? 0 );
			$product_id = (int) ( $appt['_product_id'] ?? $cart_item['product_id'] ?? 0 );
			$start_ts   = (int) ( $appt['_start'] ?? 0 );
			$end_ts     = (int) ( $appt['_end'] ?? 0 );

 			// Staff may be a scalar or array.
 			$staff_ids = [];
 			if ( isset( $appt['_staff_id'] ) ) {
 				$staff_ids = is_array( $appt['_staff_id'] ) ? array_map( 'intval', $appt['_staff_id'] ) : [ (int) $appt['_staff_id'] ];
 			}

 			if ( ! class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
 				continue;
 			}

 			$data_store = new WC_Appointments_Availability_Cache_Data_Store();

 			// Always restrict deletions to in-cart rows only.
 			if ( 0 < $appt_id ) {
 				$data_store->delete_by_criteria([
 					'source'    => 'appointment',
 					'source_id' => $appt_id,
 					'status'    => 'in-cart',
 				]);
 				continue;
 			}

 			// Fallback: no appointment id present (pure in-cart hold). Remove by matching fields and status.
 			if ( $product_id && $start_ts && $end_ts ) {
 				// If staff present, delete per-staff; else delete generic no-staff (0).
 				if ( [] !== $staff_ids ) {
 					foreach ( $staff_ids as $sid ) {
 						$data_store->delete_by_criteria([
 							'source'     => 'appointment',
 							'product_id' => (int) $product_id,
 							'staff_id'   => (int) $sid,
 							'start_ts'   => (int) $start_ts,
 							'end_ts'     => (int) $end_ts,
 							'status'     => 'in-cart',
 						]);
 					}
 				} else {
 					$data_store->delete_by_criteria([
 						'source'     => 'appointment',
 						'product_id' => (int) $product_id,
 						'staff_id'   => 0,
 						'start_ts'   => (int) $start_ts,
 						'end_ts'     => (int) $end_ts,
 						'status'     => 'in-cart',
 					]);
 				}
 			}
 		}

 		// Invalidate front-end caches once after removals.
 		$this->invalidate_frontend_cache();
 	}

	/**
     * Unified public API: delete all cache rows for a specific Appointment.
     */
    public function delete_appointment( int $appointment_id ): void {
		if ( 0 >= $appointment_id ) {
			return;
		}
		$this->delete_rows( 'appointment', $appointment_id );
		$this->invalidate_frontend_cache();
	}


	/**
     * Unified public API: delete all cache rows for an availability rule.
     *
     * @param int $availability_id
     */
    public function delete_availability( $availability_id ): void {
		$this->delete_rows( 'availability', (int) $availability_id );
		$this->invalidate_frontend_cache();
	}

	/**
     * Hook wrapper: after availability save -> index by ID.
     *
     * @param object $availability
     */
    public function on_availability_saved( $availability ): void {
		// Check if indexed availability is enabled
		if ( ! self::is_index_enabled() ) {
			return;
		}

		if ( is_object( $availability ) && method_exists( $availability, 'get_id' ) ) {
			$this->index_availability( (int) $availability->get_id() );
		}
	}

	/**
	 * Called after an appointment object is saved.
	 * @param WC_Appointment $appointment
	 * @param mixed          $data_store
	 * @param array          $changes
	 */
	public function on_appointment_saved( $appointment, $data_store = null, $changes = [] ): void {
		// Check if indexed availability is enabled
		if ( ! self::is_index_enabled() ) {
			return;
		}

		// Be defensive about the object.
		if ( ! $appointment || ! is_object( $appointment ) || ! method_exists( $appointment, 'get_id' ) ) {
			return;
		}

		// Ensure cache rows reflect the latest snapshot.
		$this->delete_rows( 'appointment', (int) $appointment->get_id() );
		$this->insert_appointment_row( $appointment );

		$this->invalidate_frontend_cache();
	}

	/**
	 * Called when appointment status transitions.
	 * Core emits ($appointment_id, $appointment).
	 *
	 * @param int             $appointment_id
	 * @param WC_Appointment  $appointment
	 */
	public function on_appointment_status_event( $appointment_id, $appointment = null ): void {
		// Check if indexed availability is enabled
		if ( ! self::is_index_enabled() ) {
			return;
		}

		$appointment_id = (int) $appointment_id;

		// Prefer the object if passed, otherwise try to load.
		if ( ! $appointment || ! is_object( $appointment ) ) {
			try {
				$appointment = get_wc_appointment( $appointment_id );
			} catch ( \Exception $e ) {
				return;
			}
		}
		if ( ! $appointment || ! method_exists( $appointment, 'get_id' ) ) {
			return;
		}

		// Re-index this appointment row to reflect new status/qty/staff etc.
		$this->delete_rows( 'appointment', (int) $appointment->get_id() );
		$this->insert_appointment_row( $appointment );

		$this->invalidate_frontend_cache();
	}

	/**
     * Bridge payment completion -> index all appointments for that order.
     *
     * @param int|string $order_id
     */
    public function index_order_appointments( $order_id ): void {
		$order_id = (int) $order_id;
		if ( 0 >= $order_id ) {
			return;
		}

		if ( ! class_exists( 'WC_Appointment_Data_Store' ) ) {
			return;
		}

		$appointment_ids = (array) WC_Appointment_Data_Store::get_appointment_ids_from_order_id( $order_id );
		if ( [] === $appointment_ids ) {
			return;
		}

		foreach ( $appointment_ids as $aid ) {
			$aid = (int) $aid;
			if ( 0 >= $aid ) {
				continue;
			}
			try {
				$appointment = get_wc_appointment( $aid );
			} catch ( \Exception $e ) {
				continue;
			}
			if ( ! $appointment ) {
				continue;
			}
			$this->delete_rows( 'appointment', $aid );
			$this->insert_appointment_row( $appointment );
		}

		$this->invalidate_frontend_cache();
	}

	/**
	 * Delete appointment from post ID.
	 *
	 * @param int $post_id
	 * @since 5.0.0
	 */
	public function delete_appointment_from_post( $post_id ): void {
		if ( 'wc_appointment' !== get_post_type( $post_id ) ) {
			return;
		}

		$this->delete_rows( 'appointment', (int) $post_id );
	}

	/**
     * Re-index appointment after it is restored from trash.
     *
     * @param int $post_id
     */
    public function reindex_appointment_from_untrash( $post_id ): void {
		// Only handle appointment post type.
		if ( 'wc_appointment' !== get_post_type( $post_id ) ) {
			return;
		}

		// Respect setting: only index when feature is enabled.
		if ( ! self::is_index_enabled() ) {
			return;
		}

		// Re-index the appointment to add it back to the cache index.
		$this->index_appointment( (int) $post_id );
	}

	/**
     * Unified public API: index a single appointment by ID.
     * Idempotent. Internally forwards to existing implementation.
     */
    public function index_appointment( int $appointment_id ): void {
		if ( 0 >= $appointment_id ) {
			return;
		}
		// Prefer the existing method if present.
		if ( method_exists( $this, 'index_appointment_from_id' ) ) {
			$this->index_appointment_from_id( $appointment_id );
			$this->invalidate_frontend_cache();
			return;
		}
		// Fallback: load object and call object-based indexer if available.
		if ( method_exists( $this, 'index_appointment_object' ) ) {
			$appointment = get_wc_appointment( $appointment_id );
			if ( $appointment ) {
				$this->index_appointment_object( $appointment );
				$this->invalidate_frontend_cache();
			}
		}
	}

	/**
	 * Index appointment from ID.
	 *
	 * Loads an appointment by ID and indexes it.
	 *
	 * @param int $appointment_id Appointment ID.
	 */
	public function index_appointment_from_id( $appointment_id ): void {
		$appointment_id = (int) $appointment_id;
		if ( 0 >= $appointment_id ) {
			return;
		}

		// Prevent PHP timeouts during indexing operations
		if ( ! function_exists( 'ini_get' ) || ! ini_get( 'safe_mode' ) ) {
			@set_time_limit( 0 );
		}
		@ini_set( 'max_execution_time', 0 );

		try {
			$appointment = get_wc_appointment( $appointment_id );
		} catch ( Exception $e ) {
			return;
		}
		$this->delete_rows( 'appointment', $appointment_id );
		$this->insert_appointment_row( $appointment );

		$this->invalidate_frontend_cache();
	}

	private function insert_appointment_row( $appointment ): void {
		$product_id      = (int) ( method_exists( $appointment, 'get_product_id' ) ? $appointment->get_product_id() : 0 );
		// Legacy single staff id.
		$legacy_staff_id = (int) ( method_exists( $appointment, 'get_staff_id' ) ? $appointment->get_staff_id() : 0 );
		// Multiple staff ids support.
		$staff_ids       = method_exists( $appointment, 'get_staff_ids' ) ? (array) $appointment->get_staff_ids() : [];
		// Multiple staff ids support.
		$status          = method_exists( $appointment, 'get_status' ) ? (array) $appointment->get_status() : '';

		// Normalize staff IDs: merge legacy single staff id if provided, ensure ints, unique, allow empty -> [0].
		if ( 0 < $legacy_staff_id ) {
			$staff_ids[] = $legacy_staff_id;
		}
		$staff_ids = array_values( array_unique( array_map( 'intval', array_filter( $staff_ids, static fn($v): bool => is_numeric( $v ) && (int) $v >= 0 ) ) ) );
		if ( [] === $staff_ids ) {
			$staff_ids = [ 0 ]; // no specific staff assigned
		}

		$qty = (int) ( method_exists( $appointment, 'get_qty' ) ? $appointment->get_qty() : 1 );

		// Do not cache appointments that were in the cart.
		if ( 'was-in-cart' === $status ) {
			return;
		}

		$status   = method_exists( $appointment, 'get_status' ) ? $appointment->get_status() : 'trash';
		$start    = method_exists( $appointment, 'get_start' ) ? $appointment->get_start() : '';
		$end      = method_exists( $appointment, 'get_end' ) ? $appointment->get_end() : '';
		$start_ts = is_numeric( $start ) ? (int) $start : ( $start ? strtotime( $start . ' UTC' ) : 0 );
		$end_ts   = is_numeric( $end ) ? (int) $end : ( $end ? strtotime( $end . ' UTC' ) : 0 );

		if ( ! $start_ts || ! $end_ts || $end_ts <= $start_ts ) {
			return;
		}

		// During manual re-indexing, include past appointments within the retention period
		// For automatic indexing, only index future appointments
		if ( ! $this->should_index_end_ts( $end_ts ) ) {
			return;
		}

		if ( ! class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			return;
		}

		$rows = [];
		foreach ( $staff_ids as $sid ) {
			$rows[] = [
				'source'        => 'appointment',
				'source_id'     => (int) $appointment->get_id(),
				'product_id'    => $product_id,
				'staff_id'      => (int) $sid,
				'scope'         => 'product',
				'appointable'   => 'no',
				'priority'      => 10,
				'qty'           => max( 1, $qty ),
				'start_ts'      => $start_ts,
				'end_ts'        => $end_ts,
				'range_type'    => 'appointment',
				'rule_kind'     => 'appointment',
				'status'        => $status,
				'date_created'  => current_time( 'mysql' ),
				'date_modified' => current_time( 'mysql' ),
			];
		}

		if ( [] === $rows ) {
			return;
		}

		$data_store = new WC_Appointments_Availability_Cache_Data_Store();
		$data_store->bulk_insert( $rows );
	}

	/**
	 * Delete rows.
	 *
	 * Deletes cache rows for a specific source and ID.
	 *
	 * @param string $source    Source type (availability, appointment).
	 * @param int    $source_id Source ID.
	 */
	private function delete_rows( string $source, int $source_id ): void {
		if ( ! class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			return;
		}

		$data_store = new WC_Appointments_Availability_Cache_Data_Store();
		$data_store->delete_by_source( $source, $source_id );
	}

	private function index_non_rrule_availability( object $availability, array $limits = [] ): int {
		// Prevent PHP timeouts during indexing operations
		if ( ! function_exists( 'ini_get' ) || ! ini_get( 'safe_mode' ) ) {
			@set_time_limit( 0 );
		}
		@ini_set( 'max_execution_time', 0 );

		$kind        = (string) $availability->get_kind();
		$kind_id     = (string) $availability->get_kind_id();
		$range_type  = (string) $availability->get_range_type();
		$appointable = (string) $availability->get_appointable();
		$priority    = (int) $availability->get_priority();
		$ordering    = (int) $availability->get_ordering();
		$qty         = (int) $availability->get_qty();

		// Status.
		$status = false !== strpos( $range_type, ':expired' ) ? 'expired' : '';
		if ( 'expired' === $status ) {
			return 0; // do not index expired rules.
		}

		// Determine scope/product/staff from 'kind' and 'kind_id'.
		$scope_info = $this->derive_scope( $kind, $kind_id );
		$product_id = $scope_info['product_id'];
		$staff_id   = $scope_info['staff_id'];
		$scope      = $scope_info['scope'];

		// Window (DATE scope, not DATETIME). These strings are interpreted by the rule manager.
		$from_date = trim( (string) $availability->get_from_date() ); // e.g. '2025-01-01' or empty
		$to_date   = trim( (string) $availability->get_to_date() );   // e.g. '2025-12-31' or empty

		// Build processing context similar to production rule evaluation.
		$product_obj = $product_id ? wc_get_product( $product_id ) : null;

		// Normalize the single rule using the Rule Manager (for non-time rules fallback).
		if ( ! class_exists( 'WC_Product_Appointment_Rule_Manager' ) ) {
			return 0;
		}
		$processed_rules = WC_Product_Appointment_Rule_Manager::process_availability_rules(
		    [ $availability ],
		    $scope,
		    false,
		    $product_obj,
		);
		$rule = $processed_rules[0] ?? null;
		if ( ! $rule ) {
			return 0;
		}

		$now     = $this->now_ts();
		$horizon = $this->horizon_ts();
		$force_full_index = (bool) get_option( 'wc_appointments_manual_reindex_full_indexing', false );

		// During manual re-indexing, include past occurrences within retention period
		// For automatic indexing, only index from today forward
		$retention_days = self::INDEX_RETENTION_DAYS;
		$retention_cut = strtotime( '-' . $retention_days . ' days UTC', $now );
		$min_start_day = $force_full_index ? $retention_cut : strtotime( 'today 00:00:00 UTC', $now );

		// Determine day iteration bounds (inclusive) using only DATEs. If missing, clamp appropriately.
		$start_day_ts = '' !== $from_date ? strtotime( $from_date . ' 00:00:00 UTC' ) : $min_start_day;
		$end_day_ts   = ''   !== $to_date ? strtotime( $to_date   . ' 23:59:59 UTC' ) : $horizon;

		$start_day_ts = max( $min_start_day, $start_day_ts );
		$end_day_ts   = min( $horizon, $end_day_ts );

		if ( $end_day_ts <= $start_day_ts ) {
			return 0;
		}

		$availability_id = (int) $availability->get_id();
		$last_cached_end = $this->get_availability_last_cached_end_ts( $availability_id );
		$force_full_index = (bool) get_option( 'wc_appointments_manual_reindex_full_indexing', false );

		if ( 0 < $last_cached_end ) {
			$resume_from_day = strtotime( gmdate( 'Y-m-d 00:00:00', $last_cached_end - 1 ) . ' UTC' );
			$start_day_ts    = max( $start_day_ts, $resume_from_day );

			// For recurring rules with force_full_index, extend end_day_ts to include next occurrence
			// But still respect reasonable limits to avoid timeouts
			if ( $force_full_index && '' === $to_date && ( strpos( $range_type, 'time:' ) === 0 || 'days' === $range_type ) ) {
				$tolerance_seconds = self::HORIZON_TOLERANCE_DAYS * DAY_IN_SECONDS;
				$extended_end_ts = $horizon + $tolerance_seconds + ( 7 * DAY_IN_SECONDS );
				$end_day_ts = max( $end_day_ts, $extended_end_ts );
			}
		}

		$max_days = (int) ( $limits['max_days'] ?? PHP_INT_MAX );
		$max_rows = (int) ( $limits['max_rows'] ?? PHP_INT_MAX );

		// Handle days range differently - iteration by full day.
		if ( 'days' === $range_type ) {
			$from_rng = trim( (string) $availability->get_from_range() ); // day number 1-7 (1=Monday, 7=Sunday)
			$to_rng   = trim( (string) $availability->get_to_range() );   // day number 1-7

			if ( '' !== $from_rng && '' !== $to_rng ) {
				$from_day = (int) $from_rng;
				$to_day   = (int) $to_rng;

				// Validate day range (1-7)
				if ( 1 <= $from_day && 7 >= $from_day && 1 <= $to_day && 7 >= $to_day ) {
					return $this->index_days_range_availability( $availability, $from_day, $to_day, $start_day_ts, $end_day_ts );
				}
			}
			return 0; // Invalid day range
		}

		// Handle month ranges differently - iterate by month instead of day
		if ( 'months' === $range_type ) {
			$from_rng = trim( (string) $availability->get_from_range() ); // month number 1-12
			$to_rng   = trim( (string) $availability->get_to_range() );   // month number 1-12

			if ( '' !== $from_rng && '' !== $to_rng ) {
				$from_month = (int) $from_rng;
				$to_month   = (int) $to_rng;

				// Validate month range (1-12)
				if ( 1 <= $from_month && 12 >= $from_month && 1 <= $to_month && 12 >= $to_month ) {
					return $this->index_month_range_availability( $availability, $from_month, $to_month, $start_day_ts, $end_day_ts, $horizon );
				}
			}
			return 0; // Invalid month range
		}

		// Special-case: custom:daterange should index a single span from from_date/from_range to to_date/to_range.
		if ( 'custom:daterange' === $range_type ) {
			$from_rng = trim( (string) $availability->get_from_range() ); // HH:MM
			$to_rng   = trim( (string) $availability->get_to_range() );   // HH:MM

			if ( '' !== $from_date && '' !== $to_date && '' !== $from_rng && '' !== $to_rng ) {
				$to_minutes = static function( string $hhmm ): int {
					$parts = explode( ':', $hhmm );
					$h = (int) ( $parts[0] ?? 0 );
					$m = (int) ( $parts[1] ?? 0 );
					return max( 0, min( 23, $h ) ) * 60 + max( 0, min( 59, $m ) );
				};

				$from_min = $to_minutes( $from_rng );
				$to_min   = $to_minutes( $to_rng );

				$start_day = strtotime( $from_date . ' 00:00:00 UTC' );
				$end_day   = strtotime( $to_date   . ' 00:00:00 UTC' );

				$occ_start = $start_day + ( $from_min * 60 );
				$occ_end   = $end_day   + ( $to_min   * 60 );

				if ( $this->should_index_end_ts( $occ_end ) && $occ_end > $occ_start ) {
					$rows = [
						[
							'source'        => 'availability',
							'source_id'     => (int) $availability->get_id(),
							'product_id'    => $product_id,
							'staff_id'      => $staff_id,
							'scope'         => $scope,
							'appointable'   => $appointable,
							'priority'      => $priority,
							'ordering'      => $ordering,
							'qty'           => $qty,
							'start_ts'      => $occ_start,
							'end_ts'        => $occ_end,
							'range_type'    => $range_type,
							'rule_kind'     => $kind,
							'status'        => $status,
							'date_created'  => current_time( 'mysql' ),
							'date_modified' => current_time( 'mysql' ),
						],
					];

					return $this->bulk_insert_cache_rows( $rows );
				}

				// If invalid or reversed bounds, do not index.
				return 0;
			}
			// If missing bounds, fall through to the default time/day indexing below.
		}

		// Parse explicit time range if available (e.g., 'time' rules), to avoid per-minute expansion.
		$from_rng = trim( (string) $availability->get_from_range() ); // e.g. '08:00'
		$to_rng   = trim( (string) $availability->get_to_range() );   // e.g. '04:00'
		$has_time_bounds = ( '' !== $from_rng && '' !== $to_rng );

		$parse_to_minutes = function( string $hhmm ): int {
			// Accept HH:MM or HH:MM:SS; treat invalid as 0.
			$parts = explode( ':', $hhmm );
			$h = (int) ( $parts[0] ?? 0 );
			$m = (int) ( $parts[1] ?? 0 );
			return max( 0, min( 23, $h ) ) * 60 + max( 0, min( 59, $m ) );
		};

		$from_min = $has_time_bounds ? $parse_to_minutes( $from_rng ) : null;
		$to_min   = $has_time_bounds ? $parse_to_minutes( $to_rng )   : null;

		$rows        = [];
		$cursor      = (int) strtotime( gmdate( 'Y-m-d 00:00:00', $start_day_ts ) . ' UTC' );
		$days_done   = 0;
		$rows_done   = 0;
		$inserted    = 0;

		while ( $cursor <= $end_day_ts ) {
			if ( $days_done >= $max_days || $rows_done >= $max_rows ) {
				break;
			}

			// Check if this day matches the range_type day specification
			$day_matches = true;
			if ( strpos( $range_type, 'time:' ) === 0 ) {
				$day_spec = (int) substr( $range_type, 5 ); // Extract day number from 'time:X'
				if ( 1 <= $day_spec && 7 >= $day_spec ) {
					$current_day_of_week = (int) gmdate( 'N', $cursor ); // 1=Monday, 7=Sunday
					$day_matches = ( $current_day_of_week === $day_spec );
				}
			}

			// For custom range types, check if the current date is within from_range and to_range.
			if ( 'custom' === $range_type ) {
				$from_range_date = trim( (string) $availability->get_from_range() );
				$to_range_date = trim( (string) $availability->get_to_range() );

				if ( '' !== $from_range_date && '' !== $to_range_date ) {
					$current_date = gmdate( 'Y-m-d', $cursor );
					$day_matches = ( $current_date >= $from_range_date && $current_date <= $to_range_date );
				}
			}

			// Skip this day if it doesn't match the day specification
			if ( ! $day_matches ) {
				$cursor += DAY_IN_SECONDS;
				$days_done++;
				continue;
			}

			// Skip this day if it's already fully cached (unless force_full_index is enabled for recurring rules)
			if ( 0 < $last_cached_end && ( ! $force_full_index || ( strpos( $range_type, 'time:' ) !== 0 && 'days' !== $range_type ) ) ) {
				$day_end_ts = $cursor + DAY_IN_SECONDS;
				if ( $day_end_ts <= $last_cached_end ) {
					$cursor += DAY_IN_SECONDS;
					$days_done++;
					continue;
				}
			}

			// Prefer explicit time bounds when present (faster than per-minute expansion).
			$from_rng = trim( (string) $availability->get_from_range() );
			$to_rng   = trim( (string) $availability->get_to_range() );
			$has_time_bounds = ( '' !== $from_rng && '' !== $to_rng );

			if ( $has_time_bounds ) {
				$to_minutes = static function( string $hhmm ): int {
					$parts = explode( ':', $hhmm );
					$h = (int) ( $parts[0] ?? 0 );
					$m = (int) ( $parts[1] ?? 0 );
					return max( 0, min( 23, $h ) ) * 60 + max( 0, min( 59, $m ) );
				};

				$from_min = $to_minutes( $from_rng );
				$to_min   = $to_minutes( $to_rng );

				if ( $to_min < $from_min ) {
					// Overnight rule: save a single cached row spanning into the next day
					// start_ts = today's from_min, end_ts = next day's to_min (no +1 minute).
					$occ_start = $cursor + ( $from_min * 60 );
					$occ_end   = $cursor + DAY_IN_SECONDS + ( $to_min * 60 );

					if ( $this->should_index_end_ts( $occ_end ) && $occ_end > $occ_start ) {
						$rows[] = [
							'source' => 'availability',
							'source_id' => $availability_id,
							'product_id' => $product_id,
							'staff_id' => $staff_id,
							'scope' => $scope,
							'appointable' => $appointable,
							'priority' => $priority,
							'ordering' => $ordering,
							'qty' => $qty,
							'start_ts' => $occ_start,
							'end_ts' => $occ_end,
							'range_type' => $range_type,
							'rule_kind' => $kind,
							'status' => $status,
							'date_created' => current_time( 'mysql' ),
							'date_modified' => current_time( 'mysql' ),
						];
						$rows_done++;

						if ( count( $rows ) >= 200 ) {
							$inserted += $this->bulk_insert_cache_rows( $rows );
							$rows = [];
						}
					}
				} elseif ( $from_min === $to_min ) {
					// Whole-day rule: when from_range equals to_range, this represents the entire day.
					$occ_start = $cursor;
					$occ_end   = $cursor + DAY_IN_SECONDS; // End of day

					if ( $this->should_index_end_ts( $occ_end ) ) {
						$rows[] = [
							'source' => 'availability',
							'source_id' => $availability_id,
							'product_id' => $product_id,
							'staff_id' => $staff_id,
							'scope' => $scope,
							'appointable' => $appointable,
							'priority' => $priority,
							'ordering' => $ordering,
							'qty' => $qty,
							'start_ts' => $occ_start,
							'end_ts' => $occ_end,
							'range_type' => $range_type,
							'rule_kind' => $kind,
							'status' => $status,
							'date_created' => current_time( 'mysql' ),
							'date_modified' => current_time( 'mysql' ),
						];
						$rows_done++;

						if ( count( $rows ) >= 200 ) {
							$inserted += $this->bulk_insert_cache_rows( $rows );
							$rows = [];
						}
					}
				} else {
					// Same-day rule: one row for today's window (no +1 minute).
					$occ_start = $cursor + ( $from_min * 60 );
					$occ_end   = $cursor + ( $to_min * 60 );

					if ( $this->should_index_end_ts( $occ_end ) && $occ_end > $occ_start ) {
						$rows[] = [
							'source' => 'availability',
							'source_id' => $availability_id,
							'product_id' => $product_id,
							'staff_id' => $staff_id,
							'scope' => $scope,
							'appointable' => $appointable,
							'priority' => $priority,
							'ordering' => $ordering,
							'qty' => $qty,
							'start_ts' => $occ_start,
							'end_ts' => $occ_end,
							'range_type' => $range_type,
							'rule_kind' => $kind,
							'status' => $status,
							'date_created' => current_time( 'mysql' ),
							'date_modified' => current_time( 'mysql' ),
						];
						$rows_done++;

						if ( count( $rows ) >= 200 ) {
							$inserted += $this->bulk_insert_cache_rows( $rows );
							$rows = [];
						}
					}
				}
			} else {
				// Fallback to rule manager ranges; still index a single row per contiguous range,
				// and if the range wraps (end_min < start_min), save ONE row spanning into the next day.
				$minute_data = WC_Product_Appointment_Rule_Manager::get_rule_minute_range( $rule, $cursor );

				$ranges = [];
				if ( isset( $minute_data['ranges'] ) && is_array( $minute_data['ranges'] ) && (isset($minute_data['ranges']) && [] !== $minute_data['ranges']) ) {
					$ranges = $minute_data['ranges'];
				} else {
					$minutes = $minute_data['minutes'] ?? [];
					if ( is_array( $minutes ) && [] !== $minutes ) {
						$ranges = $this->compress_minutes_to_ranges( $minutes );
					}
				}

				foreach ( $ranges as $range ) {
						if ( $rows_done >= $max_rows ) {
							break 2;
						}

						$start_min = (int) $range[0];
						$end_min   = (int) $range[1];

						if ( $end_min < $start_min ) {
							// Overnight: one row spanning to next day end (no +1 minute).
							$occ_start = $cursor + ( $start_min * 60 );
							$occ_end   = $cursor + DAY_IN_SECONDS + ( $end_min * 60 );

							if ( $this->should_index_end_ts( $occ_end ) && $occ_end > $occ_start ) {
								$rows[] = [
									'source' => 'availability',
									'source_id' => $availability_id,
									'product_id' => $product_id,
									'staff_id' => $staff_id,
									'scope' => $scope,
									'appointable' => $appointable,
									'priority' => $priority,
									'ordering' => $ordering,
									'qty' => $qty,
									'start_ts' => $occ_start,
									'end_ts' => $occ_end,
									'range_type' => $range_type,
									'rule_kind' => $kind,
									'status' => $status,
									'date_created' => current_time( 'mysql' ),
									'date_modified' => current_time( 'mysql' ),
								];
								$rows_done++;

								if ( count( $rows ) >= 200 ) {
									$inserted += $this->bulk_insert_cache_rows( $rows );
									$rows = [];
								}
							}
						} else {
							// Normal same-day range as a single row (no +1 minute).
							$occ_start = $cursor + ( $start_min * 60 );
							$occ_end   = $cursor + ( $end_min * 60 );

							if ( $this->should_index_end_ts( $occ_end ) && $occ_end > $occ_start ) {
								$rows[] = [
									'source' => 'availability',
									'source_id' => $availability_id,
									'product_id' => $product_id,
									'staff_id' => $staff_id,
									'scope' => $scope,
									'appointable' => $appointable,
									'priority' => $priority,
									'ordering' => $ordering,
									'qty' => $qty,
									'start_ts' => $occ_start,
									'end_ts' => $occ_end,
									'range_type' => $range_type,
									'rule_kind' => $kind,
									'status' => $status,
									'date_created' => current_time( 'mysql' ),
									'date_modified' => current_time( 'mysql' ),
								];
								$rows_done++;

								if ( count( $rows ) >= 200 ) {
									$inserted += $this->bulk_insert_cache_rows( $rows );
									$rows = [];
								}
							}
						}
					}
			}

			$cursor += DAY_IN_SECONDS;
			$days_done++;

			if ( $cursor > ( $end_day_ts + DAY_IN_SECONDS ) ) {
				break;
			}
		}

		if ( [] !== $rows ) {
			$inserted += $this->bulk_insert_cache_rows( $rows );
		}

		return $inserted;
	}

	/**
     * Index availability for day-of-week ranges - check each day and index full days.
     *
     * @param WC_Appointments_Availability $availability
     * @param int $from_day Day number 1-7 (1=Monday, 7=Sunday)
     * @param int $to_day Day number 1-7
     * @param int $start_day_ts
     * @param int $end_day_ts
     * @return int Number of rows inserted
     */
    private function index_days_range_availability( object $availability, int $from_day, int $to_day, $start_day_ts, $end_day_ts ): int {
		$availability_id = (int) $availability->get_id();
		$kind            = (string) $availability->get_kind();
		$kind_id         = (string) $availability->get_kind_id();
		$range_type      = (string) $availability->get_range_type();
		$appointable     = (string) $availability->get_appointable();
		$priority        = (int) $availability->get_priority();
		$ordering        = (int) $availability->get_ordering();
		$qty             = (int) $availability->get_qty();
		$status          = ''; #status is not available with original availability.

		// Determine scope/product/staff from 'kind' and 'kind_id'.
		$scope_info = $this->derive_scope( $kind, $kind_id );
		$product_id = $scope_info['product_id'];
		$staff_id   = $scope_info['staff_id'];
		$scope      = $scope_info['scope'];

		#error_log( var_export( $availability, true ) );

		// Create a day-of-week range (handle wrapping, e.g., Friday to Monday)
		$target_days = [];
		if ( $to_day >= $from_day ) {
			// Normal range (e.g., Monday to Friday)
			for ( $day = $from_day; $day <= $to_day; $day++ ) {
				$target_days[] = $day;
			}
		} else {
			// Wrapping range (e.g., Friday to Monday)
			for ( $day = $from_day; 7 >= $day; $day++ ) {
				$target_days[] = $day;
			}
			for ( $day = 1; $day <= $to_day; $day++ ) {
				$target_days[] = $day;
			}
		}

		// Safety check: if no target days, return early
		if ( [] === $target_days ) {
			return 0;
		}

		$rows = [];
		$cursor = $start_day_ts;
		$inserted = 0;
		$days_processed = 0;
		$max_days = 365; // Safety limit to prevent infinite loops

		while ( $cursor <= $end_day_ts && $days_processed < $max_days ) {
			// Get day of the week (1=Monday, 7=Sunday)
			$day_of_week = (int) gmdate( 'N', $cursor );

			// Check if this day matches our target days
			if ( in_array( $day_of_week, $target_days, true ) ) {
				// Index the full day (00:00:00 to 23:59:59)
				$day_start = $cursor;
				#$day_end = $cursor + 86400 - 1; // 23:59:59 (86400 = 24 * 60 * 60)
				$day_end = $cursor + 86400; // the next day at noon.

				// Index if within retention period (for manual re-indexing) or in the future (for automatic)
				if ( $this->should_index_end_ts( $day_end ) ) {
					$rows[] = [
						'source' => 'availability',
						'source_id' => $availability_id,
						'product_id' => $product_id,
						'staff_id' => $staff_id,
						'scope' => $scope,
						'appointable' => $appointable,
						'priority' => $priority,
						'ordering' => $ordering,
						'qty' => $qty,
						'start_ts' => $day_start,
						'end_ts' => $day_end,
						'range_type' => $range_type,
						'rule_kind' => $kind,
						'status' => $status,
						'date_created' => current_time( 'mysql' ),
						'date_modified' => current_time( 'mysql' ),
					];

					if ( count( $rows ) >= 200 ) {
						$inserted += $this->bulk_insert_cache_rows( $rows );
						$rows = [];
					}
				}
			}

			// Move to the next day.
			$cursor += 86400; // 24 * 60 * 60 seconds
			$days_processed++;
		}

		if ( [] !== $rows ) {
			$inserted += $this->bulk_insert_cache_rows( $rows );
		}

		return $inserted;
	}

	/**
     * Index availability for month ranges - iterate by month instead of day.
     *
     * @param WC_Appointments_Availability $availability
     * @param int $from_month Month number 1-12
     * @param int $to_month Month number 1-12
     * @param int $start_day_ts
     * @param int $end_day_ts
     * @return int Number of rows inserted
     */
    private function index_month_range_availability( object $availability, int $from_month, int $to_month, $start_day_ts, $end_day_ts, int $horizon ): int {

		$availability_id = (int) $availability->get_id();
		$kind            = (string) $availability->get_kind();
		$kind_id         = (string) $availability->get_kind_id();
		$range_type      = (string) $availability->get_range_type();
		$appointable     = (string) $availability->get_appointable();
		$priority        = (int) $availability->get_priority();
		$availability->get_ordering();
		$qty             = (int) $availability->get_qty();
		$status          = ''; #status is not available with original availability.

		// Determine scope/product/staff from 'kind' and 'kind_id'.
		$scope_info = $this->derive_scope( $kind, $kind_id );
		$product_id = $scope_info['product_id'];
		$staff_id   = $scope_info['staff_id'];
		$scope      = $scope_info['scope'];

		$rows     = [];
		$inserted = 0;

		// Get the year range to iterate through
		$start_year = (int) gmdate( 'Y', $start_day_ts );
		$end_year   = (int) gmdate( 'Y', $end_day_ts );

		// Iterate through years and months
		for ( $year = $start_year; $year <= $end_year; $year++ ) {
			// Determine which months to process for this year
			$year_start_month = ( $year === $start_year ) ? (int) gmdate( 'n', $start_day_ts ) : 1;
			$year_end_month   = ( $year === $end_year ) ? (int) gmdate( 'n', $end_day_ts ) : 12;

			for ( $month = $year_start_month; $month <= $year_end_month; $month++ ) {
				// Check if this month is in the availability range
				if ( $this->is_month_in_range( $month, $from_month, $to_month ) ) {
					// Create a month range: first day 00:00:00 to first day of next month 00:00:00
					$month_start = strtotime( sprintf( '%d-%02d-01 00:00:00 UTC', $year, $month ) );
					$next_month = $month + 1;
					$next_year = $year;
					if ( 12 < $next_month ) {
						$next_month = 1;
						$next_year++;
					}
					$month_end = strtotime( sprintf( '%d-%02d-01 00:00:00 UTC', $next_year, $next_month ) );

					// Ensure the month range is within our bounds and within retention period (for manual re-indexing) or future (for automatic)
					if ( $this->should_index_end_ts( $month_end ) && $month_start <= $horizon ) {
						// Always use whole month ranges regardless of horizon bounds
						$occ_start = $month_start;
						$occ_end   = $month_end;

						if ( $occ_end > $occ_start ) {
							$rows[] = [
								'source' => 'availability',
								'source_id' => $availability_id,
								'product_id' => $product_id,
								'staff_id' => $staff_id,
								'scope' => $scope,
								'appointable' => $appointable,
								'priority' => $priority,
								'qty' => $qty,
								'start_ts' => $occ_start,
								'end_ts' => $occ_end,
								'range_type' => $range_type,
								'rule_kind' => $kind,
								'status' => $status,
								'date_created' => current_time( 'mysql' ),
								'date_modified' => current_time( 'mysql' ),
							];

							if ( count( $rows ) >= 200 ) {
								$inserted += $this->bulk_insert_cache_rows( $rows );
								$rows = [];
							}
						}
					}
				}
			}
		}

		if ( [] !== $rows ) {
			$inserted += $this->bulk_insert_cache_rows( $rows );
		}

		return $inserted;
	}

	/**
     * Check if a month is within the availability range, handling wrap-around.
     *
     * @param int $month Current month (1-12)
     * @param int $from_month Start month (1-12)
     * @param int $to_month End month (1-12)
     */
    private function is_month_in_range( int $month, int $from_month, int $to_month ): bool {
		if ( $from_month <= $to_month ) {
			// Normal range (e.g., March to September: 3-9)
			return $month >= $from_month && $month <= $to_month;
		}
        // Wrap-around range (e.g., October to February: 10-2)
        return $month >= $from_month || $month <= $to_month;
	}

	/**
	 * Turn a sorted list of minute indices into contiguous [start,end] minute ranges.
	 * Example: [0,1,2,5,6] -> [[0,2],[5,6]]
	 *
	 * @param int[] $minutes
	 * @return array<int,int>[]
	 */
	private function compress_minutes_to_ranges( array $minutes ): array {
		$ranges = [];
		sort( $minutes, SORT_NUMERIC );

		$start = null;
		$prev  = null;

		foreach ( $minutes as $m ) {
			if ( null === $start ) {
				$start = $m;
				$prev  = $m;
				continue;
			}
			if ( $prev + 1 === $m ) {
				$prev = $m;
				continue;
			}
			// Break in sequence.
			$ranges[] = [ $start, $prev ];
			$start = $m;
			$prev  = $m;
		}

		if ( null !== $start ) {
			$ranges[] = [ $start, $prev ];
		}

		return $ranges;
	}

	/**
	 * Expand RRULE availability.
	 *
	 * Expands and indexes a recurring availability rule.
	 *
	 * @param object $availability Availability object.
	 * @param string $rrule_string RRULE string.
	 * @param array  $limits       Processing limits.
	 * @return int Number of rows inserted.
	 */
	private function expand_rrule_availability( object $availability, string $rrule_string, array $limits = [] ): int {
		// Prevent PHP timeouts during indexing operations
		if ( ! function_exists( 'ini_get' ) || ! ini_get( 'safe_mode' ) ) {
			@set_time_limit( 0 );
		}
		@ini_set( 'max_execution_time', 0 );

		$kind        = (string) $availability->get_kind();
		$kind_id     = (string) $availability->get_kind_id();
		$range_type  = (string) $availability->get_range_type();
		$appointable = (string) $availability->get_appointable();
		$priority    = (int) $availability->get_priority();
		$ordering    = (int) $availability->get_ordering();
		$qty         = (int) $availability->get_qty();

		// Status.
		$status = false !== strpos( $range_type, ':expired' ) ? 'expired' : '';
		if ( 'expired' === $status ) {
			return 0; #do not index expired rules.
		}

		#error_log( var_export( $rrule_string, true ) );

		$scope_info = $this->derive_scope( $kind, $kind_id );
		$product_id = $scope_info['product_id'];
		$staff_id   = $scope_info['staff_id'];
		$scope      = $scope_info['scope'];

		// DTSTART: prefer from_range, fallback to to_range.
		$from_rng = trim( (string) $availability->get_from_range() );
		$to_rng   = trim( (string) $availability->get_to_range() );
		$dtstart_str = '' !== $from_rng ? $from_rng : $to_rng;
		if ( '' === $dtstart_str ) {
			return 0;
		}

		if ( ! class_exists( \RRule\RRule::class ) ) {
			return 0;
		}

		try {
			// Handle timezone for recurring rules
			// Parse the datetime string with its original timezone and create DTSTART in site timezone
			$site_timezone = new DateTimeZone( wc_timezone_string() );

			// Parse the datetime string with its original timezone
			$temp_dt = new DateTimeImmutable( $dtstart_str );

			// Get the time components in the original timezone
			$year = (int) $temp_dt->format( 'Y' );
			$month = (int) $temp_dt->format( 'n' );
			$day = (int) $temp_dt->format( 'j' );
			$hour = (int) $temp_dt->format( 'G' );
			$minute = (int) $temp_dt->format( 'i' );
			$second = (int) $temp_dt->format( 's' );

			// Create a datetime object with the same time components but in site timezone
			$dtstart = new DateTimeImmutable();
			$dtstart = $dtstart->setTimezone( $site_timezone )
								 ->setDate( $year, $month, $day )
								 ->setTime( $hour, $minute, $second );

			// If the provided rrule string contains multiple lines (RRULE and EXDATE entries),
			// extract only the RRULE line to pass into the RRule constructor. Passing
			// the full multi-line string may cause the library to not yield occurrences.
			$rrule_to_pass = $rrule_string;
			if ( preg_match( '/(^|\r?\n)RRULE:(.+?)(\r?\n|$)/i', $rrule_string, $m ) ) {
				$rrule_to_pass = trim( $m[2] );
			}

			$rule    = new \RRule\RRule( $rrule_to_pass, $dtstart );
		} catch ( \Throwable $e ) {
			return 0;
		}

		$horizon = $this->horizon_ts();
		$this->now_ts();

		// For RRULE rules with force_full_index, extend horizon to include next occurrence
		// But still respect reasonable limits to avoid timeouts
		$force_full_index = (bool) get_option( 'wc_appointments_manual_reindex_full_indexing', false );
		$to_date = trim( (string) $availability->get_to_date() );
		if ( $force_full_index && '' === $to_date ) {
			$tolerance_seconds = self::HORIZON_TOLERANCE_DAYS * DAY_IN_SECONDS;
			$horizon = $horizon + $tolerance_seconds + ( 7 * DAY_IN_SECONDS );
		}

		// Determine the duration for each occurrence.
		$duration_secs = 0;
		if ( '' !== $from_rng && '' !== $to_rng ) {
			// Parse datetime strings and convert to site timezone for duration calculation
			try {
				$from_dt = new DateTimeImmutable( $from_rng );
				$to_dt = new DateTimeImmutable( $to_rng );

				// Convert both to site timezone
				$from_dt_site = $from_dt->setTimezone( $site_timezone );
				$to_dt_site = $to_dt->setTimezone( $site_timezone );

				$fr_ts = $from_dt_site->getTimestamp();
				$to_ts = $to_dt_site->getTimestamp();

				if ( $to_ts > $fr_ts ) {
					$duration_secs = $to_ts - $fr_ts;
				}
			} catch ( \Throwable $e ) {
				// Fallback to the original method if parsing fails
				$fr_ts = strtotime( $from_rng . ' UTC' );
				$to_ts = strtotime( $to_rng . ' UTC' );
				if ( $fr_ts && $to_ts && $to_ts > $fr_ts ) {
					$duration_secs = $to_ts - $fr_ts;
				}
			}
		}

		$max_occ  = (int) ( $limits['max_occurrences'] ?? PHP_INT_MAX );
		$max_rows = (int) ( $limits['max_rows'] ?? PHP_INT_MAX );

		// Progress resume: ignore occurrences already fully cached in the future.
		$availability_id = (int) $availability->get_id();
		$last_cached_end = $this->get_availability_last_cached_end_ts( $availability_id );

		$rows      = [];
		$occ_done  = 0;
		$rows_done = 0;
		$inserted  = 0;

		// Parse EXDATE entries from the rrule string (if any) so we can skip those occurrences.
		// Build a set of keys in site timezone to compare against occurrences.
		// Support both date-only EXDATEs (Y-m-d) and date-time EXDATEs (Y-m-d H:i:s).
		$exdate_map = [];
		if ( stripos( $rrule_string, 'EXDATE' ) !== false ) {
			// Be tolerant to params (e.g., TZID) and capture the value portion after the colon.
			preg_match_all( '/EXDATE(?:;[^:]+)?:([^\r\n]+)/i', $rrule_string, $ex_matches );
			foreach ( $ex_matches[1] as $ex_list ) {
					$parts = array_map( 'trim', explode( ',', $ex_list ) );
					foreach ( $parts as $part ) {
						if ( '' === $part ) {
							continue;
						}
						try {
							// Try generic parse first.
							$ex_dt = new DateTimeImmutable( $part );
							$ex_dt_site = $ex_dt->setTimezone( $site_timezone );
							// Full datetime key.
							$ex_full = $ex_dt_site->format( 'Y-m-d H:i:s' );
							$ex_date = $ex_dt_site->format( 'Y-m-d' );
							$exdate_map[ $ex_full ] = true;
							$exdate_map[ $ex_date ] = true; // allow date-only matches
							continue;
						} catch ( \Throwable $e ) {
							// Fallbacks for iCal common formats

							// 1) UTC basic format YYYYMMDDTHHMMSSZ
							if ( preg_match( '/^\d{8}T\d{6}Z$/', $part ) ) {
								$dt = DateTimeImmutable::createFromFormat( 'Ymd\\THis\\Z', $part, new DateTimeZone( 'UTC' ) );
								if ( $dt ) {
									$dt_site = $dt->setTimezone( $site_timezone );
									$ex_full = $dt_site->format( 'Y-m-d H:i:s' );
									$ex_date = $dt_site->format( 'Y-m-d' );
									$exdate_map[ $ex_full ] = true;
									$exdate_map[ $ex_date ] = true;
									continue;
								}
							}

							// 2) Date-only YYYYMMDD
							if ( preg_match( '/^\d{8}$/', $part ) ) {
								$dt = DateTimeImmutable::createFromFormat( 'Ymd', $part, $site_timezone );
								if ( $dt ) {
									$ex_date = $dt->format( 'Y-m-d' );
									$exdate_map[ $ex_date ] = true;
									continue;
								}
							}

							// 3) Hyphenated date YYYY-MM-DD (ensure it's accepted)
							if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $part ) ) {
								try {
									$dt = new DateTimeImmutable( $part );
									$dt_site = $dt->setTimezone( $site_timezone );
									$ex_date = $dt_site->format( 'Y-m-d' );
									$exdate_map[ $ex_date ] = true;
									continue;
								} catch ( \Throwable $e2 ) {
									// ignore
								}
							}

							// If parsing fails, ignore this token.
							continue;
						}
					}
				}
		}

		foreach ( $rule as $occurrence ) {
			if ( $occ_done >= $max_occ || $rows_done >= $max_rows ) {
				break;
			}
			if ( ! $occurrence instanceof DateTimeInterface ) {
				continue;
			}

			// Normalize occurrence to site timezone for comparison with EXDATEs.
			$occ_in_site = $occurrence->setTimezone( $site_timezone );
			$occ_full = $occ_in_site->format( 'Y-m-d H:i:s' );
			$occ_date = $occ_in_site->format( 'Y-m-d' );

			// If this occurrence matches an EXDATE (either full datetime or date-only), skip it.
			if ( isset( $exdate_map[ $occ_full ] ) || isset( $exdate_map[ $occ_date ] ) ) {
				$occ_done++;
				continue;
			}

			// Compute start_ts preserving local time as before (format then strtotime with 'UTC').
			$local_time_str = $occurrence->format( 'Y-m-d H:i:s' );
			$start_ts = (int) strtotime( $local_time_str . ' UTC' );

			// Compute end_ts for occurrence duration.
			$end_ts = 0 < $duration_secs ? $start_ts + $duration_secs : $start_ts + DAY_IN_SECONDS;

			// During manual re-indexing, include past occurrences within retention period
			// For automatic indexing, skip fully past occurrences
			if ( ! $this->should_index_end_ts( $end_ts ) ) {
				$occ_done++;
				continue;
			}

			if ( $start_ts > $horizon ) {
				break;
			}

			if ( $end_ts <= $start_ts || ( 0 < $last_cached_end && $end_ts <= $last_cached_end ) ) {
				$occ_done++;
				continue;
			}

			$rows[] = [
				'source' => 'availability',
				'source_id' => (int) $availability->get_id(),
				'product_id' => $product_id,
				'staff_id' => $staff_id,
				'scope' => $scope,
				'appointable' => $appointable,
				'priority' => $priority,
				'ordering' => $ordering,
				'qty' => $qty,
				'start_ts' => $start_ts,
				'end_ts' => $end_ts,
				'range_type' => $range_type,
				'rule_kind' => $kind,
				'status' => $status,
				'date_created' => current_time( 'mysql' ),
				'date_modified' => current_time( 'mysql' ),
			];
			$rows_done++;
			$occ_done++;

			// Insert in reasonable chunks to avoid huge queries.
			if ( count( $rows ) >= 200 ) {
				$inserted += $this->bulk_insert_cache_rows( $rows );
				$rows = [];
			}
		}

		if ( [] !== $rows ) {
			$inserted += $this->bulk_insert_cache_rows( $rows );
		}

		return $inserted;
	}

	/**
	 * Bulk insert cache rows using CRUD data store.
	 *
	 * @param array $rows_data Array of row data arrays
	 * @return int Number of rows inserted
	 */
	private function bulk_insert_cache_rows( array $rows_data ): int {
		if ( [] === $rows_data ) {
			return 0;
		}

		if ( ! class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			return 0;
		}

		$data_store = new WC_Appointments_Availability_Cache_Data_Store();
		return $data_store->bulk_insert( $rows_data );
	}

	/**
	 * Derive scope.
	 *
	 * Determines the scope, product ID, and staff ID from kind/kind_id.
	 *
	 * @param string $kind    Kind string.
	 * @param string $kind_id Kind ID string.
	 * @return array Scope info.
	 */
	private function derive_scope( string $kind, string $kind_id ): array {
		// Heuristics based on existing kinds:
		// - 'global' kinds -> scope=global, product_id=0, staff_id=0
		// - 'product' kinds -> scope=product, product_id=kind_id
		// - 'staff' kinds -> scope=staff, staff_id=kind_id; product_id may be 0 or a parent product if available.
		$scope = 'global';
		$product_id = 0;
		$staff_id = 0;

		if ( stripos( $kind, 'product' ) !== false ) {
			$scope = 'product';
			$product_id = (int) $kind_id;
		} elseif ( stripos( $kind, 'staff' ) !== false ) {
			$scope = 'staff';
			$staff_id = (int) $kind_id;
		}

		return [
			'scope'      => $scope,
			'product_id' => $product_id,
			'staff_id'   => $staff_id,
		];
	}

	/**
	 * Daily task: extend the availability cache toward the horizon for all rules in bounded chunks.
	 * Single entry point to keep backfill simple and predictable.
	 */
	public function extend_rrule_horizon(): void {
		// Prevent PHP timeouts during indexing operations
		if ( ! function_exists( 'ini_get' ) || ! ini_get( 'safe_mode' ) ) {
			@set_time_limit( 0 );
		}
		@ini_set( 'max_execution_time', 0 );
		@ignore_user_abort( true );

		// Gather all availability rule IDs that could require indexing.
		$availability_ids = $this->get_all_availability_ids();
		if ( [] === $availability_ids ) {
			return;
		}

		// Periodic fail check: ensure all rules are indexed regularly
		$this->periodic_fail_check( $availability_ids );

		// Periodic fail check: ensure all appointments are indexed
		$this->periodic_fail_check_appointments();

		// For each rule, schedule/trigger a bounded indexing pass that advances it toward the horizon.
		foreach ( $availability_ids as $availability_id ) {
			$this->schedule_rule_index( (int) $availability_id );
		}

		// Optional: prune past rows to keep the cache lean.
		$this->prune_past_rows();
	}

	/**
	 * Schedule appointment backfill batches using the Action Scheduler.
	 * This mirrors how extend_rrule_horizon schedules per-rule indexing to avoid timeouts.
	 *
	 * @param int $limit Number of appointments to process per batch (the worker will reschedule itself if needed).
	 */
	public function schedule_backfill_appointments( $limit = 200 ): void {
		// Avoid duplicate schedules for appointment backfill batches.
		if ( ! as_next_scheduled_action( 'wc_appointments_backfill_appointments_batch' ) ) {
			as_schedule_single_action( time() + 15, 'wc_appointments_backfill_appointments_batch', [ (int) $limit ] );
		}
	}

	/**
	 * Background worker: process a bounded batch of appointments and reschedule if more remain.
	 *
	 * Called by Action Scheduler with a single integer argument $limit.
	 *
	 * @param int $limit
	 */
	public function run_backfill_appointments_batch( $limit = 200 ): void {
		// Prevent PHP timeouts during indexing operations
		if ( ! function_exists( 'ini_get' ) || ! ini_get( 'safe_mode' ) ) {
			@set_time_limit( 0 );
		}
		@ini_set( 'max_execution_time', 0 );
		@ignore_user_abort( true );

		$limit = (int) $limit;
		if ( 0 >= $limit ) {
			$limit = 200;
		}

		// Run one bounded pass that will schedule per-appointment indexing tasks.
		$processed = $this->backfill_appointments( $limit );

		// If we processed a full batch, assume more work remains and schedule another pass.
        // Avoid duplicate schedules for the same worker; schedule next pass.
        if ( $processed >= $limit && ! as_next_scheduled_action( 'wc_appointments_backfill_appointments_batch' ) ) {
			as_schedule_single_action( time() + 15, 'wc_appointments_backfill_appointments_batch', [ $limit ] );
		}

		// No immediate cache invalidation here — per-appointment indexing will invalidate as they run.
	}

	/**
	 * Periodic fail check: ensure all rules are indexed regularly within the horizon tolerance.
	 * This method identifies rules that:
	 * 1. Haven't been indexed recently (7+ days ago) AND don't reach the horizon within tolerance, OR
	 * 2. Were recently indexed but still don't reach the horizon within tolerance (indicates indexing failure).
	 *
	 * Rules must reach within HORIZON_TOLERANCE_DAYS of the horizon at all times for reliable index operation.
	 *
	 * @param array $availability_ids Array of availability rule IDs to check.
	 */
	private function periodic_fail_check( array $availability_ids ): void {
		if ( [] === $availability_ids ) {
			return;
		}

		$horizon = $this->horizon_ts();
		$check_threshold = strtotime( '-7 days', $this->now_ts() ); // Check rules not indexed in the last 7 days.
		$failed_rules = [];
		$recently_indexed_but_failed = [];
		$constrained_rules_not_at_limit = [];

		foreach ( $availability_ids as $availability_id ) {
			$availability_id = (int) $availability_id;
			if ( 0 >= $availability_id ) {
				continue;
			}

			// Check if a rule has been indexed recently
			$last_cached_end = $this->get_availability_last_cached_end_ts( $availability_id );

			// Skip rules with no cached data (they shouldn't be indexed yet or are new).
			if ( 0 >= $last_cached_end ) {
				continue;
			}

			// Check if rule has constraints that prevent it from reaching the horizon
			$constraint_info = $this->check_rule_constraints( $availability_id, $horizon );

			if ( $constraint_info['has_constraint'] ) {
				// Rule has constraints (to_date or UNTIL) that prevent it from reaching the horizon
				// Check if it's within tolerance of its maximum possible end (the constraint)
				$is_within_constraint_tolerance = $this->is_rule_within_horizon_tolerance( $last_cached_end, $constraint_info['max_possible_end_ts'] );

				if ( ! $is_within_constraint_tolerance ) {
					// Rule has constraints but isn't indexed to its limit - needs re-indexing
					$constrained_rules_not_at_limit[] = [
						'id' => $availability_id,
						'constraint_type' => $constraint_info['constraint_type'],
						'max_possible_end_ts' => $constraint_info['max_possible_end_ts'],
					];
				}
				// If rule is within tolerance of its constraint limit, skip it (it's already at its maximum)
				continue;
			}

			// Rule has no constraints - check if it's within tolerance of the horizon
			$is_within_tolerance = $this->is_rule_within_horizon_tolerance( $last_cached_end, $horizon );

			if ( ! $is_within_tolerance ) {
				// Rule doesn't reach horizon within tolerance - needs re-indexing
				$was_recently_indexed = $last_cached_end >= $check_threshold;

				if ( $was_recently_indexed ) {
					// Rule was indexed recently but still doesn't reach horizon - this indicates indexing failure
					$recently_indexed_but_failed[] = $availability_id;
				} else {
					// Rule hasn't been indexed recently - periodic fail check should catch this
					$failed_rules[] = $availability_id;
				}
			}
		}

		// Log and handle rules that haven't been indexed recently
		if ( [] !== $failed_rules ) {
			$this->log_index_message(
			    sprintf(
			        'Periodic fail check found %d rules requiring re-indexing (not indexed in 7+ days). Rule IDs: %s',
			        count( $failed_rules ),
			        implode( ', ', $failed_rules ),
			    ),
			    'warning',
			);

			foreach ( $failed_rules as $failed_rule_id ) {
				$this->run_rule_index_pass( $failed_rule_id );
			}
		}

		if ( [] !== $recently_indexed_but_failed ) {
			$this->log_index_message(
			    sprintf(
			        'CRITICAL: Periodic fail check found %d rules that were recently indexed but still don\'t reach horizon within %d-day tolerance. These rules have no constraints and should be able to reach the horizon. This indicates indexing failure. Rule IDs: %s',
			        count( $recently_indexed_but_failed ),
			        self::HORIZON_TOLERANCE_DAYS,
			        implode( ', ', $recently_indexed_but_failed ),
			    ),
			    'error',
			);

			$original_force_full_index = get_option( 'wc_appointments_manual_reindex_full_indexing', false );
			update_option( 'wc_appointments_manual_reindex_full_indexing', true, false );

			foreach ( $recently_indexed_but_failed as $failed_rule_id ) {
				$this->run_rule_index_pass( $failed_rule_id );
			}

			if ( true !== $original_force_full_index ) {
				delete_option( 'wc_appointments_manual_reindex_full_indexing' );
			}
		}

		if ( [] !== $constrained_rules_not_at_limit ) {
			$rule_ids = array_column( $constrained_rules_not_at_limit, 'id' );
			$constraint_types = array_unique( array_column( $constrained_rules_not_at_limit, 'constraint_type' ) );

			$this->log_index_message(
			    sprintf(
			        'Periodic fail check found %d rules with constraints (%s) that aren\'t indexed to their limit within %d-day tolerance. Rule IDs: %s',
			        count( $constrained_rules_not_at_limit ),
			        implode( ', ', $constraint_types ),
			        self::HORIZON_TOLERANCE_DAYS,
			        implode( ', ', $rule_ids ),
			    ),
			    'warning',
			);

			foreach ( $constrained_rules_not_at_limit as $constrained_rule ) {
				$this->run_rule_index_pass( $constrained_rule['id'] );
			}
		}
	}

	/**
     * Periodic fail check: ensure all appointments that should be indexed are actually in the index.
     * Checks for appointments that exist in the database but are missing from the index.
     *
     * Processes appointments in batches and indexes them directly to avoid flooding Action Scheduler.
     * For high-volume sites, this method processes a limited batch per run and will continue
     * checking on subsequent daily cron runs until all appointments are verified.
     */
    private function periodic_fail_check_appointments(): void {
		if ( ! class_exists( 'WC_Appointment_Data_Store' ) ) {
			return;
		}

		$now = $this->now_ts();
		$horizon = $this->horizon_ts();

		// Process a reasonable batch per run to avoid timeouts
		// For sites with hundreds of thousands of appointments, this will process
		// incrementally over multiple daily runs
		$check_limit = 200; // Check up to 200 appointments per run
		$index_limit = 50;  // Index up to 50 missing appointments per run to avoid timeouts

		// Get offset from last run to continue where we left off
		$last_offset = (int) get_option( 'wc_appointments_periodic_check_appt_offset', 0 );

		$appointment_ids = WC_Appointment_Data_Store::get_appointment_ids_by(
		    [
				'date_after' => $now - DAY_IN_SECONDS, // Include appointments starting from yesterday (ongoing)
				'date_before' => $horizon, // Up to horizon
				'limit' => $check_limit,
				'offset' => $last_offset,
				'order' => 'ASC',
				'order_by' => 'start_date',
			],
		);

		if ( empty( $appointment_ids ) ) {
			// Reset offset if we've checked all appointments
			update_option( 'wc_appointments_periodic_check_appt_offset', 0, false );
			return;
		}

		$missing_appointments = [];
		$cache_data_store = null;

		if ( class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			$cache_data_store = new WC_Appointments_Availability_Cache_Data_Store();
		}

		foreach ( $appointment_ids as $appointment_id ) {
			$appointment_id = (int) $appointment_id;
			if ( 0 >= $appointment_id ) {
				continue;
			}

			// Check if appointment exists in index
			if ( $cache_data_store instanceof \WC_Appointments_Availability_Cache_Data_Store ) {
				$existing_rows = $cache_data_store->get_items([
					'source' => 'appointment',
					'source_id' => $appointment_id,
					'limit' => 1,
				]);

				// Check if any existing row has end_ts > now (future appointment)
				$has_future_row = false;
				foreach ( $existing_rows as $row ) {
					if ( isset( $row['end_ts'] ) && (int) $row['end_ts'] > $now ) {
						$has_future_row = true;
						break;
					}
				}

				if ( ! $has_future_row ) {
					// Verify the appointment actually has a future end time before marking as missing
					try {
						$appointment = get_wc_appointment( $appointment_id );
						if ( $appointment && method_exists( $appointment, 'get_end' ) ) {
							$end_ts = (int) $appointment->get_end();
							if ( $end_ts > $now ) {
								$missing_appointments[] = $appointment_id;
							}
						}
					} catch ( Exception $e ) {
						// Skip if appointment can't be loaded
						continue;
					}
				}
			}
		}

		// Index missing appointments directly (in batches to avoid timeouts)
		$indexed_count = 0;
		if ( [] !== $missing_appointments ) {
			$this->log_index_message(
			    sprintf(
			        'Periodic fail check found %d appointments missing from index (checking batch starting at offset %d). Will index up to %d per run.',
			        count( $missing_appointments ),
			        $last_offset,
			        $index_limit,
			    ),
			    'warning',
			);

			// Index missing appointments directly in batches to avoid Action Scheduler overload
			foreach ( array_slice( $missing_appointments, 0, $index_limit ) as $appointment_id ) {
				try {
					$this->index_appointment_from_id( $appointment_id );
					$indexed_count++;
				} catch ( Exception $e ) {
					// Log error but continue with other appointments
					$this->log_index_message(
					    sprintf( 'Failed to index appointment %d during periodic check: %s', $appointment_id, $e->getMessage() ),
					    'error',
					);
				}
			}

			if ( 0 < $indexed_count ) {
				$this->log_index_message(
				    sprintf(
				        'Periodic fail check indexed %d missing appointments. Appointment IDs: %s',
				        $indexed_count,
				        implode( ', ', array_slice( $missing_appointments, 0, min( 20, $indexed_count ) ) ) . ( 20 < $indexed_count ? '...' : '' ),
				    ),
				    'info',
				);
			}

			// If there are more missing appointments than we indexed, log that they'll be processed in next run
			if ( count( $missing_appointments ) > $index_limit ) {
				$this->log_index_message(
				    sprintf(
				        'Periodic fail check: %d additional missing appointments will be indexed in subsequent runs.',
				        count( $missing_appointments ) - $index_limit,
				    ),
				    'info',
				);
			}
		}

		// Update offset for next run
		$new_offset = $last_offset + count( $appointment_ids );

		// If we processed a full batch, continue from this offset next time
		// Otherwise, reset offset to start from beginning (we've checked all appointments)
		if ( count( $appointment_ids ) >= $check_limit ) {
			update_option( 'wc_appointments_periodic_check_appt_offset', $new_offset, false );
		} else {
			// We've reached the end, reset for next cycle
			update_option( 'wc_appointments_periodic_check_appt_offset', 0, false );
		}
	}

	/**
	 * Get all availability rule IDs quickly (IDs only).
	 *
	 * @return int[]
	 */
	private function get_all_availability_ids(): array {
		if ( ! class_exists( 'WC_Appointments_Availability_Data_Store' ) ) {
			return [];
		}

		// Use the availability data store to get all IDs through CRUD
		try {
			$data_store = new WC_Appointments_Availability_Data_Store();
			// Get all availability objects and extract IDs.
			return $data_store->get_all_availability_rule_ids();
		} catch ( Exception $e ) {
			// Fallback to a direct query if CRUD method not available.
			global $wpdb;
			$table = $wpdb->prefix . 'wc_appointments_availability';
			$ids   = $wpdb->get_col( "SELECT ID FROM {$table}" );
			if ( empty( $ids ) ) {
				return [];
			}
			return array_map( 'intval', $ids );
		}
	}

	/**
	 * Prune past/expired rows from the cache to prevent unbounded growth.
	 * - Removes rows with status='expired' (availability)
	 * - Removes appointment/availability rows with end_ts older than retention
	 * - Removes stale in-cart appointment holds older than 1 day
	 */
	private function prune_past_rows(): void {
		if ( ! class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			return;
		}

		$data_store = new WC_Appointments_Availability_Cache_Data_Store();

		// Global retention cut for availability/appointment rows.
		$retention_days = defined( 'self::INDEX_RETENTION_DAYS' ) ? self::INDEX_RETENTION_DAYS : 30;
		$cut = strtotime( '-' . $retention_days . ' days UTC', $this->now_ts() );

		// Remove expired availability rows.
		$data_store->delete_by_time_criteria([
			'source' => 'availability',
			'status' => 'expired',
		]);

		// Remove very old rows (appointments and availability) beyond retention cut.
		$data_store->delete_by_time_criteria([
			'end_ts_before' => (int) $cut,
		]);

		// Remove stale in-cart appointment holds older than 1 day to free holds.
		$in_cart_cut = strtotime( '-1 day UTC', $this->now_ts() );
		$data_store->delete_by_time_criteria([
			'source' => 'appointment',
			'status' => 'in-cart',
			'end_ts_before' => (int) $in_cart_cut,
		]);
	}

	/**
	 * First-run backfill entry: enqueue per-rule passes, then mark complete.
	 * Keeps behavior aligned with the daily extender.
	 */
	public function run_backfill_batches(): void {
		// Prevent PHP timeouts during indexing operations
		if ( ! function_exists( 'ini_get' ) || ! ini_get( 'safe_mode' ) ) {
			@set_time_limit( 0 );
		}
		@ini_set( 'max_execution_time', 0 );
		@ignore_user_abort( true );

		// Schedule availability rule passes (do not insert directly to avoid timeouts).
		$this->extend_rrule_horizon();

		// Schedule appointment backfill batches (do not insert directly to avoid timeouts).
		$this->schedule_backfill_appointments();

		// Mark that initial backfill scheduling has been done (batches will run asynchronously).
		$this->mark_backfill_complete();
	}

	/**
	 * Backfill appointments.
	 *
	 * Processes a batch of appointments for backfilling the index.
	 *
	 * @param int $limit Max appointments to process.
	 * @return int Number of appointments processed.
	 */
	private function backfill_appointments( int $limit = 1000 ): int {
		$now = $this->now_ts();

		// Query future and ongoing appointments via data store.
		if ( ! class_exists( 'WC_Appointment_Data_Store' ) ) {
			return 0;
		}

		// Get offset from last run to continue where we left off
		$offset = (int) get_option( 'wc_appointments_backfill_appt_offset', 0 );

		// Fetch IDs where the end is after now (date_after applies to start, so we request a wider set and filter).
		$ids = WC_Appointment_Data_Store::get_appointment_ids_by(
		    [
				'date_after' => $now - YEAR_IN_SECONDS, // safety window; we'll filter precisely below.
				'limit'      => $limit,
				'offset'     => $offset,
				'order'      => 'ASC',
				'order_by'   => 'start_date',
			],
		);

		if ( empty( $ids ) ) {
			// Reset offset if we've processed all appointments
			update_option( 'wc_appointments_backfill_appt_offset', 0, false );
			return 0;
		}

		$processed = 0;
		$indexed = 0;
		$max_index_per_batch = 50; // Index up to 50 appointments directly per batch to avoid timeouts
		$offset_save_interval = 10; // Save offset every 10 processed appointments for resilience

		$cache_data_store = null;
		if ( class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			$cache_data_store = new WC_Appointments_Availability_Cache_Data_Store();
		}

		foreach ( $ids as $id ) {
			$id = (int) $id;

			// Skip if a future cache row already exists for this appointment using CRUD
			if ( $cache_data_store instanceof \WC_Appointments_Availability_Cache_Data_Store ) {
				$existing_rows = $cache_data_store->get_items([
					'source' => 'appointment',
					'source_id' => $id,
					'limit' => 1,
				]);

				// Check if any existing row has end_ts > now
				$has_future_row = false;
				foreach ( $existing_rows as $row ) {
					if ( isset( $row['end_ts'] ) && (int) $row['end_ts'] > $now ) {
						$has_future_row = true;
						break;
					}
				}

				if ( $has_future_row ) {
					$processed++;
					// Save offset periodically to ensure progress is preserved even if process is interrupted
					if ( $processed % $offset_save_interval === 0 ) {
						$current_offset = $offset + $processed;
						update_option( 'wc_appointments_backfill_appt_offset', $current_offset, false );
					}
					continue;
				}
			}

			try {
				$appointment = get_wc_appointment( $id );
			} catch ( Exception $e ) {
				$processed++;
				// Save offset periodically
				if ( $processed % $offset_save_interval === 0 ) {
					$current_offset = $offset + $processed;
					update_option( 'wc_appointments_backfill_appt_offset', $current_offset, false );
				}
				continue;
			}

			$end_ts = method_exists( $appointment, 'get_end' ) ? (int) $appointment->get_end() : 0;
			if ( $end_ts && $end_ts > $now ) {
				// Index directly in batches instead of scheduling individual Action Scheduler actions
				// This avoids flooding Action Scheduler queue for high-volume sites
				if ( $indexed < $max_index_per_batch ) {
					try {
						$this->index_appointment_from_id( $id );
						$indexed++;
					} catch ( Exception $e ) {
						// Log error but continue processing
						$this->log_index_message(
						    sprintf( 'Failed to index appointment %d during backfill: %s', $id, $e->getMessage() ),
						    'error',
						);
					}
				}
				$processed++;
			} else {
				$processed++;
			}

			// Save offset periodically to ensure progress is preserved even if process is interrupted
			if ( $processed % $offset_save_interval === 0 ) {
				$current_offset = $offset + $processed;
				update_option( 'wc_appointments_backfill_appt_offset', $current_offset, false );
			}

			// Stop if we've processed enough or indexed our batch limit
			if ( $processed >= $limit ) {
				break;
			}
		}

		// Update offset for next run (final save)
		$new_offset = $offset + count( $ids );

		// If we processed a full batch, continue from this offset next time
		// Otherwise, reset offset to start from beginning (we've processed all appointments)
		if ( count( $ids ) >= $limit ) {
			update_option( 'wc_appointments_backfill_appt_offset', $new_offset, false );
		} else {
			// We've reached the end, reset for next cycle
			update_option( 'wc_appointments_backfill_appt_offset', 0, false );
		}

		// Return number of appointments that needed indexing (not just processed)
		return $processed;
	}
}

new WC_Appointments_Cache_Availability();
