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

/**
 * Gets appointments
 */
class WC_Appointments_Controller {

	/**
	 * Check if debug logging is enabled.
	 *
	 * @param string $fn Function name.
	 *
	 * @return bool
	 */
	private static function dbg_enabled( $fn = '' ) {
		return apply_filters( 'wc_appointments_debug_timing', false, $fn );
	}

	/**
	 * Log debug message.
	 *
	 * @param string $msg Message to log.
	 */
	private static function dbg_log( string $msg ): void {
		if ( self::dbg_enabled() ) {
			error_log( '[WC Appointments Timing] ' . $msg );
		}
	}

	/**
	 * Find padding day slots for an appointable product.
	 *
	 * Calculates and returns days that should be un-appointable due to padding rules.
	 * Padding days are days before or after fully scheduled days, based on the product's
	 * padding duration setting. This prevents bookings too close to existing appointments.
	 *
	 * @since 2.0.0
	 *
	 * @param WC_Product_Appointment|int $appointable_product Product instance or ID.
	 *
	 * @return array<string, string> Padding days indexed by date in 'Y-n-j' format.
	 *                               Empty array if product is not appointable or has no padding.
	 *
	 * @throws InvalidArgumentException If product cannot be loaded or is not an appointment product.
	 *
	 * @example
	 * // Get padding days for a product
	 * $padding_days = WC_Appointments_Controller::find_padding_day_slots( $product_id );
	 * // Returns: ['2025-01-05' => '2025-01-05', '2025-01-06' => '2025-01-06']
	 */
	public static function find_padding_day_slots( $appointable_product ): array {
		if ( is_int( $appointable_product ) ) {
			$appointable_product = wc_get_product( $appointable_product );
		}
		if ( ! is_wc_appointment_product( $appointable_product ) ) {
			return [];
		}

		$scheduled = self::find_scheduled_day_slots( $appointable_product );

		return self::get_padding_day_slots_for_scheduled_days( $appointable_product, $scheduled['fully_scheduled_days'] );
	}

	/**
	 * Get padding day slots for a list of fully scheduled days.
	 *
	 * Calculates padding days (before and after) for each fully scheduled day based on
	 * the product's padding duration. Days adjacent to other scheduled days are excluded
	 * to avoid redundant padding.
	 *
	 * @since 3.3.0
	 *
	 * @param WC_Product_Appointment|int $appointable_product  Product instance or ID.
	 * @param array<string, mixed>       $fully_scheduled_days Fully scheduled days indexed by date.
	 *
	 * @return array<string, string> Padding days indexed by date in 'Y-n-j' format.
	 *                               Empty array if product is not appointable or has no padding.
	 *
	 * @throws InvalidArgumentException If product cannot be loaded or is not an appointment product.
	 *
	 * @example
	 * // Get padding for specific scheduled days
	 * $padding = WC_Appointments_Controller::get_padding_day_slots_for_scheduled_days(
	 *     $product,
	 *     ['2025-01-10' => [], '2025-01-15' => []]
	 * );
	 */
	public static function get_padding_day_slots_for_scheduled_days( $appointable_product, $fully_scheduled_days ): array {
		if ( is_int( $appointable_product ) ) {
			$appointable_product = wc_get_product( $appointable_product );
		}
		if ( ! is_wc_appointment_product( $appointable_product ) ) {
			return [];
		}

		$padding_duration = $appointable_product->get_padding_duration();
		$padding_days     = [];

		foreach ( $fully_scheduled_days as $date => $data ) {
			$next_day = strtotime( '+1 day', strtotime( $date ) );

			if ( array_key_exists( date( 'Y-n-j', $next_day ), $fully_scheduled_days ) ) {
				continue;
			}

			// x days after
			for ( $i = 1; $padding_duration + 1 > $i; $i++ ) {
				$padding_day                  = date( 'Y-n-j', strtotime( "+{$i} day", strtotime( $date ) ) );
				$padding_days[ $padding_day ] = $padding_day;
			}
		}

		#if ( $appointable_product->get_apply_adjacent_padding() ) {
		foreach ( $fully_scheduled_days as $date => $data ) {
			$previous_day = strtotime( '-1 day', strtotime( $date ) );

			if ( array_key_exists( date( 'Y-n-j', $previous_day ), $fully_scheduled_days ) ) {
				continue;
			}

			// x days before
			for ( $i = 1; $padding_duration + 1 > $i; $i++ ) {
				$padding_day                  = date( 'Y-n-j', strtotime( "-{$i} day", strtotime( $date ) ) );
				$padding_days[ $padding_day ] = $padding_day;
			}
		}
		#}
		return $padding_days;
	}

	/**
	 * Find months which are fully scheduled for an appointable product.
	 *
	 * Analyzes scheduled day slots and aggregates them by month to determine
	 * which months have no available appointment slots remaining.
	 *
	 * @since 2.0.0
	 *
	 * @param WC_Product_Appointment|int $appointable_product Product instance or ID.
	 *
	 * @return array<string, array<string>> {
	 *     Array of scheduled month data.
	 *
	 *     @type array<string> $fully_scheduled_months Months in 'Y-n' format that are fully scheduled.
	 * }
	 *
	 * @throws InvalidArgumentException If product cannot be loaded or is not an appointment product.
	 *
	 * @example
	 * // Get fully scheduled months
	 * $months = WC_Appointments_Controller::find_scheduled_month_slots( $product_id );
	 * // Returns: ['fully_scheduled_months' => ['2025-1', '2025-2']]
	 */
	public static function find_scheduled_month_slots( $appointable_product ) {
		$scheduled_day_slots = self::find_scheduled_day_slots( $appointable_product, 0, 0, 'Y-n' );

		$scheduled_month_slots = [
			'fully_scheduled_months' => $scheduled_day_slots['fully_scheduled_days'],
		];

		/**
		 * Filter the scheduled month slots calculated per project.
		 * @since 3.7.4
		 *
		 * @param array $scheduled_month_slots {
		 *  @type array $fully_scheduled_months
		 * }
		 * @param WC_Product $appointable_product
		 */
		return apply_filters( 'woocommerce_appointments_scheduled_month_slots', $scheduled_month_slots, $appointable_product );
	}

	/**
	 * Finds days which are partially scheduled & fully scheduled already.
	 *
	 * This function will get a general min/max Appointment date, which initially is [today, today + 1 year]
	 * Based on the Appointments retrieved from that date, it will shrink the range to the [Appointments_min, Appointments_max]
	 * For the newly generated range, it will determine availability of dates by calling `wc_appointments_get_time_slots` on it.
	 *
	 * Depending on the data returned from it we set:
	 * Fully scheduled days     - for those dates that there are no more slot available
	 * Partially scheduled days - for those dates that there are some slots available
	 *
	 * @param  WC_Product_Appointment|int $appointable_product Product or ID.
	 * @param  int                        $min_date Min date timestamp.
	 * @param  int                        $max_date Max date timestamp.
	 * @param  string                     $default_date_format Date format.
	 * @param  int                        $timezone_offset Timezone offset.
	 * @param  array                      $staff_ids Staff IDs.
	 *
	 * @return array( 'partially_scheduled_days', 'remaining_scheduled_days', 'fully_scheduled_days', 'unavailable_days' )
	 */
	public static function find_scheduled_day_slots_orig( $appointable_product, $min_date = 0, $max_date = 0, $default_date_format = 'Y-n-j', $timezone_offset = 0, $staff_ids = [] ) {
		$scheduled_day_slots = [
			'partially_scheduled_days' => [],
			'remaining_scheduled_days' => [],
			'fully_scheduled_days'     => [],
			'unavailable_days'         => [],
		];

		$timezone_offset *= HOUR_IN_SECONDS;

		if ( is_int( $appointable_product ) ) {
			$appointable_product = wc_get_product( $appointable_product );
		}

		if ( ! is_wc_appointment_product( $appointable_product ) ) {
			return $scheduled_day_slots;
		}

		// Get existing appointments and go through them to set partial/fully scheduled days
		$existing_appointments = WC_Appointment_Data_Store::get_all_existing_appointments( $appointable_product, $min_date, $max_date, $staff_ids );

		if ( empty( $existing_appointments ) ) {
			// For minute/hour duration, mark days with zero generated slots as fully scheduled
			// so the calendar doesn't show them as available when gaps are too small.
			if ( in_array( $appointable_product->get_duration_unit(), [ 'minute', 'hour' ], true ) ) {
				[$min_appointment_date, $max_appointment_date] = self::calculate_appointment_date_range( $appointable_product, $min_date, $max_date );
				$staff_id_for_slots = 0;
				if ( ! empty( $staff_ids ) ) {
					$staff_id_for_slots = is_array( $staff_ids ) ? (int) reset( $staff_ids ) : (int) $staff_ids;
				}

				$slots = $appointable_product->get_slots_in_range( $min_appointment_date, $max_appointment_date, [], $staff_id_for_slots, [], false, false );
				$slots_by_day = [];
				foreach ( $slots as $slot_ts ) {
					$slots_by_day[ date( $default_date_format, $slot_ts ) ] = true;
				}

				$mark_staff_ids = [];
				if ( ! empty( $staff_ids ) ) {
					$mark_staff_ids = is_array( $staff_ids ) ? array_map( 'intval', $staff_ids ) : [ (int) $staff_ids ];
				} elseif ( $appointable_product->has_staff() ) {
					$mark_staff_ids = array_map( 'intval', (array) $appointable_product->get_staff_ids() );
					$mark_staff_ids[] = 0;
				} else {
					$mark_staff_ids = [ 0 ];
				}
				$mark_staff_ids = array_values( array_unique( $mark_staff_ids ) );

				$day_cursor = strtotime( 'midnight', $min_appointment_date );
				$range_end  = strtotime( 'midnight', $max_appointment_date );
				while ( $day_cursor < $range_end ) {
					$day_key = date( $default_date_format, $day_cursor );
					if ( ! isset( $slots_by_day[ $day_key ] ) ) {
						foreach ( $mark_staff_ids as $mark_staff_id ) {
							$scheduled_day_slots['unavailable_days'][ $day_key ][ $mark_staff_id ] = 1;
						}
					}
					$day_cursor = strtotime( '+1 day', $day_cursor );
				}
			}

			return $scheduled_day_slots;
		}

		$min_appointment_date = INF;
		$max_appointment_date = -INF;
		$appointments         = [];
		$day_format           = 1 === $appointable_product->get_available_qty() ? 'unavailable_days' : 'partially_scheduled_days';

		// Find the minimum and maximum appointment dates and store the appointment data in an array for further processing.
		foreach ( $existing_appointments as $existing_appointment ) {
			#print '<pre>'; print_r( $existing_appointment->get_id() ); print '</pre>';

			// Skip if not an appointment.
			if ( ! is_a( $existing_appointment, 'WC_Appointment' ) ) {
				continue;
			}

			// Check appointment start and end times.
			$check_date    = strtotime( 'midnight', $existing_appointment->get_start() + $timezone_offset );
			$check_date_to = strtotime( 'midnight', $existing_appointment->get_end() + $timezone_offset - 1 ); #make sure midnight drops to same day

			#print '<pre>'; print_r( date( 'Y-m-d H:i', $check_date ) ); print '</pre>';
			#print '<pre>'; print_r( date( 'Y-m-d H:i', $check_date_to ) ); print '</pre>';

			// Get staff IDs. If non exist, make it zero (applies to all).
			$existing_staff_ids = $existing_appointment->get_staff_ids();
			$existing_staff_ids = is_array( $existing_staff_ids ) ? $existing_staff_ids : [ $existing_staff_ids ];
			$existing_staff_ids = [] === $existing_staff_ids ? [ 0 ] : $existing_staff_ids;

			if ( ! empty( $staff_ids ) && ! array_intersect( $existing_staff_ids, $staff_ids ) ) {
				continue;
			}

			// If it's an appointment on the same day, move it before the end of the current day
			if ( $check_date_to === $check_date ) {
				$check_date_to = strtotime( '+1 day', $check_date ) - 1;
			}

			$min_appointment_date = min( $min_appointment_date, $check_date );
			$max_appointment_date = max( $max_appointment_date, $check_date_to );

			// If the appointment duration is day, make sure we add the (duration) days to unavailable days.
			// This will mark them as white on the calendar, since they are not fully scheduled, but rather
			// unavailable. The difference is that an appointment extending to those days is allowed.
			if ( 1 < $appointable_product->get_duration() && 'day' === $appointable_product->get_duration_unit() ) {
				$check_new_date = strtotime( '-' . ( $appointable_product->get_duration() - 1 ) . ' days', $min_appointment_date );

				// Mark the days between the fake appointment and the actual appointment as unavailable.
				while ( $check_new_date < $min_appointment_date ) {
					$date_format = date( $default_date_format, $check_new_date );
					foreach ( $existing_staff_ids as $existing_staff_id ) {
						$scheduled_day_slots[ $day_format ][ $date_format ][ $existing_staff_id ] = 1;
					}
					$check_new_date = strtotime( '+1 day', $check_new_date );
				}
			}

			$appointments[] = [
				'start'          => $check_date,
				'end'            => $check_date_to,
				'staff'          => $existing_staff_ids,
				'get_staff_ids'  => $existing_appointment->get_staff_ids(),
				'get_start'      => $existing_appointment->get_start(),
				'get_end'        => $existing_appointment->get_end(),
				'get_qty'        => $existing_appointment->get_qty(),
				'get_id'         => $existing_appointment->get_id(),
				'get_product_id' => $existing_appointment->get_product_id(),
				'is_all_day'     => $existing_appointment->is_all_day(),
			];
		}

		// Make sure passed date is INT.
		// INF constant won't work from here on.
		if ( is_float( $min_appointment_date ) ) {
			$min_appointment_date = $min_date;
		}
		if ( is_float( $max_appointment_date ) ) {
			$max_appointment_date = $max_date;
		}

		$max_appointment_date = strtotime( '+1 day', $max_appointment_date );

		// phpcs:ignore PHPCompatibility.FunctionUse.ArgumentFunctionsReportCurrentValue.Changed -- Reason: value is unchanged.
		$arg_list = func_get_args();
		$source   = $arg_list[6] ?? 'user';

		// If the seventh argument is `action-scheduler-helper`, just schedule an event and return void.
		if ( 'action-scheduler-helper' === $source ) {
			$product_id = $appointable_product->get_id();

			// Create action scheduler event only if it's not already created
			// OR it's not running right now (i.e. 'cache_update_started' meta does not exist).
			$clear_cache_event = as_has_scheduled_action( 'wc-appointment-scheduled-update-availability', [ $product_id, $min_appointment_date, $max_appointment_date, $timezone_offset ] );

			// Append unique transient name in metas to support multiple event creations.
			$transient_name       = 'schedule_ts_' . md5( http_build_query( [ $product_id, 0, $min_appointment_date, $max_appointment_date, false ] ) );
			$cache_update_started = get_post_meta( $product_id, 'cache_update_started-' . $transient_name, true );

			if ( ! $clear_cache_event && ! $cache_update_started ) {

				// Schedule event.
				as_schedule_single_action( time(), 'wc-appointment-scheduled-update-availability', [ $product_id, $min_appointment_date, $max_appointment_date, $timezone_offset ] );

				// Preserve previous availability to serve to users until new one is generated.
				$available_slots = WC_Appointments_Cache::get( $transient_name );
				if ( $available_slots ) {
					WC_Appointments_Cache::set( 'prev-availability-' . $transient_name, $available_slots, 5 * MINUTE_IN_SECONDS );
				}

				// Track that the event is scheduled via a meta, in order to show users the old availability until it runs.
				update_post_meta( $product_id, 'cache_update_scheduled-' . $transient_name, true );
			}

			return;
		}

		// Call these for the whole chunk range for the appointments since they're expensive
		$slots_a           = [];
		$slots             = $appointable_product->get_slots_in_range( $min_appointment_date, $max_appointment_date );
		$available_slots   = $appointable_product->get_time_slots(
		    [
				'slots'           => $slots,
				'staff_id'        => $staff_ids,
				'from'            => $min_appointment_date,
				'to'              => $max_appointment_date,
				'appointments'    => $appointments,
				'timezone_offset' => $timezone_offset,
				'source'          => $source,
			],
		);
		$available_slots_a = [];

		// Pre-calculate values for performance
		$staff_ids_not_empty = ! empty( $staff_ids );
		$product_has_staff = $appointable_product->has_staff();

		// Available slots for the days.
		foreach ( $available_slots as $slot => $quantity ) {
			$available = $quantity['available']; #overall availability
			$slot_date_format = date( $default_date_format, $slot );
			foreach ( $quantity['staff'] as $staff_id => $availability ) {
				if ( $staff_ids_not_empty && ! in_array( $staff_id, $staff_ids ) ) {
					continue;
				}
				// Include staff in available slots if they have individual availability
				// Don't require overall 'available' to be > 0 as it might be calculated differently
				if ( 0 < $availability ) {
					$available_slots_a[ $staff_id ][] = $slot_date_format;
				}
			}
		}

		// All available slots for the days.
		foreach ( $slots as $a_slot ) {
			$slots_a[] = date( $default_date_format, $a_slot );
		}

		#print '<pre>'; print_r( $slots ); print '</pre>';
		#print '<pre>'; print_r( $slots_a ); print '</pre>';
		#print '<pre>'; print_r( $appointments ); print '</pre>';
		#print '<pre>'; print_r( $available_slots ); print '</pre>';
		#print '<pre>'; print_r( $available_slots_a ); print '</pre>';

		// Go through [start, end] of each of the appointments by chunking it in days: [start, start + 1d, start + 2d, ..., end]
		// For each of the chunk check the available slots. If there are no slots, it is fully scheduled, otherwise partially scheduled.
		foreach ( $appointments as $appointment ) {
			$check_date = $appointment['start'];

			#print '<pre>'; print_r( date( 'Y-m-d', $check_date ) ); print '</pre>';

			// Pre-calculate values for this appointment
				$slots_a_is_array = is_array( $slots_a );
				$has_staff_no_filter = $product_has_staff && ! $staff_ids;
				$available_slots_a_0_exists = isset( $available_slots_a[0] );

				while ( $check_date <= $appointment['end'] ) {
					$date_format     = date( $default_date_format, $check_date );
					$count_all_slots = $slots_a_is_array ? count( array_keys( $slots_a, $date_format ) ) : 0;

					// When no staff selected and product has staff.
					if ( $has_staff_no_filter ) {
						$appointment_type_all = $available_slots_a_0_exists && in_array( $date_format, $available_slots_a[0] ) ? 'partially_scheduled_days' : 'fully_scheduled_days';
						#print '<pre>'; print_r( $date_format ); print '</pre>';
						#print '<pre>'; print_r( $date_format ); print '</pre>';

						$scheduled_day_slots[ $appointment_type_all ][ $date_format ][0] = 1;
						// Remainging scheduled, when staff is selected.
						if ( 'partially_scheduled_days' === $appointment_type_all ) {
							$count_available_slots = count( array_keys( $available_slots_a[0], $date_format ) );

							$count_s = absint( $count_all_slots );
							$count_a = isset( $count_s ) && 0 !== $count_s ? $count_s : 1;
							$count_b = absint( $count_available_slots );
							$count_r = absint( round( ( $count_b / $count_a ) * 10 ) );
							$count_r = ( 10 === $count_r ) ? 9 : $count_r;
							$count_r = ( 0 === $count_r ) ? 1 : $count_r;

							$scheduled_day_slots['remaining_scheduled_days'][ $date_format ][0] = $count_r;
						}
					}

				// Pre-calculate values for staff loop
				$count_s = absint( $count_all_slots );
				$count_a = isset( $count_s ) && 0 !== $count_s ? $count_s : 1;

				foreach ( $appointment['staff'] as $existing_staff_id ) {
					if ( $staff_ids_not_empty && ! in_array( $existing_staff_id, $staff_ids ) ) {
						continue;
					}

					$appointment_type = ( isset( $available_slots_a[ $existing_staff_id ] ) && in_array( $date_format, $available_slots_a[ $existing_staff_id ] ) ) ? 'partially_scheduled_days' : 'fully_scheduled_days';
					#print '<pre>'; print_r( $date_format ); print '</pre>';
					#print '<pre>'; print_r( $existing_staff_id ); print '</pre>';
					#print '<pre>'; print_r( $date_format ); print '</pre>';

					$scheduled_day_slots[ $appointment_type ][ $date_format ][ $existing_staff_id ] = 1;
					// Remainging scheduled, when staff is selected.
					if ( 'partially_scheduled_days' === $appointment_type ) {
						$count_available_slots = count( array_keys( $available_slots_a[ $existing_staff_id ], $date_format ) );

						$count_b = absint( $count_available_slots );
						$count_r = absint( round( ( $count_b / $count_a ) * 10 ) );
						$count_r = ( 10 === $count_r ) ? 9 : $count_r;
						$count_r = ( 0 === $count_r ) ? 1 : $count_r;

						$scheduled_day_slots['remaining_scheduled_days'][ $date_format ][ $existing_staff_id ] = $count_r;
					}
				}

				$check_date = strtotime( '+1 day', $check_date );
			}
		}

		#print '<pre>'; print_r( $scheduled_day_slots ); print '</pre>';
		#error_log( var_export( $scheduled_day_slots, true ) );

		/**
		 * Filter the scheduled day slots calculated per project.
		 * @since 3.3.0
		 *
		 * @param array $scheduled_day_slots {staff
		 *  @type array $partially_scheduled_days
		 *  @type array $fully_scheduled_days
		 * }
		 * @param WC_Product $appointable_product
		 */
		return apply_filters( 'woocommerce_appointments_scheduled_day_slots', $scheduled_day_slots, $appointable_product );
	}

	/**
	 * Return cache horizon timestamp based on configurable admin setting.
	 * Falls back to 3 months if the helper function is not available.
	 */
	private static function cache_horizon_ts(): int {
		$__t0 = microtime( true );
		if ( function_exists( 'wc_appointments_get_cache_horizon_months' ) ) {
			$months = wc_appointments_get_cache_horizon_months();
			$val = (int) strtotime( '+' . $months . ' months UTC' );
			self::dbg_log( __FUNCTION__ . ' horizon=' . $months . 'm took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );
			return $val;
		}
		// Fallback to 3 months if helper function is not available.
		$val = strtotime( '+3 months UTC' );
		self::dbg_log( __FUNCTION__ . ' horizon=3m took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );
		return $val;
	}

	/**
	 * Find days which are partially scheduled and fully scheduled using cached availabilities.
	 *
	 * Retrieves cached availability data (appointments and rules) from the cache table,
	 * then determines slot availability by processing cached rules and blocked appointments.
	 * Uses indexed availability when enabled and within cache horizon, otherwise falls back
	 * to the original non-cached method.
	 *
	 * @since 4.0.0
	 *
	 * @param WC_Product_Appointment|int $appointable_product  Product instance or ID.
	 * @param int                        $min_date             Optional. Start timestamp. Default 0 (uses product min_date).
	 * @param int                        $max_date             Optional. End timestamp. Default 0 (uses product max_date).
	 * @param string                     $default_date_format  Optional. Date format for keys. Default 'Y-n-j'.
	 * @param int                        $timezone_offset      Optional. Timezone offset in seconds. Default 0.
	 * @param int[]|int                  $staff_ids            Optional. Staff IDs to filter by. Default empty array.
	 *
	 * @return array<string, array<string, mixed>> {
	 *     Scheduled day slots data.
	 *
	 *     @type array<string, mixed> $partially_scheduled_days Days with some slots available.
	 *     @type array<string, mixed> $remaining_scheduled_days Days with remaining capacity.
	 *     @type array<string, mixed> $fully_scheduled_days     Days with no slots available.
	 *     @type array<string, mixed> $unavailable_days         Days marked as unavailable.
	 * }
	 *
	 * @throws InvalidArgumentException If product cannot be loaded or is not an appointment product.
	 *
	 * @example
	 * // Get scheduled days for next 30 days
	 * $slots = WC_Appointments_Controller::find_scheduled_day_slots(
	 *     $product_id,
	 *     strtotime( 'today' ),
	 *     strtotime( '+30 days' )
	 * );
	 */
	/**
	 * Check if indexed availability should be used based on horizon.
	 *
	 * @since 4.0.0
	 *
	 * @param int $max_date Maximum date timestamp.
	 * @return bool Whether to use indexed availability.
	 */
	private static function should_use_indexed_availability( int $max_date ): bool {
		if ( ! class_exists( 'WC_Appointments_Cache_Availability' ) || ! method_exists( 'WC_Appointments_Cache_Availability', 'is_index_enabled' ) ) {
			return false;
		}
		$index_toggle   = WC_Appointments_Cache_Availability::is_index_enabled();
		$horizon_months = function_exists( 'wc_appointments_get_cache_horizon_months' ) ? wc_appointments_get_cache_horizon_months() : 3;
		$horizon_ts     = strtotime( '+' . $horizon_months . ' months UTC' );
		return $index_toggle && ( $max_date <= $horizon_ts );
	}

	/**
	 * Calculate appointment date range from product settings or provided dates.
	 *
	 * @since 4.0.0
	 *
	 * @param WC_Product_Appointment $appointable_product Product.
	 * @param int $min_date Minimum date timestamp (0 to use product default).
	 * @param int $max_date Maximum date timestamp (0 to use product default).
	 * @return array [min_appointment_date, max_appointment_date]
	 */
	private static function calculate_appointment_date_range( $appointable_product, int $min_date, int $max_date ): array {
		if ( empty( $min_date ) ) {
			$min_a = $appointable_product->get_min_date_a();
			$min_a = empty( $min_a ) ? [ 'unit' => 'minute', 'value' => 1 ] : $min_a;
			$min_appointment_date = strtotime( "midnight +{$min_a['value']} {$min_a['unit']}", current_time( 'timestamp' ) );
		} else {
			$min_appointment_date = (int) $min_date;
		}

		if ( empty( $max_date ) ) {
			$max_a = $appointable_product->get_max_date_a();
			$max_a = empty( $max_a ) ? [ 'unit' => 'month', 'value' => 12 ] : $max_a;
			$max_appointment_date = strtotime( "+{$max_a['value']} {$max_a['unit']}", current_time( 'timestamp' ) );
		} else {
			$max_appointment_date = (int) $max_date;
		}

		// Include the last day fully.
		$max_appointment_date = strtotime( '+1 day', $max_appointment_date );

		return [ $min_appointment_date, $max_appointment_date ];
	}

	public static function find_scheduled_day_slots( $appointable_product, $min_date = 0, $max_date = 0, $default_date_format = 'Y-n-j', $timezone_offset = 0, $staff_ids = [] ) {
		$__t0 = microtime( true );
		// Check if we can use indexed availability within horizon.
		$use_indexed = self::should_use_indexed_availability( $max_date );

		// Use non-indexed availability if disabled and outside horizon.
		if ( ! $use_indexed && class_exists( 'WC_Appointments_Controller' ) && method_exists( 'WC_Appointments_Controller', 'find_scheduled_day_slots_orig' ) ) {
			return self::find_scheduled_day_slots_orig( $appointable_product, $min_date, $max_date, $default_date_format, $timezone_offset, $staff_ids );
		}

		$scheduled_day_slots = [
			'partially_scheduled_days' => [],
			'remaining_scheduled_days' => [],
			'fully_scheduled_days'     => [],
			'unavailable_days'         => [],
		];

		if ( is_int( $appointable_product ) ) {
			$appointable_product = wc_get_product( $appointable_product );
		}

		if ( ! is_wc_appointment_product( $appointable_product ) ) {
			return $scheduled_day_slots;
		}

		// Simplified staff ID normalization - consistent with get_cached_slots_in_range_for_day.
		$staff_ids_for_query = [];
		if ( $staff_ids && is_int( $staff_ids ) && 0 !== $staff_ids ) {
			$staff_ids_for_query[] = (int) $staff_ids;
		} elseif ( $staff_ids && is_array( $staff_ids ) && 0 !== $staff_ids ) {
			$staff_ids_for_query = array_map( 'intval', $staff_ids );
		} elseif ( $appointable_product->has_staff() ) {
			$staff_ids_for_query = wc_appointments_normalize_staff_ids( $appointable_product->get_staff_ids() );
		}
		$staff_ids_for_query[] = 0;

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

		// Filter out zero values to only include valid staff IDs.
		$valid_staff_ids = array_filter( $staff_ids, fn($id): bool => 0 < $id );

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

		// Define min/max timestamps for slot generation.
		[$min_appointment_date, $max_appointment_date] = self::calculate_appointment_date_range( $appointable_product, $min_date, $max_date );

		// Use get_cached_slots_in_range to create all time slots in range from availability rules
		$__tA = microtime( true );
		$slots = self::get_cached_slots_in_range(
		    $appointable_product,
		    $min_appointment_date,
		    $max_appointment_date,
		    [],
		    $staff_ids,
		    [],
		    false,
		    false,
		);
		self::dbg_log( __FUNCTION__ . ' slots_range count=' . count( (array) $slots ) . ' took=' . number_format( ( microtime( true ) - $__tA ) * 1000, 2 ) . 'ms' );

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

		// Note: get_cached_time_slots will query appointments internally, but we also need them
		// for day classification of days without slots. To avoid duplicate queries, we fetch once
		// and could pass to get_cached_time_slots, but that requires refactoring that function.
		// For now, we accept the duplicate query as a trade-off for correctness.
		// TODO: Refactor get_cached_time_slots to accept pre-fetched appointments parameter.

		// Use get_cached_time_slots to check which time slots are available against appointments
		$__tB = microtime( true );
		$available_slots = self::get_cached_time_slots(
		    $appointable_product,
		    [
				'slots'           => $slots,
				'intervals'       => [],
				'staff_id'        => $staff_ids,
				'from'            => $min_appointment_date,
				'to'              => $max_appointment_date,
				'timezone_offset' => $timezone_offset,
				// Include sold-out slots so day-level booking detection works even
				// when all capacity is consumed at certain times on a day.
				'include_sold_out' => true,
			],
		);
		self::dbg_log( __FUNCTION__ . ' time_slots count=' . count( (array) $available_slots ) . ' took=' . number_format( ( microtime( true ) - $__tB ) * 1000, 2 ) . 'ms' );

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

		// Build helper maps from slots.
		$available_slots_a = [];
		$slots_a = [];
		$total_slots_by_day = [];
		$available_slots_by_day_and_staff = [];

		// Map: all slots per day (total possible appointable time slots).
		$__tC = microtime( true );
		foreach ( $slots as $a_slot ) {
			$day_key = date( $default_date_format, $a_slot );
			$slots_a[] = $day_key;
			$total_slots_by_day[ $day_key ] = ( $total_slots_by_day[ $day_key ] ?? 0 ) + 1;
		}
		if ( in_array( $appointable_product->get_duration_unit(), [ 'minute', 'hour' ], true ) ) {
			$day_cursor = strtotime( 'midnight', $min_appointment_date );
			$range_end  = strtotime( 'midnight', $max_appointment_date );
			while ( $day_cursor < $range_end ) {
				$day_key = date( $default_date_format, $day_cursor );
				if ( ! isset( $total_slots_by_day[ $day_key ] ) ) {
					$total_slots_by_day[ $day_key ] = 0;
				}
				$day_cursor = strtotime( '+1 day', $day_cursor );
			}
		}
		self::dbg_log( __FUNCTION__ . ' build_slots_by_day days=' . count( $total_slots_by_day ) . ' took=' . number_format( ( microtime( true ) - $__tC ) * 1000, 2 ) . 'ms' );

		// Map: available slots per day per staff.
		$__tD = microtime( true );
		foreach ( $available_slots as $slot => $quantity ) {
			$available = (int) ( $quantity['available'] ?? 0 );
			if ( 0 >= $available ) {
				continue;
			}
			$day_key = date( $default_date_format, $slot );
			foreach ( $quantity['staff'] as $sid => $availability ) {
				if ( [] !== $valid_staff_ids && ! in_array( $sid, $staff_ids, true ) ) {
					continue;
				}
				if ( (int) $availability > 0 ) {
					$available_slots_a[ $sid ][] = $day_key;
					if ( ! isset( $available_slots_by_day_and_staff[ $sid ] ) ) {
						$available_slots_by_day_and_staff[ $sid ] = [];
					}
					$available_slots_by_day_and_staff[ $sid ][ $day_key ] = ( $available_slots_by_day_and_staff[ $sid ][ $day_key ] ?? 0 ) + 1;
				}
			}
		}
		self::dbg_log( __FUNCTION__ . ' map_available_by_day took=' . number_format( ( microtime( true ) - $__tD ) * 1000, 2 ) . 'ms' );

		// Determine staff IDs to classify.
		$all_staff_ids = [];
		if ( $appointable_product->has_staff() ) {
			$product_staff = array_map( 'intval', (array) $appointable_product->get_staff_ids() );
			$all_staff_ids = [] === $valid_staff_ids ? $product_staff : array_values( array_intersect( $staff_ids, $product_staff ) );
			// When no specific staff is selected, also include aggregated any-staff bucket 0.
			if ( [] === $valid_staff_ids ) {
				$all_staff_ids[] = 0;
			}
		} else {
			$all_staff_ids = [ 0 ];
		}

		// Build day -> slots map for booking detection.
		$slots_by_day = [];
		foreach ( $slots as $slot_ts ) {
			$day_key = date( $default_date_format, $slot_ts );
			if ( ! isset( $slots_by_day[ $day_key ] ) ) {
				$slots_by_day[ $day_key ] = [];
			}
			$slots_by_day[ $day_key ][] = $slot_ts;
		}

		// Detect bookings per day and per staff using slot metadata.
		$any_booking_by_day = [];
		$booked_by_day_staff = [];
		$__tE = microtime( true );
		foreach ( $slots_by_day as $day_key => $slot_list ) {
			$any_booking = false;
			$booked_by_day_staff[ $day_key ] = [];
			foreach ( $slot_list as $slot_ts ) {
				if ( ! isset( $available_slots[ $slot_ts ] ) ) {
					continue; // Slot not appointable or filtered out
				}
				$meta = $available_slots[ $slot_ts ]['meta'] ?? [];
				if ( isset( $meta['had_booking'] ) && $meta['had_booking'] ) {
					$any_booking = true;
				}
				if ( isset( $meta['booked_by_staff'] ) && is_array( $meta['booked_by_staff'] ) ) {
					foreach ( $meta['booked_by_staff'] as $sid => $qty ) {
						if ( (int) $qty > 0 ) {
							$booked_by_day_staff[ $day_key ][ (int) $sid ] = true;
						}
					}
				}
			}
			$any_booking_by_day[ $day_key ] = $any_booking;
		}
		self::dbg_log( __FUNCTION__ . ' scan_day_meta days=' . count( $slots_by_day ) . ' took=' . number_format( ( microtime( true ) - $__tE ) * 1000, 2 ) . 'ms' );

		// Note: With Option 2 fix, slots are now generated for all days in range (even if rules
		// make them unavailable), so this workaround is no longer needed. Day classification will
		// process all days through the normal slot-based flow.

		// Classify each day for each staff using availability counts and booking detection.
		$__tF = microtime( true );
		foreach ( $total_slots_by_day as $day_key => $total_count ) {
			$day_total = absint( $total_count );
			$day_total = 0 < $day_total ? $day_total : 1; // avoid divide-by-zero

			foreach ( $all_staff_ids as $sid ) {
				$avail_count = absint( $available_slots_by_day_and_staff[ $sid ][ $day_key ] ?? 0 );
				$has_booking_for_staff = isset( $booked_by_day_staff[ $day_key ][ $sid ] ) && $booked_by_day_staff[ $day_key ][ $sid ];

				// Fully scheduled when no remaining availability for this staff on this day.
				if ( 0 === $avail_count ) {
					$scheduled_day_slots['fully_scheduled_days'][ $day_key ][ $sid ] = 1;
					// Approximate staff-unavailable semantics: if other staff have availability while this staff has none.
					if ( 0 < $sid && isset( $available_slots_by_day_and_staff[0][ $day_key ] ) && 0 < $available_slots_by_day_and_staff[0][ $day_key ] ) {
						$scheduled_day_slots['unavailable_days'][ $day_key ][ $sid ] = 1;
					}
					continue;
				}

				// Partially scheduled only when capacity is reduced by bookings.
				// That is: there exists at least one booking for this staff on this day.
				if (0 < $sid) {
                    if ( $has_booking_for_staff ) {
						$scheduled_day_slots['partially_scheduled_days'][ $day_key ][ $sid ] = 1;
						$count_r = absint( round( ( $avail_count / $day_total ) * 10 ) );
						$count_r = ( 10 === $count_r ) ? 9 : $count_r;
						$count_r = ( 0 === $count_r ) ? 1 : $count_r;
						$scheduled_day_slots['remaining_scheduled_days'][ $day_key ][ $sid ] = $count_r;
					}
                } elseif (isset( $any_booking_by_day[ $day_key ] ) && $any_booking_by_day[ $day_key ]) {
                    // sid = 0 (any staff bucket) – mark partial only if any booking exists on the day.
                    $scheduled_day_slots['partially_scheduled_days'][ $day_key ][0] = 1;
                    $count_r = absint( round( ( $avail_count / $day_total ) * 10 ) );
                    $count_r = ( 10 === $count_r ) ? 9 : $count_r;
                    $count_r = ( 0 === $count_r ) ? 1 : $count_r;
                    $scheduled_day_slots['remaining_scheduled_days'][ $day_key ][0] = $count_r;
                }
			}
		}
		self::dbg_log( __FUNCTION__ . ' classify days=' . count( $total_slots_by_day ) . ' took=' . number_format( ( microtime( true ) - $__tF ) * 1000, 2 ) . 'ms total=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );

		// Note: we intentionally avoid scanning raw appointments here.
		// This derives day-level status purely from $available_slots and $slots, as requested.
		#error_log( var_export( $scheduled_day_slots, true ) );

		/**
		 * Filter the scheduled day slots calculated per project.
		 * @since 3.3.0
		 *
		 * @param array $scheduled_day_slots
		 * @param WC_Product $appointable_product
		 */
		return apply_filters( 'woocommerce_appointments_scheduled_day_slots', $scheduled_day_slots, $appointable_product );
	}

	/**
	 * Optimized version of get_slots_in_range using cached availabilities
	 *
	 * @since 4.0.0
	 *
	 * @param WC_Product_Appointment $product
	 * @param int $start_date
	 * @param int $end_date
	 * @param array $intervals
	 * @param int $staff_id
	 * @param array $scheduled
	 * @param bool $get_past_times
	 * @param bool $timezone_span
	 * @return array
	 */
	public static function get_cached_slots_in_range( $product, $start_date, $end_date, $intervals = [], $staff_id = 0, $scheduled = [], $get_past_times = false, $timezone_span = true ) {
		$__t0 = microtime( true );
		// Fallback to non-cached when indexing is not enabled
		if ( ! class_exists( 'WC_Appointments_Cache_Availability' ) || ! WC_Appointments_Cache_Availability::is_index_enabled() ) {
			return $product->get_slots_in_range( $start_date, $end_date, $intervals, $staff_id, $scheduled, $get_past_times, $timezone_span );
		}

		$intervals = empty( $intervals ) ? $product->get_intervals() : $intervals;

		// Span 1 day before and after to account for all timezones
		if ( $timezone_span ) {
			$start_date = strtotime( '-1 day', $start_date );
			$end_date   = strtotime( '+1 day', $end_date );
		}

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

		// Fallback to non-cached when exceeding cache horizon.
		if ( self::cache_horizon_ts() < $end_date ) {
			// Use original product generator to avoid cache lookups.
			return $product->get_slots_in_range( $start_date, $end_date, $intervals, $staff_id, $scheduled, $get_past_times, false );
		}

		$__path = '';
		if ( WC_Appointments_Constants::DURATION_DAY === $product->get_duration_unit() ) {
			$slots_in_range = self::get_cached_slots_in_range_for_day( $product, $start_date, $end_date, $staff_id );
			$__path = 'day';
		} elseif ( WC_Appointments_Constants::DURATION_MONTH === $product->get_duration_unit() ) {
			$slots_in_range = self::get_cached_slots_in_range_for_month( $product, $start_date, $end_date, $staff_id );
			$__path = 'month';
		} else {
			$slots_in_range = self::get_cached_slots_in_range_for_hour_or_minutes( $product, $start_date, $end_date, $intervals, $staff_id, $get_past_times );
			$__path = 'hour_minute';
		}

		asort( $slots_in_range );
		self::dbg_log( __FUNCTION__ . ' product=' . ( is_object( $product ) && method_exists( $product, 'get_id' ) ? $product->get_id() : 0 ) . ' path=' . $__path . ' count=' . count( $slots_in_range ) . ' took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );

		return array_unique( $slots_in_range );
	}

	/**
	 * Get slots in range for month duration using cached availability
	 *
	 * @since 4.0.0
	 *
	 * @return int[]|false[]
	 */
	private static function get_cached_slots_in_range_for_month( $product, $start_date, $end_date, $staff_id ): array {
		$__t0 = microtime( true );
		$slots = [];

		// Preload cached rules for the whole period, for general (0) and selected staff.
		if ( ! class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			return $slots;
		}

		$data_store = new WC_Appointments_Availability_Cache_Data_Store();

		// Build staff list for cache query: specific staff (if set) + general (0)
		$staff_ids_for_query = [];
		if ( $staff_id && is_int( $staff_id ) && 0 !== $staff_id ) {
			$staff_ids_for_query[] = (int) $staff_id;
		} elseif ( $staff_id && is_array( $staff_id ) && 0 !== $staff_id ) {
			$staff_ids_for_query = array_map( 'intval', $staff_id );
		} elseif ( $product->has_staff() ) {
			$staff_ids_for_query = array_map( 'intval', (array) $product->get_staff_ids() );
		}
		$staff_ids_for_query[] = 0;
		$staff_ids_for_query = array_unique( $staff_ids_for_query );

		// Simplified staff ID normalization - used outside query.
		$candidate_staff_ids = [];
		if ( $staff_id && is_int( $staff_id ) && 0 !== $staff_id ) {
			$candidate_staff_ids[] = (int) $staff_id;
		} elseif ( $staff_id && is_array( $staff_id ) && 0 !== $staff_id ) {
			$candidate_staff_ids = array_map( 'intval', $staff_id );
		} elseif ( $product->has_staff() ) {
			$candidate_staff_ids = array_map( 'intval', (array) $product->get_staff_ids() );
		} else {
			$candidate_staff_ids = [ 0 ];
		}
		$candidate_staff_ids = array_unique( $candidate_staff_ids );

		$__tQ = microtime( true );
		$rows = $data_store->get_availability_rules_for_slots(
		    (int) $product->get_id(),
		    (int) $start_date,
		    (int) $end_date,
		    $staff_ids_for_query,
		);
		self::dbg_log( __FUNCTION__ . ' rules rows=' . count( (array) $rows ) . ' took=' . number_format( ( microtime( true ) - $__tQ ) * 1000, 2 ) . 'ms' );

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

		$rules_by_staff = [];
		foreach ( $rows as $row ) {
			$rules_by_staff[ (int) $row->staff_id ][] = $row;
		}

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

		$default_appointable = (bool) $product->get_default_availability();
		$base_product_capacity = (int) $product->get_qty(); // Use base product qty, not get_available_qty()
		$default_capacity = $base_product_capacity;

		$__slotCandidates = 0;
		$__slotPushed = 0;

		$current_date = $start_date;
		while ( $current_date <= $end_date ) {
			$__slotCandidates++;
			$month_start = strtotime( date( 'Y-m-01', $current_date ) );
			$month_end   = strtotime( '+1 month', $month_start );

			#error_log( var_export( $month_start . '-' . $month_end, true ) );

			// Determine appointable and capacity using cached rules with override.
			$month_appointable_any = false;
			$month_capacity_best   = 0;

			foreach ( $candidate_staff_ids as $sid_candidate ) {
				$applicable_rule_sets = [];
				if ( isset( $rules_by_staff[0] ) ) {
					$applicable_rule_sets[] = $rules_by_staff[0];
				}
				if ( $sid_candidate && isset( $rules_by_staff[ $sid_candidate ] ) ) {
					$applicable_rule_sets[] = $rules_by_staff[ $sid_candidate ];
				}

				$effective_appointable = $default_appointable;
				$effective_capacity    = $default_capacity;

				if ( [] !== $applicable_rule_sets ) {
					$combined = [];
					foreach ( $applicable_rule_sets as $set ) {
						$combined = array_merge( $combined, $set );
					}

					foreach ( $combined as $rule ) {
						// Month is appointable if any instant within the month is covered by a rule.
						$overlap = ( $rule->start_ts <= $month_end ) && ( $rule->end_ts >= $month_start );
						if ( ! $overlap ) {
							continue;
						}
						$effective_appointable = ( 'yes' === $rule->appointable );
						// If a rule defines a positive qty, prefer it as capacity.
						if ( isset( $rule->qty ) && (int) $rule->qty > 0 ) {
							$effective_capacity = (int) $rule->qty;
						}
						#error_log( var_export( $effective_appointable, true ) );
						#error_log( var_export( $rule, true ) );
						// No break: last rule by priority ASC should override earlier rules.
					}

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

					if ( $effective_appointable ) {
						$month_appointable_any = true;
						$month_capacity_best   = max( $month_capacity_best, $effective_capacity );
					}
				}
			}

			if ( $month_appointable_any ) {
				$slots[] = $month_start;
				$__slotPushed++;
			}

			$current_date = strtotime( '+1 month', $current_date );
		}

		self::dbg_log( __FUNCTION__ . ' product=' . (int) $product->get_id() . ' months=' . count( $slots ) . ' took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );
		self::dbg_log( __FUNCTION__ . ' product=' . (int) $product->get_id() . ' candidates=' . $__slotCandidates . ' generated=' . $__slotPushed . ' took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );
		return $slots;
}

	/**
	 * Get slots in range for day duration using cached availability
	 *
	 * @since 4.0.0
	 *
	 * @return mixed[]
	 */
	private static function get_cached_slots_in_range_for_day( $product, $start_date, $end_date, $staff_id ): array {
		$__t0 = microtime( true );
		$slots = [];

		// Preload cached rules for the whole period, for general (0) and selected staff.
		if ( ! class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			return $slots;
		}

		$data_store = new WC_Appointments_Availability_Cache_Data_Store();

		// Build staff list for cache query: specific staff (if set) + general (0)
		$staff_ids_for_query = [];
		if ( $staff_id && is_int( $staff_id ) && 0 !== $staff_id ) {
			$staff_ids_for_query[] = (int) $staff_id;
		} elseif ( $staff_id && is_array( $staff_id ) && 0 !== $staff_id ) {
			$staff_ids_for_query = array_map( 'intval', $staff_id );
		} elseif ( $product->has_staff() ) {
			$staff_ids_for_query = array_map( 'intval', (array) $product->get_staff_ids() );
		}
		$staff_ids_for_query[] = 0;
		$staff_ids_for_query = array_unique( $staff_ids_for_query );

		// Simplified staff ID normalization - used outside query.
		$candidate_staff_ids = [];
		if ( $staff_id && is_int( $staff_id ) && 0 !== $staff_id ) {
			$candidate_staff_ids[] = (int) $staff_id;
		} elseif ( $staff_id && is_array( $staff_id ) && 0 !== $staff_id ) {
			$candidate_staff_ids = array_map( 'intval', $staff_id );
		} elseif ( $product->has_staff() ) {
			$candidate_staff_ids = array_map( 'intval', (array) $product->get_staff_ids() );
		} else {
			$candidate_staff_ids = [ 0 ];
		}
		$candidate_staff_ids = array_unique( $candidate_staff_ids );

		$__tQ = microtime( true );
		$rows = $data_store->get_availability_rules_for_slots(
		    (int) $product->get_id(),
		    (int) $start_date,
		    (int) $end_date,
		    $staff_ids_for_query,
		);
		self::dbg_log( __FUNCTION__ . ' rules rows=' . count( (array) $rows ) . ' took=' . number_format( ( microtime( true ) - $__tQ ) * 1000, 2 ) . 'ms' );

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

		$rules_by_staff = [];
		foreach ( $rows as $row ) {
			$rules_by_staff[ (int) $row->staff_id ][] = $row;
		}

		$default_appointable = (bool) $product->get_default_availability();
		$product->get_available_qty();
		$base_product_capacity = (int) $product->get_qty();

		// Pre-calculate values outside the loop for performance
		$product_has_staff = $product->has_staff();
		$staff_qtys = $product_has_staff ? $product->get_staff_qtys() : [];
		$general_rules = $rules_by_staff[0] ?? [];
        $product->get_id();
        if ($product_has_staff) {
            array_map( 'intval', (array) $product->get_staff_ids() );
        }

		$current_date = $start_date;
		while ( $current_date < $end_date ) {
			$day_start = strtotime( 'midnight', $current_date );
			$day_end   = strtotime( '+1 day', $day_start );

			// Padding for the product in seconds; used to expand appointment overlap window.
			// This mirrors the cached hourly/minute path where combined padding is considered.
			$padding_duration_seconds = max( 0, (int) $product->get_padding_duration_in_minutes() ) * 60;

			// Calculate effective capacity following priority: staff qty (if set) > availability rules qty > product qty
			$effective_appointable = $default_appointable;
			$effective_capacity = $base_product_capacity;

			// Apply general availability rules first (staff_id = 0)
			foreach ( $general_rules as $rule ) {
				$overlap = ( $rule->start_ts < $day_end ) && ( $rule->end_ts > $day_start );

				if ( ! $overlap ) {
					continue;
				}
				$effective_appointable = ( 'yes' === $rule->appointable );
				// Availability rules can override product qty
				if ( isset( $rule->qty ) && (int) $rule->qty > 0 ) {
					$effective_capacity = (int) $rule->qty;
				}
			}

			// Check if day is appointable and get final capacity
			$day_appointable = $effective_appointable;
			$day_capacity = $effective_capacity;
			$staff_capacities = [];

			// For products with staff, check individual staff availability and calculate capacities
			if ( $product_has_staff ) {
				$staff_available = false;
				foreach ( $candidate_staff_ids as $sid_candidate ) {
					if ( 0 === $sid_candidate ) {
						continue; // Skip general rules, already processed
					}

					$staff_appointable = $effective_appointable;

					// Check staff quantity first (highest priority - overrides everything)
					if ( isset( $staff_qtys[ $sid_candidate ] ) && '' !== $staff_qtys[ $sid_candidate ] && (int) $staff_qtys[ $sid_candidate ] > 0 ) {
						$staff_capacity = (int) $staff_qtys[ $sid_candidate ];
					} else {
						$staff_capacity = $effective_capacity;

						// Apply specific staff rules only if no staff quantity is set
						if ( isset( $rules_by_staff[ $sid_candidate ] ) ) {
							foreach ( $rules_by_staff[ $sid_candidate ] as $rule ) {
								$overlap = ( $rule->start_ts < $day_end ) && ( $rule->end_ts > $day_start );
								if ( ! $overlap ) {
									continue;
								}
								$staff_appointable = ( 'yes' === $rule->appointable );
								// Staff rules can only reduce capacity, not increase beyond product/availability rules
								if ( isset( $rule->qty ) && (int) $rule->qty > 0 ) {
									$staff_capacity = min( $staff_capacity, (int) $rule->qty );
								}
							}
						}
					}

					if ( $staff_appointable && 0 < $staff_capacity ) {
						$staff_available = true;
						$staff_capacities[ $sid_candidate ] = $staff_capacity;
					}
				}

				$day_appointable = $day_appointable || $staff_available;

				// When no staff selected, use sum of all staff capacities
				if ( ! $staff_id && [] !== $staff_capacities ) {
					$day_capacity = array_sum( $staff_capacities );
				}
			}

			// Generate slots for ALL days in the range, even if rules make them unavailable.
			// This ensures day classification can process all days, including those with appointments
			// but marked unavailable by rules. The rules will still mark them as unavailable in
			// get_cached_time_slots, but at least the slots exist for classification.
			// Only skip if we're checking capacity and there's none available.
			$should_generate_slot = true;

			if ( $day_appointable ) {
				// Build base capacities per staff for per-staff deduction when product has staff.
				$base_capacities = [];
				if ( $product_has_staff && [] !== $staff_capacities ) {
					foreach ( $candidate_staff_ids as $sid_candidate ) {
						if ( 0 === $sid_candidate ) { continue; }
						$base_capacities[ $sid_candidate ] = (int) ( $staff_capacities[ $sid_candidate ] ?? 0 );
					}
				}

				// Track total and per-staff booked quantities.
				$total_booked_qty  = 0;
				$staff_booked_qty  = [];
				foreach ( $candidate_staff_ids as $sid_candidate ) {
					$staff_booked_qty[ $sid_candidate ] = 0;
				}

				// Cache padding per appointment product for cross-product overlap expansion.
				$appointment_padding_cache = [];

				// Compute remaining capacity.
				if ( ! $staff_id && $product_has_staff && [] !== $base_capacities ) {
					$remaining_total_capacity = 0;
					foreach ( $base_capacities as $sid => $avail ) {
						$booked = $staff_booked_qty[ $sid ] ?? 0;
						$remaining_total_capacity += max( 0, $avail );
					}
				} else {
					$remaining_total_capacity = max( 0, $day_capacity - $total_booked_qty );
				}

				// For appointable days, only generate if capacity remains.
				// For non-appointable days, always generate so day classification can process them.
				if ( 0 >= $remaining_total_capacity ) {
					$should_generate_slot = false;
				}
			}

			// Generate slot for this day (either appointable with capacity, or non-appointable for classification)
			if ( $should_generate_slot ) {
				$slots[] = $current_date;
			}

			$current_date = strtotime( '+1 day', $current_date );
		}

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

		self::dbg_log( __FUNCTION__ . ' product=' . (int) $product->get_id() . ' days=' . count( $slots ) . ' took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );
		return $slots;
}

	/**
     * Get slots in range for hour/minute duration using cached availability
     *
     * @since 4.0.0
     *
     * @return mixed[]
     */
    private static function get_cached_slots_in_range_for_hour_or_minutes( $product, $start_date, $end_date, $intervals, $staff_id, $get_past_times ): array {
		$__t0 = microtime( true );
		$slots = [];
		[$interval, $base_interval] = $intervals;

		// Use CRUD data store instead of direct database queries
		if ( ! class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			return $slots;
		}

		$data_store = new WC_Appointments_Availability_Cache_Data_Store();

		// Normalize range boundaries to midnight and include partial days
		$range_start = strtotime( 'midnight', $start_date );
		$range_end   = (int) $end_date - 1; #remove 1 second for first slot correction.

		// Get base product capacity (priority: staff qty (if set) > availability rules qty > product qty)
		$product->get_qty();

		// Build staff list for cache query: specific staff (if set) + general (0)
		$staff_ids_for_query = [];
		if ( $staff_id && is_int( $staff_id ) && 0 !== $staff_id ) {
			$staff_ids_for_query[] = (int) $staff_id;
		} elseif ( $staff_id && is_array( $staff_id ) && 0 !== $staff_id ) {
			$staff_ids_for_query = array_map( 'intval', $staff_id );
		} elseif ( $product->has_staff() ) {
			$staff_ids_for_query = array_map( 'intval', (array) $product->get_staff_ids() );
		}
		$staff_ids_for_query[] = 0;
		$staff_ids_for_query = array_unique( $staff_ids_for_query );

		// Get cached availability rules using CRUD method
		$rows = $data_store->get_availability_rules_for_slots(
		    (int) $product->get_id(),
		    (int) $range_start,
		    $range_end,
		    $staff_ids_for_query,
		);
		self::dbg_log( __FUNCTION__ . ' rules rows=' . count( (array) $rows ) . ' took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms@rules' );

		$default_appointable = (bool) $product->get_default_availability();
		$product_has_staff   = $product->has_staff();
		$availability_span   = method_exists( $product, 'get_availability_span' ) ? $product->get_availability_span() : 'all';

		$staff_ids_to_union = [];
		if ( $product_has_staff ) {
			if ( is_array( $staff_id ) && [] !== $staff_id ) {
				$staff_ids_to_union = array_values(
					array_filter(
						array_map( 'intval', $staff_id ),
						static fn( $id ): bool => 0 < (int) $id,
					),
				);
			} elseif ( $staff_id && is_int( $staff_id ) && 0 !== $staff_id ) {
				$staff_ids_to_union = [ (int) $staff_id ];
			} elseif ( ! $staff_id && ! $default_appointable ) {
				$staff_ids_to_union = array_map( 'intval', (array) $product->get_staff_ids() );
			}
		}
		if ( [] === $staff_ids_to_union ) {
			$staff_ids_to_union = [ 0 ];
		}
		$staff_ids_to_union = array_values( array_unique( $staff_ids_to_union ) );

		// Iterate day-by-day and generate slots within availability ranges.
		$current_day = $range_start;
		while ( $current_day <= $range_end ) {
			$day_start     = max( $current_day, $start_date );
			$day_boundary  = min( strtotime( '+1 day', $current_day ), $range_end + 1 );
			$slots_for_day = [];

			foreach ( $staff_ids_to_union as $sid ) {
				$rules_for_staff = self::merge_rules_for_staff( $rows, (int) $sid );
				$availability_ranges = self::derive_availability_ranges_from_rules(
					$rules_for_staff,
					$current_day,
					$default_appointable,
					(int) $interval,
				);

				foreach ( $availability_ranges as $availability_range ) {
					$range_start_ts = max( (int) $availability_range[0], $day_start );
					$range_end_ts   = min( (int) $availability_range[1], $day_boundary );

					if ( $range_end_ts <= $range_start_ts ) {
						continue;
					}

					$slot_time = $range_start_ts;
					while ( $slot_time < $range_end_ts ) {
						if ( ! $get_past_times && current_time( 'timestamp' ) > $slot_time ) {
							$slot_time += $base_interval * 60;
							continue;
						}

						if ( 'start' !== $availability_span && ( $slot_time + ( $interval * 60 ) ) > $range_end_ts ) {
							break;
						}

						$slots_for_day[] = $slot_time;

						$slot_time += $base_interval * 60;
					}
				}
			}

			if ( [] !== $slots_for_day ) {
				$slots = array_merge( $slots, array_unique( $slots_for_day ) );
			}

			$current_day = strtotime( '+1 day', $current_day );
		}

		return $slots;
	}

	/**
	 * Merge cached availability rules for a staff member with general rules.
	 */
	private static function merge_rules_for_staff( array $rules, int $staff_id ): array {
		$merged = [];
		foreach ( $rules as $rule ) {
			$rule_staff_id = isset( $rule->staff_id ) ? (int) $rule->staff_id : 0;
			if ( 0 === $rule_staff_id || ( 0 !== $staff_id && $rule_staff_id === $staff_id ) ) {
				$merged[] = $rule;
			}
		}

		return $merged;
	}

	/**
	 * Build availability ranges for a day from cached rules.
	 *
	 * @param object[] $rules
	 * @return int[][]
	 */
	private static function derive_availability_ranges_from_rules( array $rules, int $day_start, bool $default_appointable, int $interval_minutes ): array {
		$day_start = strtotime( 'midnight', $day_start );
		$max_minutes = 1440 + ( $default_appointable ? max( 0, $interval_minutes ) : 0 );
		$day_limit_ts = $day_start + ( $max_minutes * 60 );

		$minute_mask = array_fill( 0, $max_minutes, $default_appointable );

		foreach ( $rules as $rule ) {
			$rule_start = (int) ( $rule->start_ts ?? 0 );
			$rule_end   = (int) ( $rule->end_ts ?? 0 );

			if ( 0 === $rule_start && 0 === $rule_end ) {
				continue;
			}

			if ( $rule_end <= $day_start || $rule_start >= $day_limit_ts ) {
				continue;
			}

			$rule_start = max( $rule_start, $day_start );
			$rule_end   = min( $rule_end, $day_limit_ts );
			$start_minute = max( 0, (int) floor( ( $rule_start - $day_start ) / 60 ) );
			$end_minute   = min( $max_minutes, (int) ceil( ( $rule_end - $day_start ) / 60 ) );
			$appointable  = ( 'yes' === ( $rule->appointable ?? '' ) );

			for ( $m = $start_minute; $m < $end_minute; $m++ ) {
				$minute_mask[ $m ] = $appointable;
			}
		}

		$ranges = [];
		$range_start = null;
		for ( $m = 0; $m <= $max_minutes; $m++ ) {
			$is_available = ( $m < $max_minutes ) ? ! empty( $minute_mask[ $m ] ) : false;
			if ( $is_available && null === $range_start ) {
				$range_start = $m;
			} elseif ( ! $is_available && null !== $range_start ) {
				$ranges[] = [
					$day_start + ( $range_start * 60 ),
					$day_start + ( $m * 60 ),
				];
				$range_start = null;
			}
		}

		return $ranges;
	}

	/**
	 * Get available time slots for an appointable product using cached availabilities.
	 *
	 * Optimized version that uses cached availability data from the cache table for faster
	 * slot calculation. Falls back to non-cached method when indexing is disabled or when
	 * query exceeds cache horizon. Considers existing appointments, staff availability,
	 * product rules, and capacity constraints.
	 *
	 * @since 4.0.0
	 *
	 * @param WC_Product_Appointment $product Product instance.
	 * @param array<string, mixed>   $args    {
	 *     Optional. Arguments for slot calculation.
	 *
	 *     @type array<int, array<string, mixed>> $slots            Pre-generated slots to check.
	 *     @type array<int, int>                  $intervals        Slot intervals [interval, base_interval].
	 *     @type int|int[]                        $staff_id         Staff ID(s) to filter by. Default 0.
	 *     @type int                              $time_to_check    Specific time to check. Default 0.
	 *     @type int                              $from             Start timestamp. Default 0.
	 *     @type int                              $to               End timestamp. Default 0.
	 *     @type string                           $timezone         Timezone string. Default 'UTC'.
	 *     @type bool                             $include_sold_out Whether to include sold-out slots. Default false.
	 * }
	 *
	 * @return array<int, array<string, mixed>> Time slots indexed by timestamp. Each slot contains:
	 *                                          - available: bool
	 *                                          - schedulable: bool
	 *                                          - staff_id: int|null
	 *                                          - spaces: int
	 *
	 * @throws InvalidArgumentException If product is not an appointment product.
	 *
	 * @example
	 * // Get available slots for next week
	 * $slots = WC_Appointments_Controller::get_cached_time_slots(
	 *     $product,
	 *     [
	 *         'slots' => $pre_generated_slots,
	 *         'from'  => strtotime( 'today' ),
	 *         'to'    => strtotime( '+7 days' ),
	 *         'staff_id' => 5,
	 *     ]
	 * );
	 */
public static function get_cached_time_slots( $product, $args ) {
		$__t0 = microtime( true );
		$args = wp_parse_args(
		    $args,
		    [
				'slots'            => [],
				'intervals'        => [],
				'staff_id'         => 0,
				'time_to_check'    => 0,
				'from'             => 0,
				'to'               => 0,
				'timezone'         => 'UTC',
				'include_sold_out' => false,
			],
		);

		// Fallback to non-cached when indexing is not enabled
		if ( ! class_exists( 'WC_Appointments_Cache_Availability' ) || ! WC_Appointments_Cache_Availability::is_index_enabled() ) {
			return $product->get_time_slots( $args );
		}

		$slots            = $args['slots'];
		$intervals        = $args['intervals'];
		$staff_id         = $args['staff_id'];
		$from             = $args['from'];
		$to               = $args['to'];
		$include_sold_out = $args['include_sold_out'];
		$is_qty_per_day   = method_exists( $product, 'is_qty_per_day' ) && $product->is_qty_per_day();

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

		// Fallback to non-cached when exceeding cache horizon.
		if ( self::cache_horizon_ts() < $to ) {
			return $product->get_time_slots(
			    [
					'slots'            => $slots,
					'intervals'        => $intervals,
					'staff_id'         => $staff_id,
					'time_to_check'    => $args['time_to_check'],
					'from'             => $from,
					'to'               => $to,
					'timezone'         => $args['timezone'],
					'include_sold_out' => $include_sold_out,
				],
			);
		}

		// Early return for empty slots
		if ( empty( $slots ) ) {
			return [];
		}

		// Get staff assignment type to determine availability logic.
		$staff_assignment_type = $product->has_staff() ? $product->get_staff_assignment() : 'customer';

		// Simplified staff ID normalization - consistent with get_cached_slots_in_range_for_day
		$staff_ids_for_query = [];
		if ( 'all' === $staff_assignment_type && $product->has_staff() ) {
			$staff_ids_for_query = array_map( 'intval', (array) $product->get_staff_ids() );
		} elseif ( $staff_id && is_int( $staff_id ) && 0 !== $staff_id ) {
			$staff_ids_for_query[] = (int) $staff_id;
		} elseif ( $staff_id && is_array( $staff_id ) && 0 !== $staff_id ) {
			$staff_ids_for_query = array_map( 'intval', $staff_id );
		} elseif ( $product->has_staff() ) {
			$staff_ids_for_query = array_map( 'intval', (array) $product->get_staff_ids() );
		}
		$staff_ids_for_query[] = 0; // Always include general rules
		$staff_ids_for_query = array_unique( $staff_ids_for_query );

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

		// Simplified staff ID normalization - used outside query.
		$candidate_staff_ids = [];
		if ( 'all' === $staff_assignment_type && $product->has_staff() ) {
			$candidate_staff_ids = array_map( 'intval', (array) $product->get_staff_ids() );
		} elseif ( $staff_id && is_int( $staff_id ) && 0 !== $staff_id ) {
			$candidate_staff_ids[] = (int) $staff_id;
		} elseif ( $staff_id && is_array( $staff_id ) && 0 !== $staff_id ) {
			$candidate_staff_ids = array_map( 'intval', $staff_id );
		} elseif ( $product->has_staff() ) {
			$candidate_staff_ids = array_map( 'intval', (array) $product->get_staff_ids() );
		} else {
			$candidate_staff_ids = [ 0 ];
		}
		$candidate_staff_ids = array_unique( $candidate_staff_ids );

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

		// Get intervals and cache frequently accessed values
		$intervals = empty( $intervals ) ? $product->get_intervals() : $intervals;
		[$interval, $base_interval] = $intervals;
		$product_duration_unit = method_exists( $product, 'get_duration_unit' ) ? $product->get_duration_unit() : 'minute';
		$default_appointable = (bool) $product->get_default_availability();
		$product_id = (int) $product->get_id();

		// Get padding duration in seconds for appointment overlap checking
		$padding_duration_minutes = method_exists( $product, 'get_padding_duration_in_minutes' ) ? $product->get_padding_duration_in_minutes() : 0;
		$padding_duration_seconds = $padding_duration_minutes * 60;

		// Get base product capacity - this is the foundation for all calculations
		// Priority: staff qty (if set) > availability rules qty > product qty
		$base_product_capacity = (int) $product->get_qty();

		// Ensure sensible intervals for day/night duration products
		if ( in_array( $product_duration_unit, [ 'day', 'night' ], true ) ) {
			$duration_days = method_exists( $product, 'get_duration' ) ? max( 1, (int) $product->get_duration() ) : 1;
			$interval      = max( $interval, $duration_days * 1440 );
			$base_interval = max( $base_interval, 1440 );
		}

		sort( $slots );

		// Single database query for availability rules
		if ( ! class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			return [];
		}

		$data_store = new WC_Appointments_Availability_Cache_Data_Store();
		$__tRules = microtime( true );
		$rows = $data_store->get_availability_rules_for_slots(
		    $product_id,
		    (int) $from,
		    (int) $to,
		    $staff_ids_for_query,
		);
		self::dbg_log( __FUNCTION__ . ' rules rows=' . count( (array) $rows ) . ' took=' . number_format( ( microtime( true ) - $__tRules ) * 1000, 2 ) . 'ms' );

		// Organize rules by staff for efficient lookup
		$rules_by_staff = [];
		foreach ( $rows as $row ) {
			$rules_by_staff[ (int) $row->staff_id ][] = $row;
		}

		// Bucket rules by day for faster per-slot lookups
		$rules_buckets_by_staff = [];
		foreach ( $rules_by_staff as $sid_key => $rules_arr ) {
			foreach ( $rules_arr as $rule ) {
				$start_day = strtotime( 'midnight', (int) $rule->start_ts );
				$end_day   = strtotime( 'midnight', max( 0, (int) $rule->end_ts - 1 ) );
				for ( $d = $start_day; $d <= $end_day; $d = strtotime( '+1 day', $d ) ) {
					$rules_buckets_by_staff[ $sid_key ][ $d ][] = $rule;
				}
			}
		}

		// Single database query for appointments
		$__tAppt = microtime( true );
		$cached_appointments = self::get_cached_appointments(
		    $product,
		    $from,
		    $to,
		    $staff_ids_for_query,
		);
		self::dbg_log( __FUNCTION__ . ' appointments rows=' . count( $cached_appointments ) . ' took=' . number_format( ( microtime( true ) - $__tAppt ) * 1000, 2 ) . 'ms' );
		self::dbg_log( __FUNCTION__ . ' appointments rows=' . count( $cached_appointments ) . ' took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms@appts' );

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

		// Ensure interval mirrors originals: respect availability span.
		$interval = ( 'start' === $product->get_availability_span() && ! in_array( $product_duration_unit, [ 'day', 'night' ], true ) ) ? $base_interval : $interval;

		// Pre-calculate values outside the loop for performance
		$interval_seconds = $interval * 60;
		$product_has_staff = $product->has_staff();
		$staff_qtys = $product_has_staff ? $product->get_staff_qtys() : [];
		$staff_id_is_array = is_array( $staff_id );
		$staff_id_int = $staff_id_is_array ? 0 : (int) $staff_id;

		$available_slots = [];
		$general_mask_by_day = [];

		// Precompute appointment paddings and padded windows
		$appointment_padding_cache = [];
		$appointments_padded = [];
		$appointments_buckets_by_day = [];
		foreach ( $cached_appointments as $appointment ) {
			$appointment_product_id = (int) ( $appointment['product_id'] ?? 0 );
			$additional_padding_seconds = 0;
			if ( $appointment_product_id && $appointment_product_id !== $product_id ) {
				if ( isset( $appointment_padding_cache[ $appointment_product_id ] ) ) {
					$additional_padding_seconds = $appointment_padding_cache[ $appointment_product_id ];
				} else {
					$appointment_product = wc_get_product( $appointment_product_id );
					$appointment_padding_minutes = ( $appointment_product && method_exists( $appointment_product, 'get_padding_duration_in_minutes' ) ) ? (int) $appointment_product->get_padding_duration_in_minutes() : 0;
					$additional_padding_seconds = max( 0, $appointment_padding_minutes ) * 60;
					$appointment_padding_cache[ $appointment_product_id ] = $additional_padding_seconds;
				}
			}
			$combined_padding_seconds = $padding_duration_seconds + $additional_padding_seconds;

			$padded_start = (int) $appointment['start_ts'] - $combined_padding_seconds;
			$padded_end   = (int) $appointment['end_ts'] + $combined_padding_seconds;

			$appt_data = [
				'product_id'   => (int) $appointment['product_id'],
				'staff_id'     => (int) $appointment['staff_id'],
				'qty'          => (int) $appointment['qty'],
				'source_id'    => (int) ( $appointment['source_id'] ?? 0 ),
				'padded_start' => $padded_start,
				'padded_end'   => $padded_end,
			];
			$appointments_padded[] = $appt_data;

			// Bucket by day
			$start_day = strtotime( 'midnight', $padded_start );
			$end_day   = strtotime( 'midnight', max( 0, $padded_end - 1 ) );
			for ( $d = $start_day; $d <= $end_day; $d = strtotime( '+1 day', $d ) ) {
				$appointments_buckets_by_day[ $d ][] = count($appointments_padded) - 1;
			}
		}
		$general_mask_by_day = [];
		$booked_qty_per_day_by_staff = [];
		if ( $is_qty_per_day && [] !== $appointments_padded ) {
			foreach ( $appointments_padded as $appt ) {
				$day_start = strtotime( 'midnight', $appt['padded_start'] );
				$day_end   = strtotime( 'midnight', max( 0, $appt['padded_end'] - 1 ) );
				for ( $d = $day_start; $d <= $day_end; $d = strtotime( '+1 day', $d ) ) {
					$sid = $appt['staff_id'];
					if ( ! isset( $booked_qty_per_day_by_staff[ $sid ][ $d ] ) ) {
						$booked_qty_per_day_by_staff[ $sid ][ $d ] = 0;
					}
					$booked_qty_per_day_by_staff[ $sid ][ $d ] += $appt['qty'];

					if ( 0 !== $sid ) {
						if ( ! isset( $booked_qty_per_day_by_staff[0][ $d ] ) ) {
							$booked_qty_per_day_by_staff[0][ $d ] = 0;
						}
						$booked_qty_per_day_by_staff[0][ $d ] += $appt['qty'];
					}
				}
			}
		}
		$day_base_capacity_by_staff = [];

		// Process each slot with simplified logic
		$__slotCount = 0;
		foreach ( $slots as $slot ) {
			$slot_end = $slot + $interval_seconds - 1;
			$slot_available = false;
			$available_qty = 0;
			$staff_map = [];
			$had_any_booking = false; // true when any appointment overlaps this slot
			$had_other_product_booking = false; // true when overlapping appointment belongs to a different product
			$other_product_booked_qty = 0; // quantity blocked by other products (approximate)
			$any_staff_appointable = false;
			$appointable_by_staff = [];

			#error_Log( var_export( date( "m/d/Y h:i", $slot), true ) );

			// Calculate effective capacity following priority: staff qty (if set) > availability rules qty > product qty
			$effective_appointable = $default_appointable;
			$effective_capacity = $base_product_capacity;

			// Apply general availability rules first (staff_id = 0) with span-aware semantics
			$availability_span = method_exists( $product, 'get_availability_span' ) ? $product->get_availability_span() : 'all';
			if ( 'start' === $availability_span ) {
				$slot_day_start = strtotime( 'midnight', $slot );
				$general_day_rules = $rules_buckets_by_staff[0][ $slot_day_start ] ?? [];
				foreach ( $general_day_rules as $rule ) {
					if ( (int) $rule->start_ts <= $slot && (int) $rule->end_ts > $slot ) {
						$effective_appointable = ( 'yes' === $rule->appointable );
						// Availability rules can override product qty
						if ( isset( $rule->qty ) && (int) $rule->qty > 0 ) {
							$effective_capacity = (int) $rule->qty;
						}
					}
				}
			} else {
				$appointment_valid_general = true;
				$slot_day_start = strtotime( 'midnight', $slot );
				$slot_day_end_boundary = strtotime( 'midnight', $slot_end );
				if ( $slot_day_start === $slot_day_end_boundary ) {
					if ( ! isset( $general_mask_by_day[ $slot_day_start ] ) ) {
						$mask = array_fill( 0, 1440, $default_appointable );
						$g_rules = $rules_buckets_by_staff[0][ $slot_day_start ] ?? [];
						foreach ( $g_rules as $rule ) {
							$start_ts = max( (int) $rule->start_ts, $slot_day_start );
							$end_ts   = min( (int) $rule->end_ts, $slot_day_start + DAY_IN_SECONDS );
							if ( $start_ts < $end_ts ) {
								for ( $ts = $start_ts; $ts < $end_ts; $ts += 60 ) {
									$idx = (int) floor( ( $ts - $slot_day_start ) / 60 );
									if ( 0 <= $idx && 1440 > $idx ) {
										$mask[ $idx ] = ( 'yes' === $rule->appointable );
									}
								}
							}
						}
						$general_mask_by_day[ $slot_day_start ] = $mask;
					}
					$mask = $general_mask_by_day[ $slot_day_start ];
					$start_idx = max( 0, (int) floor( ( $slot - $slot_day_start ) / 60 ) );
					$end_idx   = min( 1440, (int) ceil( ( $slot_end - $slot_day_start ) / 60 ) );
					for ( $mi = $start_idx; $mi < $end_idx; $mi++ ) {
						if ( empty( $mask[ $mi ] ) ) { $appointment_valid_general = false; break; }
					}
				} else {
					for ( $check_ts = $slot; $check_ts < $slot_end; $check_ts += 60 ) {
						$minute_effective = $default_appointable;
						$g_rules = $rules_buckets_by_staff[0][ strtotime( 'midnight', $check_ts ) ] ?? [];
						foreach ( $g_rules as $rule ) {
							if ( (int) $rule->start_ts <= $check_ts && (int) $rule->end_ts > $check_ts ) {
								$minute_effective = ( 'yes' === $rule->appointable );
							}
						}
						if ( ! $minute_effective ) { $appointment_valid_general = false; break; }
					}
				}
				$effective_appointable = $appointment_valid_general;
				// Keep capacity override simple (overlap-based) to avoid excessive computation
				$general_day_rules = $rules_buckets_by_staff[0][ $slot_day_start ] ?? [];
				foreach ( $general_day_rules as $rule ) {
					if ( (int) $rule->end_ts > $slot && (int) $rule->start_ts < $slot_end && isset( $rule->qty ) && (int) $rule->qty > 0 ) {
						$effective_capacity = (int) $rule->qty;
					}
				}
			}

			// Calculate base capacities for each staff member
			$base_capacities = [];
			$staff_available = false;
			$slot_day_start_for_staff = strtotime( 'midnight', $slot );
			foreach ( $candidate_staff_ids as $sid ) {
				$staff_effective_appointable = $effective_appointable;

				// Check staff quantity first (highest priority - overrides everything)
				if ( $product_has_staff && 0 < $sid ) {
					if ( isset( $staff_qtys[ $sid ] ) && '' !== $staff_qtys[ $sid ] && (int) $staff_qtys[ $sid ] > 0 ) {
						$staff_effective_capacity = (int) $staff_qtys[ $sid ];
					} else {
						$staff_effective_capacity = $effective_capacity;
					}

					// Always apply specific staff rules to appointability and reduce capacity if needed
					if ( isset( $rules_buckets_by_staff[ $sid ][ $slot_day_start_for_staff ] ) ) {
						foreach ( $rules_buckets_by_staff[ $sid ][ $slot_day_start_for_staff ] as $rule ) {
							if ( $slot < $rule->end_ts && $slot_end > $rule->start_ts ) {
								$staff_effective_appointable = ( 'yes' === $rule->appointable );
								// Staff rules can only reduce capacity, not increase beyond product/availability rules or staff qty
								if ( isset( $rule->qty ) && (int) $rule->qty > 0 ) {
									$staff_effective_capacity = min( $staff_effective_capacity, (int) $rule->qty );
								}
							}
						}
					}
				} else {
					$staff_effective_capacity = $effective_capacity;
				}

				$appointable_by_staff[ $sid ] = $staff_effective_appointable;
				if ( $staff_effective_appointable ) {
					$any_staff_appointable = true;
				}
				if ( 0 < $staff_effective_capacity ) {
					$staff_available = true;
					$base_capacities[ $sid ] = $staff_effective_capacity;
				} else {
					$base_capacities[ $sid ] = 0;
				}
			}

			if ( $is_qty_per_day ) {
				$slot_day_key = strtotime( 'midnight', $slot );
				foreach ( $candidate_staff_ids as $sid ) {
					$cap_for_day = (int) ( $base_capacities[ $sid ] ?? 0 );
					if ( ! isset( $day_base_capacity_by_staff[ $sid ][ $slot_day_key ] ) ) {
						$day_base_capacity_by_staff[ $sid ][ $slot_day_key ] = $cap_for_day;
					} else {
						$day_base_capacity_by_staff[ $sid ][ $slot_day_key ] = min( $day_base_capacity_by_staff[ $sid ][ $slot_day_key ], $cap_for_day );
					}
				}
			}

			// Note: available_qty will be calculated after booking deductions

			// Calculate total booked quantity for this slot across all relevant staff/appointments
			$total_booked_qty = 0;
			// Track per-appointment quantities to avoid double counting across multiple staff rows
			$per_appointment_quantities = [];
			$staff_booked_qty = [];
			$all_staff_available = true;

			// Initialize staff booked quantities
			foreach ( $candidate_staff_ids as $sid ) {
				$staff_booked_qty[ $sid ] = 0;
			}

			// Cache padding per appointment product to avoid repeated lookups
			// Calculate booked quantities
			$slot_day_start_bucket = strtotime( 'midnight', $slot );
			$slot_day_end_bucket   = strtotime( 'midnight', $slot_end );

			if ( $slot_day_start_bucket === $slot_day_end_bucket ) {
				$day_appointments_indices = $appointments_buckets_by_day[ $slot_day_start_bucket ] ?? [];
			} else {
				$day_appointments_indices = [];
				for ( $d = $slot_day_start_bucket; $d <= $slot_day_end_bucket; $d = strtotime( '+1 day', $d ) ) {
					if ( isset( $appointments_buckets_by_day[ $d ] ) ) {
						foreach ( $appointments_buckets_by_day[ $d ] as $idx ) {
							$day_appointments_indices[$idx] = $idx;
						}
					}
				}
			}

			foreach ( $day_appointments_indices as $idx ) {
				$appointment = $appointments_padded[$idx];

				// Apply padding time to appointment time range.
				$appointment_start_with_padding = $appointment['padded_start'];
				$appointment_end_with_padding   = $appointment['padded_end'];
				if ( $slot < $appointment_end_with_padding && $slot_end > $appointment_start_with_padding ) {
					$appointment_staff_id = $appointment['staff_id'];
					$appointment_qty = $appointment['qty'];
					$appointment_source_id = $appointment['source_id'];

					// When staff booked with another product, subtract only the booked staff's capacity; for staff_id=0, block full slot
					if ( $appointment['product_id'] !== $product_id ) {
						if ( 0 === $appointment_staff_id ) {
							$appointment_qty = $effective_capacity;
						} elseif ( isset( $base_capacities[ $appointment_staff_id ] ) ) {
							$appointment_qty = (int) $base_capacities[ $appointment_staff_id ];
						} else {
							$appointment_qty = 0;
						}
						$had_other_product_booking = true;
						$other_product_booked_qty += $appointment_qty; // approximate amount blocked by other product
					}

					// Count bookings based on staff assignment and selection
					// Check if staff is actually selected (not just [0] or 0 which means "no staff selected")
					$has_staff_selected = false;
					if ( $staff_id_is_array ) {
						// Filter out 0 from array to check if any actual staff is selected
						$non_zero_staff = array_filter( $staff_id, fn($id): bool => (int) $id > 0 );
						$has_staff_selected = [] !== $non_zero_staff;
					} elseif ( 0 < $staff_id_int ) {
						$has_staff_selected = true;
					}

					if ( $has_staff_selected ) {
						// Specific staff selected - only count bookings for that staff
						if ( $staff_id_is_array ) {
							if ( in_array( $appointment_staff_id, $staff_id ) ) {
								$total_booked_qty += $appointment_qty;
								if ( ! isset( $staff_booked_qty[ $appointment_staff_id ] ) ) {
									$staff_booked_qty[ $appointment_staff_id ] = 0;
								}
								$staff_booked_qty[ $appointment_staff_id ] += $appointment_qty;
							}
						} elseif ( $appointment_staff_id === $staff_id_int ) {
							$total_booked_qty += $appointment_qty;
							if ( ! isset( $staff_booked_qty[ $appointment_staff_id ] ) ) {
								$staff_booked_qty[ $appointment_staff_id ] = 0;
							}
							$staff_booked_qty[ $appointment_staff_id ] += $appointment_qty;
						}
					} else {
						// No specific staff selected - count all relevant bookings
						// This includes appointments with staff_id=0 when no staff is selected
						$total_booked_qty += $appointment_qty;
						// Always track booked quantity by staff, even if staff_id is 0 or not in candidate_staff_ids
						// This ensures appointments with staff_id=0 are properly detected for day classification
						if ( ! isset( $staff_booked_qty[ $appointment_staff_id ] ) ) {
							$staff_booked_qty[ $appointment_staff_id ] = 0;
						}
						$staff_booked_qty[ $appointment_staff_id ] += $appointment_qty;
						// Track per-appointment quantities for scheduled count when no staff selected
						if ( 0 !== $appointment_source_id ) {
							if ( ! isset( $per_appointment_quantities[ $appointment_source_id ] ) ) {
								$per_appointment_quantities[ $appointment_source_id ] = [];
							}
							$per_appointment_quantities[ $appointment_source_id ][] = $appointment_qty;
						}
					}

					$had_any_booking = true;
				}
			}

			// Calculate remaining capacity - use sum of staff capacities when no staff selected
			if ( ! $staff_id && $product_has_staff ) {
				$remaining_total_capacity = 0;
				foreach ( $base_capacities as $sid => $avail ) {
					$booked = $staff_booked_qty[ $sid ] ?? 0;
					$net = max( 0, (int) $avail - $booked );
					if ( isset( $appointable_by_staff[ $sid ] ) && $appointable_by_staff[ $sid ] ) {
						$remaining_total_capacity += $net;
					}
				}
			} else {
				// When staff selected or no staff product, use effective capacity
				$remaining_total_capacity = max( 0, $effective_capacity - $total_booked_qty );
			}

			#error_log( var_export( $base_capacities, true ) );
			#error_log( var_export( $staff_booked_qty, true ) );
			#error_log( var_export( $total_booked_qty, true ) );
			#error_log( var_export( $remaining_total_capacity, true ) );

			// Calculate individual staff remaining capacities
			foreach ( $candidate_staff_ids as $sid ) {
				if ( ! isset( $base_capacities[ $sid ] ) || 0 >= $base_capacities[ $sid ] ) {
					$staff_map[ $sid ] = 0;
					$all_staff_available = false;
					continue;
				}

				$staff_remaining = max( 0, $base_capacities[ $sid ] - $staff_booked_qty[ $sid ] );
				$staff_map[ $sid ] = ( $appointable_by_staff[ $sid ] ?? false ) ? $staff_remaining : 0;

				// For 'all' assignment type with a specific staff selected, all staff must have capacity
				if ( 'all' === $staff_assignment_type && ! $staff_id_is_array && 0 < $staff_id_int && 0 >= $staff_remaining ) {
					$all_staff_available = false;
				}

				// For 'all' assignment type, if any staff is not appointable, then all staff are not available
				if ( 'all' === $staff_assignment_type && ( ! isset( $appointable_by_staff[ $sid ] ) || ! $appointable_by_staff[ $sid ] ) ) {
					$all_staff_available = false;
				}
			}

			// Add aggregated capacity for key 0 when product has staff and no staff selected
			if ( ! $staff_id && $product_has_staff ) {
				$staff_map[0] = (int) $remaining_total_capacity;
			}

			if ( $is_qty_per_day ) {
				$slot_day_key = strtotime( 'midnight', $slot );
				$day_remaining_total = null;

				if ( $product_has_staff ) {
					if ( 'all' === $staff_assignment_type ) {
						foreach ( $candidate_staff_ids as $sid ) {
							if ( 0 === $sid && $product_has_staff ) {
								continue;
							}

							$base_for_day   = $day_base_capacity_by_staff[ $sid ][ $slot_day_key ] ?? ( (int) ( $base_capacities[ $sid ] ?? 0 ) );
							$booked_for_day = $booked_qty_per_day_by_staff[ $sid ][ $slot_day_key ] ?? 0;

							if ( isset( $booked_qty_per_day_by_staff[0][ $slot_day_key ] ) && 0 !== $sid ) {
								$booked_for_day += $booked_qty_per_day_by_staff[0][ $slot_day_key ];
							}

							$day_remaining = max( 0, $base_for_day - $booked_for_day );

							if ( isset( $staff_map[ $sid ] ) ) {
								$staff_map[ $sid ] = min( $staff_map[ $sid ], $day_remaining );
							}

							$day_remaining_total = null === $day_remaining_total ? $day_remaining : min( $day_remaining_total, $day_remaining );
						}

						if ( null === $day_remaining_total ) {
							$day_remaining_total = 0;
						}

						$remaining_total_capacity = min( $remaining_total_capacity, (int) $day_remaining_total );

						if ( 0 >= $remaining_total_capacity ) {
							$all_staff_available = false;
						}
					} elseif ( ! $staff_id ) {
						$day_sum = 0;
						foreach ( $candidate_staff_ids as $sid ) {
							if ( 0 === $sid ) {
								continue;
							}

							$base_for_day   = $day_base_capacity_by_staff[ $sid ][ $slot_day_key ] ?? ( (int) ( $base_capacities[ $sid ] ?? 0 ) );
							$booked_for_day = $booked_qty_per_day_by_staff[ $sid ][ $slot_day_key ] ?? 0;

							if ( isset( $booked_qty_per_day_by_staff[0][ $slot_day_key ] ) ) {
								$booked_for_day += $booked_qty_per_day_by_staff[0][ $slot_day_key ];
							}

							$day_remaining = max( 0, $base_for_day - $booked_for_day );

							if ( isset( $staff_map[ $sid ] ) ) {
								$staff_map[ $sid ] = min( $staff_map[ $sid ], $day_remaining );
							}

							if ( isset( $appointable_by_staff[ $sid ] ) && $appointable_by_staff[ $sid ] ) {
								$day_sum += $day_remaining;
							}
						}

						$remaining_total_capacity = min( $remaining_total_capacity, $day_sum );
						$staff_map[0]             = (int) $remaining_total_capacity;
					} else {
						$target_sid = $staff_id_is_array ? (int) reset( $staff_id ) : ( $staff_id_int ?: 0 );

						$base_for_day   = $day_base_capacity_by_staff[ $target_sid ][ $slot_day_key ] ?? ( (int) ( $base_capacities[ $target_sid ] ?? 0 ) );
						$booked_for_day = $booked_qty_per_day_by_staff[ $target_sid ][ $slot_day_key ] ?? 0;

						if ( isset( $booked_qty_per_day_by_staff[0][ $slot_day_key ] ) && 0 !== $target_sid ) {
							$booked_for_day += $booked_qty_per_day_by_staff[0][ $slot_day_key ];
						}

						$day_remaining = max( 0, $base_for_day - $booked_for_day );

						if ( isset( $staff_map[ $target_sid ] ) ) {
							$staff_map[ $target_sid ] = min( $staff_map[ $target_sid ], $day_remaining );
						}

						$remaining_total_capacity = min( $remaining_total_capacity, $day_remaining );
					}
				} else {
					$base_for_day   = $day_base_capacity_by_staff[0][ $slot_day_key ] ?? $base_product_capacity;
					$booked_for_day = $booked_qty_per_day_by_staff[0][ $slot_day_key ] ?? 0;
					$day_remaining  = max( 0, $base_for_day - $booked_for_day );

					$remaining_total_capacity = min( $remaining_total_capacity, $day_remaining );
					$staff_map[0]             = $remaining_total_capacity;
				}
			}

			// When no staff is preselected, cap aggregate remaining capacity to the product's per-day quantity.
			if ($is_qty_per_day && $product_has_staff && ! $staff_id) {
                $remaining_total_capacity = min( $remaining_total_capacity, $base_product_capacity );
                $staff_map[0] = isset( $staff_map[0] ) ? min( $staff_map[0], $base_product_capacity ) : $remaining_total_capacity;
            }

			// Final available quantity is the remaining total capacity
			$available_qty = $remaining_total_capacity;

			// Compute scheduled as a numeric count to match WC_Product_Appointment::get_time_slots
			$scheduled_qty = 0;
			if ( ! $staff_id && $product_has_staff ) {
				foreach ( $per_appointment_quantities as $parts ) {
					if ( [] === $parts ) {
						continue;
					}
					$scheduled_qty += max( $parts );
				}
			} else {
				$scheduled_qty = $total_booked_qty;
			}

			#error_log( "slot: " . date( "m/d/Y h:i", $slot) . " available: " . $available_qty . " scheduled: " . $scheduled_qty );
			#error_log( "effective_appointable: " . $effective_appointable );
			#error_log( "staff_effective_appointable: " . $staff_effective_appointable );
			#error_log( "all_staff_available: " . $all_staff_available );
			#error_log( "available_qty: " . $available_qty );

			// Determine slot availability based on assignment type and selection, gated by union of staff when none selected
			if ( 'all' === $staff_assignment_type && ! $staff_id_is_array && 0 < $staff_id_int ) {
				$slot_available = $effective_appointable && $all_staff_available && 0 < $available_qty;
			} elseif ( 'all' === $staff_assignment_type ) {
				$slot_available = $all_staff_available && 0 < $available_qty;
			} elseif ( ! $staff_id && $product_has_staff ) {
				$slot_available = ( ( $any_staff_appointable || $effective_appointable ) && 0 < $available_qty );
			} else {
				// When a specific staff is selected, check if that staff is appointable
				if ( $product_has_staff && 0 < $staff_id_int ) {
					$selected_staff_appointable = isset( $appointable_by_staff[ $staff_id_int ] ) ? $appointable_by_staff[ $staff_id_int ] : false;
					$slot_available = ( $selected_staff_appointable && 0 < $available_qty );
				} elseif ( $staff_id_is_array && $product_has_staff ) {
					// When multiple staff are selected, check if any of them are appointable
					$any_selected_staff_appointable = false;
					foreach ( $staff_id as $sid ) {
						if ( isset( $appointable_by_staff[ (int) $sid ] ) && $appointable_by_staff[ (int) $sid ] ) {
							$any_selected_staff_appointable = true;
							break;
						}
					}
					$slot_available = ( $any_selected_staff_appointable && 0 < $available_qty );
				} else {
					// No staff product or no staff selected - use general availability
					$slot_available = ( $effective_appointable && 0 < $available_qty );
				}
			}

			// Add slot to results if available or if including sold out
			if ( $slot_available || $include_sold_out ) {
				$available_slots[ $slot ] = [
					'available' => (int) $available_qty,
					'staff'     => $staff_map,
					'scheduled' => $scheduled_qty,
					// Extra metadata to approximate lost semantics for day-level classification.
					// This does not affect existing consumers but can be used by refactors.
					'meta'      => [
						'had_booking'       => $had_any_booking,
						'had_other_product'  => $had_other_product_booking,
						'other_product_qty'  => $other_product_booked_qty,
						// Provide per-staff booked quantities for downstream day-level classification
						'booked_by_staff'   => $staff_booked_qty,
					],
				];
			}

			#error_Log( var_export( $slot_available, true ) );
			$__slotCount++;
		}

		#error_Log( var_export( $available_slots, true ) );

		self::dbg_log( __FUNCTION__ . ' product=' . (int) $product->get_id() . ' in=' . count( (array) $slots ) . ' out=' . count( $available_slots ) . ' took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );
		return $available_slots;
}

	/**
	 * Get cached available slots (filters out unavailable ones) - OPTIMIZED VERSION
	 */
public static function get_cached_available_slots( $args ) {
		$__t0 = microtime( true );
		// Check if indexed availability is enabled
		if ( ! class_exists( 'WC_Appointments_Cache_Availability' ) || ! WC_Appointments_Cache_Availability::is_index_enabled() ) {
			return [];
		}

		$args = wp_parse_args(
		    $args,
		    [
				'slots'        => [],
				'intervals'    => [],
				'staff_id'     => 0,
				'from_range'   => '',
				'to_range'     => '',
				'from'         => '',
				'to'           => '',
			],
		);

		$product      = $args['product'];
		$slots        = $args['slots'];
		$staff_id     = $args['staff_id'];
		$from         = $args['from'];
		$to           = $args['to'];

		// Early return for empty slots
		if ( empty( $slots ) ) {
			return [];
		}

		// Cache frequently accessed values
		$product_has_staff = $product->has_staff();
		$default_appointable = (bool) $product->get_default_availability();
		$product_id = (int) $product->get_id();

		// Determine staff IDs to test (optimized logic)
		$staffs_to_test = [];
		if ( 'all' === $product->get_staff_assignment() && $product_has_staff ) {
			$staffs_to_test = array_map( 'intval', $product->get_staff_ids() );
		} elseif ( is_array( $staff_id ) && [] !== $staff_id ) {
			$staffs_to_test = array_map( 'intval', $staff_id );
		} elseif ( $product_has_staff && ! $staff_id ) {
			$staffs_to_test = array_map( 'intval', $product->get_staff_ids() );
		} elseif ( $staff_id ) {
			$staffs_to_test = [ (int) $staff_id ];
		}
		// Always include general (0) rules
		$staffs_to_test[] = 0;
		$staffs_to_test = array_values( array_unique( $staffs_to_test ) );

		// Pre-compute duration-related values to avoid repeated calculations
		$duration_unit = method_exists( $product, 'get_duration_unit' ) ? $product->get_duration_unit() : 'minute';
		$intervals = $args['intervals'] ?? [];
		if ( empty( $intervals ) && method_exists( $product, 'get_intervals' ) ) {
			$intervals = $product->get_intervals();
		}
		$interval_seconds = 60; // default
		if ( is_array( $intervals ) && count( $intervals ) >= 1 ) {
			$interval_seconds = (int) $intervals[0] * 60;
		}

		// Fetch and organize availability rules using CRUD operations
		if ( ! class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			return $slots;
		}

		$data_store = new WC_Appointments_Availability_Cache_Data_Store();

		$__tRules = microtime( true );
		$rows = $data_store->get_availability_rules_for_slots(
		    $product_id,
		    (int) $from,
		    (int) $to,
		    $staffs_to_test,
		);
		self::dbg_log( __FUNCTION__ . ' rules rows=' . count( (array) $rows ) . ' took=' . number_format( ( microtime( true ) - $__tRules ) * 1000, 2 ) . 'ms' );

		// Convert objects to arrays for compatibility with existing code
		$rows = array_map( fn($row): array => [
				'staff_id' => (int) $row->staff_id,
				'start_ts' => (int) $row->start_ts,
				'end_ts' => (int) $row->end_ts,
				'appointable' => $row->appointable,
				'priority' => (int) $row->priority,
			], $rows );

		// Optimize rule organization by staff (avoid object property access)
		$rules_by_staff = [];
		foreach ( $rows as $row ) {
			$staff_id_key = (int) $row['staff_id'];
			$rules_by_staff[ $staff_id_key ][] = [
				'start_ts' => (int) $row['start_ts'],
				'end_ts' => (int) $row['end_ts'],
				'appointable' => 'yes' === $row['appointable'],
			];
		}

		// Pre-compute slot ends for all slots to avoid repeated calculations
		$slot_ends = [];
		$__slotCount = 0;
		foreach ( $slots as $slot ) {
		switch ( $duration_unit ) {
			case WC_Appointments_Constants::DURATION_MONTH:
				$month_start = strtotime( date( 'Y-m-01 00:00:00', $slot ) );
				$slot_ends[ $slot ] = strtotime( '+1 month', $month_start ) - 1;
				break;
			case WC_Appointments_Constants::DURATION_DAY:
			case WC_Appointments_Constants::DURATION_NIGHT:
				$slot_ends[ $slot ] = strtotime( '23:59:59', $slot );
				break;
			default:
				$slot_ends[ $slot ] = $slot + $interval_seconds - 1;
				break;
		}
			$__slotCount++;
		}

		// Process slots with optimized logic
		$available_slots = [];
		foreach ( $slots as $slot ) {
			$slot_end = $slot_ends[ $slot ];
			$slot_available = false;

			// Check each staff for availability (optimized inner loop)
			if ( 'all' === $product->get_staff_assignment() && $product_has_staff ) {
				$slot_available = true;
				// For 'all' assignment, we need to check ALL staff IDs
				$staff_ids_check = $product->get_staff_ids();

				foreach ( $staff_ids_check as $sid ) {
					$effective_appointable = $default_appointable;

					// Process general rules (staff_id = 0)
					if ( isset( $rules_by_staff[0] ) ) {
						foreach ( $rules_by_staff[0] as $rule ) {
							if ( $slot < $rule['end_ts'] && $slot_end > $rule['start_ts'] ) {
								$effective_appointable = $rule['appointable'];
							}
						}
					}

					// Process specific staff rules
					if ( isset( $rules_by_staff[ $sid ] ) ) {
						foreach ( $rules_by_staff[ $sid ] as $rule ) {
							if ( $slot < $rule['end_ts'] && $slot_end > $rule['start_ts'] ) {
								$effective_appointable = $rule['appointable'];
							}
						}
					}

					if ( ! $effective_appointable ) {
						$slot_available = false;
						break;
					}
				}
			} else {
				foreach ( $staffs_to_test as $sid ) {
					$effective_appointable = $default_appointable;

					// Process general rules (staff_id = 0)
					if ( isset( $rules_by_staff[0] ) ) {
						foreach ( $rules_by_staff[0] as $rule ) {
							if ( $slot < $rule['end_ts'] && $slot_end > $rule['start_ts'] ) {
								$effective_appointable = $rule['appointable'];
							}
						}
					}

					// Process specific staff rules (if applicable)
					if ( 0 < $sid && isset( $rules_by_staff[ $sid ] ) ) {
						foreach ( $rules_by_staff[ $sid ] as $rule ) {
							if ( $slot < $rule['end_ts'] && $slot_end > $rule['start_ts'] ) {
								$effective_appointable = $rule['appointable'];
							}
						}
					}

					if ( $effective_appointable ) {
						$slot_available = true;
						break; // Early exit when any staff is available
					}
				}
			}

			if ( $slot_available ) {
				$available_slots[] = $slot;
			}
		}

		self::dbg_log( __FUNCTION__ . ' product=' . (int) $product->get_id() . ' in=' . count( (array) $slots ) . ' out=' . count( $available_slots ) . ' took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );
		return $available_slots;
}

	/**
	 * Fetch cached appointment rows from index, limited to fully-scheduled statuses.
	 *
	 * Returns rows as arrays with keys: start_ts, end_ts, staff_id, qty, status.
	 *
	 * @since 4.0.0
	 *
	 * @param WC_Product $product
	 * @param int[]      $staff_ids
	 * @return array[]
	 */
	private static function get_cached_appointments( $product, int $from, int $to, array $staff_ids = [] ): array {
		$__t0 = microtime( true );
		if ( ! class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			return [];
		}

		$product_id = is_object( $product ) && method_exists( $product, 'get_id' ) ? (int) $product->get_id() : 0;
		if ( 0 >= $product_id ) {
			return [];
		}

		// When $from is 0, define it.
		if ( 0 === $from ) {
			// Determine a min and max date
			$from = $product->get_min_date_a();
			$from = empty( $from ) ? [
				'unit'  => 'minute',
				'value' => 1,
			] : $from;
			$from = strtotime( "midnight +{$from['value']} {$from['unit']}", current_time( 'timestamp' ) );
		}

		// When $to is 0, define it.
		if ( 0 === $to ) {
			$to = $product->get_max_date_a();
			$to = empty( $to ) ? [
				'unit'  => 'month',
				'value' => 12,
			] : $to;
			$to = strtotime( "+{$to['value']} {$to['unit']}", current_time( 'timestamp' ) );
		}

		if ( 0 >= $from || 0 >= $to ) {
			return [];
		}

		// Set default date range if not provided
		if ( 0 === $from ) {
			$from = strtotime( 'today' );
		}
		if ( 0 === $to ) {
			$max_date = strtotime( '+1 year' );
		}

		$data_store = new WC_Appointments_Availability_Cache_Data_Store();

		// Get fully scheduled appointment statuses
		$fully_scheduled_statuses = get_wc_appointment_statuses( 'fully_scheduled' );

		// Build filters for appointments (only fully scheduled statuses)
		$filters = [
			'source' => 'appointment',
			'status' => $fully_scheduled_statuses,
			'time_between' => [
				'start_ts' => $from,
				'end_ts'   => $to,
			],
		];

		// Filter out zero values to only include valid staff IDs.
		$valid_staff_ids = array_filter( $staff_ids, fn(int $id): bool => 0 < $id );

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

		// When no staff filter is provided, limit by the current product only.
		// When a staff filter is provided, DO NOT limit by product, so we also get
		// appointments for the same staff across other products (needed to block/exclude).
		if ( [] === $valid_staff_ids ) {
			$filters['product_id'] = $product_id;
		}

		// Filter by staff if specified (include 0 for "any staff" bucket when provided).
		if ( [] !== $valid_staff_ids ) {
			$filters['staff_id'] = array_unique( array_merge( [ 0 ], array_map( 'intval', $staff_ids ) ) );
		}

		$__items = $data_store->get_items( $filters );
		self::dbg_log( __FUNCTION__ . ' product=' . $product_id . ' staff=' . json_encode( array_values( $staff_ids ) ) . ' rows=' . count( (array) $__items ) . ' took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );
		return $__items;
}

	/**
	 * Render cached time-slots HTML using the already computed cached availability.
	 * Expects the 'available' array as returned by get_cached_time_slots().
	 *
	 * @since 4.0.0
	 *
	 * @param WC_Product_Appointment $product Product instance.
	 * @param array                  $args    Arguments.
	 *
	 * @return string
	 */
	public static function get_cached_time_slots_html( $product, $args ) {
		$__t0 = microtime( true );
		$args = wp_parse_args(
		    $args,
		    [
				'available'        => [],
				'intervals'        => [],
				'staff_id'         => 0,
				'time_to_check'    => 0,
				'from'             => 0,
				'to'               => 0,
				'timestamp'        => 0,
				'timezone'         => 'UTC',
				'include_sold_out' => false,
			],
		);

		// Fallback to non-cached when indexing is not enabled or end time exceeds horizon
		if ( ! class_exists( 'WC_Appointments_Cache_Availability' ) || ! WC_Appointments_Cache_Availability::is_index_enabled() || self::cache_horizon_ts() < $args['to'] ) {
			return $product->get_time_slots_html( $args );
		}

		$available_slots = $args['available'];
		$intervals       = $args['intervals'];
		$staff_id        = $args['staff_id'];
		$time_to_check   = $args['time_to_check'];
		$from            = $args['from'];
		$to              = $args['to'];
		$timestamp       = $args['timestamp'];
		$timezone        = $args['timezone'];
		$include_sold_out= $args['include_sold_out'];

		$slots_html = '';
		$__rendered = 0;

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

		if ( $available_slots ) {
			$timezone_datetime = new DateTime();
			$local_time        = $product->has_timezones() ? wc_appointment_timezone_locale( 'site', 'user', $timezone_datetime->getTimestamp(), wc_appointments_time_format(), $timezone ) : '';
			$site_time         = $product->has_timezones() ? wc_appointment_timezone_locale( 'site', 'user', $timezone_datetime->getTimestamp(), wc_appointments_time_format(), wc_timezone_string() ) : '';

			$times = apply_filters(
			    'woocommerce_appointments_times_split',
			    [
					'morning'   => [
						'name' => __( 'Morning', 'woocommerce-appointments' ),
						'from' => strtotime( '00:00' ),
						'to'   => strtotime( '12:00' ),
					],
					'afternoon' => [
						'name' => __( 'Afternoon', 'woocommerce-appointments' ),
						'from' => strtotime( '12:00' ),
						'to'   => strtotime( '17:00' ),
					],
					'evening'   => [
						'name' => __( 'Evening', 'woocommerce-appointments' ),
						'from' => strtotime( '17:00' ),
						'to'   => strtotime( '24:00' ),
					],
				],
			);

			$slots_html .= "<div class=\"slot_row\">";
			foreach ( $times as $k => $v ) {
				$slots_html .= "<ul class=\"slot_column $k\">";
				$slots_html .= '<li class="slot_heading">' . $v['name'] . '</li>';
				$count       = 0;

				foreach ( $available_slots as $slot => $quantity ) {
					$local_slot   = $product->has_timezones() ? wc_appointment_timezone_locale( 'site', 'user', $slot, 'U', $timezone ) : $slot;
					$display_slot = ( $product->has_timezones() && $local_time !== $site_time ) ? $local_slot : $slot;

					// Only render for selected calendar day (respect timezone).
					if ( date( 'Y.m.d', $timestamp ) !== date_i18n( 'Y.m.d', $local_slot ) ) {
						continue;
					}

					// Check bucket range.
					if ( ! ( strtotime( date( 'G:i', $display_slot ) ) >= $v['from'] && strtotime( date( 'G:i', $display_slot ) ) < $v['to'] ) ) {
						continue;
					}

					$selected         = ( $time_to_check && date( 'G:i', $slot ) === date( 'G:i', $time_to_check ) ) ? ' selected' : '';

					// Use controller-provided, already-correct numbers:
					$available_actual = (int) ( $quantity['available'] ?? 0 );
					$show_spaces_left = ! empty( $quantity['scheduled'] );
					$spaces_left_text = $show_spaces_left
						? sprintf( _n( '%d left', '%d left', $available_actual, 'woocommerce-appointments' ), $available_actual )
						: '';
					$data_remaining   = $available_actual;

					if ( 0 < $available_actual ) {
						if ( $show_spaces_left ) {
							$slot_html = "<li class=\"slot$selected\" data-slot=\"" . esc_attr( date( 'Hi', $display_slot ) ) . "\" data-remaining=\"" . esc_attr( $data_remaining ) . "\"><a href=\"#\" data-value=\"" . date_i18n( 'G:i', $display_slot ) . "\">" . date_i18n( wc_appointments_time_format(), $display_slot ) . " <small class=\"spaces-left\">" . $spaces_left_text . "</small></a></li>";
						} else {
							$slot_html = "<li class=\"slot$selected\" data-slot=\"" . esc_attr( date( 'Hi', $display_slot ) ) . "\" data-remaining=\"" . esc_attr( $data_remaining ) . "\"><a href=\"#\" data-value=\"" . date_i18n( 'G:i', $display_slot ) . "\">" . date_i18n( wc_appointments_time_format(), $display_slot ) . "</a></li>";
						}
						$slots_html .= apply_filters( 'woocommerce_appointments_time_slot_html', $slot_html, $display_slot, $quantity, $time_to_check, $staff_id, $timezone, $product, $spaces_left_text, [] );
						$__rendered++;
					} elseif ( 0 === $available_actual && $include_sold_out ) {
						if ( $show_spaces_left ) {
							$slot_html = "<li class=\"slot$selected\" data-slot=\"" . esc_attr( date( 'Hi', $display_slot ) ) . "\" data-remaining=\"0\"><span data-value=\"" . date_i18n( 'G:i', $display_slot ) . "\">" . date_i18n( wc_appointments_time_format(), $display_slot ) . " <small class=\"spaces-left\">" . $spaces_left_text . "</small></span></li>";
						} else {
							$slot_html = "<li class=\"slot$selected\" data-slot=\"" . esc_attr( date( 'Hi', $display_slot ) ) . "\" data-remaining=\"0\"><span data-value=\"" . date_i18n( 'G:i', $display_slot ) . "\">" . date_i18n( wc_appointments_time_format(), $display_slot ) . "</span></li>";
						}
						$slots_html .= apply_filters( 'woocommerce_appointments_time_slot_html', $slot_html, $display_slot, $quantity, $time_to_check, $staff_id, $timezone, $product, $spaces_left_text, [] );
						$__rendered++;
					}

					$count++;
				}

				if ( 0 === $count ) {
					$slots_html .= '<li class="slot slot_empty">' . __( '&#45;', 'woocommerce-appointments' ) . '</li>';
				}

				$slots_html .= "</ul>";
			}

			$slots_html .= "</div>";
		}

		self::dbg_log( __FUNCTION__ . ' product=' . (int) $product->get_id() . ' rendered=' . $__rendered . ' took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );
		return apply_filters(
		    'woocommerce_appointments_time_slots_html',
		    $slots_html,
		    array_keys( (array) $available_slots ),
		    $intervals,
		    $time_to_check,
		    $staff_id,
		    $from,
		    $to,
		    $timezone,
		    $product,
		    [],
		);
	}

	/**
	 * Fast path: compute total available appointments for a range using indexed cache
	 * when the requested range is within the cache horizon.
	 *
	 * Behavior mirrors wc_appointments_get_total_available_appointments_for_range:
	 * - If product has staff and no specific staff_id is provided, return an array of staff => available_count
	 *   (only staff with at least one available slot are returned).
	 * - Otherwise, return an integer count of available slots that can satisfy $qty.
	 *
	 * Returns false when not available or request out of allowed min/max bounds.
	 *
	 * @since 4.0.0
	 *
	 * @param WC_Product_Appointment $product
	 * @param int                    $start_date
	 * @param int                    $end_date
	 * @param int|array|null         $staff_id
	 * @param int                    $qty
	 * @return array|int|false
	 */
	public static function get_indexed_total_available_for_range( $product, int $start_date, int $end_date, $staff_id = null, int $qty = 1 ) {
		$__t0 = microtime( true );
		if ( ! is_object( $product ) || ! method_exists( $product, 'get_id' ) ) {
			return false;
		}

		// Respect availability span: limit end to one slot after start if enabled.
		if ( $product->get_availability_span() ) {
			$end_date = strtotime( '+ ' . $product->get_duration() . ' ' . $product->get_duration_unit(), $start_date );
		}

		// No past dates.
		if ( date( 'Ymd', $start_date ) < date( 'Ymd', current_time( 'timestamp' ) ) ) {
			return false;
		}

		// Staff selection validation (same as legacy).
		if ( $product->has_staff() && ! is_numeric( $staff_id ) && !$product->is_staff_assignment_type( 'all' ) && (is_array($staff_id) && count( $staff_id ) > 1) ) {
			return false;
		}

		// Min/max bounds.
		$now      = ( in_array( $product->get_duration_unit(), [ 'minute', 'hour' ] ) )
			? current_time( 'timestamp' )
			: ( 'month' === $product->get_duration_unit()
				? strtotime( 'midnight first day of this month', current_time( 'timestamp' ) )
				: strtotime( 'midnight', current_time( 'timestamp' ) ) );

		$min      = $product->get_min_date_a();
		$max      = $product->get_max_date_a();
		$check_to = strtotime( "+{$max['value']} {$max['unit']}", $now );

		// Monthly duration.
		if ( 'month' === $product->get_duration_unit() ) {
			$end_date = strtotime( "+{$product->get_duration()} {$product->get_duration_unit()}", $start_date );
		}

		if ( strtotime( "midnight +{$min['value']} {$min['unit']}", current_time( 'timestamp' ) ) > $end_date || $start_date > $check_to ) {
			return false;
		}

		// Intervals for time-based computations.
		$intervals = $product->get_intervals();

		// Staff fan-out path.
		if ( $product->has_staff() && ! $staff_id ) {
			$staff_ids = (array) $product->get_staff_ids();
			$result    = [];

			foreach ( $staff_ids as $sid ) {
				$slots = self::get_cached_slots_in_range( $product, $start_date, $end_date, $intervals, (int) $sid, [], false, false );
				if ( empty( $slots ) ) {
					continue;
				}

				$slot_data = self::get_cached_time_slots(
				    $product,
				    [
						'slots'            => $slots,
						'intervals'        => $intervals,
						'staff_id'         => (int) $sid,
						'from'             => $start_date,
						'to'               => $end_date,
						'include_sold_out' => false,
					],
				);

				// Count slots that can satisfy the requested qty for this staff.
				$count = 0;
				foreach ( (array) $slot_data as $data ) {
					$available_for_staff = 0;
					if ( isset( $data['staff'][ $sid ] ) ) {
						$available_for_staff = (int) $data['staff'][ $sid ];
					} elseif ( isset( $data['available'] ) && ! $product->has_staff() ) {
						$available_for_staff = (int) $data['available'];
					}
					if ( $available_for_staff >= $qty ) {
						$count++;
					}
				}

				if ( 0 < $count ) {
					$result[ (int) $sid ] = $count;
				}
			}

			self::dbg_log( __FUNCTION__ . ' product=' . (int) $product->get_id() . ' fanout_staffs=' . count( $staff_ids ) . ' took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );
			return [] === $result ? false : $result;
		}

		// Single staff or no-staff path.
		$slots = self::get_cached_slots_in_range(
		    $product,
		    $start_date,
		    $end_date,
		    $intervals,
		    ( null === $staff_id ? 0 : ( is_array( $staff_id ) ? (int) reset( $staff_id ) : (int) $staff_id ) ),
		    [],
		    false,
		    false,
		);

		#error_log( 'Slots in range: ' . print_r( $slots, true ) );

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

		$slot_data = self::get_cached_time_slots(
		    $product,
		    [
				'slots'            => $slots,
				'intervals'        => $intervals,
				'staff_id'         => ( null === $staff_id ? 0 : ( is_array( $staff_id ) ? (int) reset( $staff_id ) : (int) $staff_id ) ),
				'from'             => $start_date,
				'to'               => $end_date,
				'include_sold_out' => false,
			],
		);

		#error_log( 'Slot data: ' . print_r( $slot_data, true ) );

		$total = 0;
		foreach ( (array) $slot_data as $data ) {
			// If specific staff was requested, honor per-staff capacity; else use aggregated available.
			if ( $product->has_staff() && is_numeric( $staff_id ) ) {
				$cap = (int) ( $data['staff'][ (int) $staff_id ] ?? 0 );
				if ( $cap >= $qty ) {
					$total++;
				}
			} else {
				$cap = (int) ( $data['available'] ?? 0 );
				if ( $cap >= $qty ) {
					$total++;
				}
			}
		}

		self::dbg_log( __FUNCTION__ . ' product=' . (int) $product->get_id() . ' total=' . $total . ' took=' . number_format( ( microtime( true ) - $__t0 ) * 1000, 2 ) . 'ms' );
		return 0 < $total ? $total : false;
	}

	/**
	 * Finds existing appointments for a product and its tied staff.
	 *
	 * @param  WC_Product_Appointment|int $appointable_product
	 * @param  int                        $min_date
	 * @param  int                        $max_date
	 * @return array
	 */
	public static function get_all_existing_appointments( $appointable_product, $min_date = 0, $max_date = 0, $staff_ids = [] ) {
		wc_deprecated_function( __METHOD__, '4.7.0', 'WC_Appointment_Data_Store::get_all_existing_appointments()' );
		return WC_Appointment_Data_Store::get_all_existing_appointments( $appointable_product, $min_date, $max_date, $staff_ids );
	}

	/**
	 * Return all appointments for a product and/or staff in a given range
	 * @param integer $start_date
	 * @param integer $end_date
	 * @param integer $product_id
	 * @param integer $staff_id
	 * @param bool    $check_in_cart
	 *
	 * @return array
	 */
	public static function get_appointments_in_date_range( $start_date, $end_date, $product_id = 0, $staff_id = 0, $check_in_cart = true, $filters = [], $strict = false ) {
		wc_deprecated_function( __METHOD__, '4.7.0', 'WC_Appointment_Data_Store::get_appointments_in_date_range()' );
		return WC_Appointment_Data_Store::get_appointments_in_date_range( $start_date, $end_date, $product_id, $staff_id, $check_in_cart, $filters, $strict );
	}

	/**
	 * Return all appointments and blocked availability for a product and/or staff in a given range.
	 *
	 * @since 4.4.0
	 *
	 * @param integer $start_date
	 * @param integer $end_date
	 * @param integer $product_id
	 * @param integer $staff_id
	 * @param bool    $check_in_cart
	 *
	 * @return array
	 */
	public static function get_events_in_date_range( $start_date, $end_date, $product_id = 0, $staff_id = 0, $check_in_cart = true, $filters = [] ) {
		wc_deprecated_function( __METHOD__, '4.7.0', 'WC_Appointments_Availability_Data_Store::get_events_in_date_range()' );
		return WC_Appointments_Availability_Data_Store::get_events_in_date_range( $start_date, $end_date, $product_id, $staff_id, $check_in_cart, $filters );
	}

	/**
	 * Return an array global_availability_rules
	 *
	 * @since 4.4.0
	 *
	 * @param  int   $start_date
	 * @param  int . $end_date
	 *
	 * @return array Global availability rules
	 */
	public static function get_global_availability_in_date_range( $start_date, $end_date ) {
		wc_deprecated_function( __METHOD__, '4.7.0', 'WC_Appointments_Availability_Data_Store::get_global_availability_in_date_range()' );
		return WC_Appointments_Availability_Data_Store::get_global_availability_in_date_range( $start_date, $end_date );
	}

	/**
	 * Gets appointments for product ids and staff ids
	 * @param  array  $ids
	 * @param  array  $status
	 * @return array of WC_Appointment objects
	 */
	public static function get_appointments_for_objects( $product_ids = [], $staff_ids = [], $status = [], $date_from = 0, $date_to = 0 ) {
		wc_deprecated_function( __METHOD__, '4.7.0', 'WC_Appointment_Data_Store::get_appointments_for_objects()' );
		return WC_Appointment_Data_Store::get_appointments_for_objects( $product_ids, $staff_ids, $status, $date_from, $date_to );
	}

	/**
	 * Gets appointments for product ids and staff ids
	 * @param  array  $ids
	 * @param  array  $status
	 * @param  integer  $date_from
	 * @param  integer  $date_to
	 * @return array of WC_Appointment objects
	 */
	public static function get_appointments_for_objects_query( $product_ids, $staff_ids, $status, $date_from = 0, $date_to = 0 ) {
		wc_deprecated_function( __METHOD__, '4.7.0', 'WC_Appointment_Data_Store::get_appointments_for_objects_query()' );
		return WC_Appointment_Data_Store::get_appointments_for_objects_query( $product_ids, $staff_ids, $status, $date_from, $date_to );
	}

	/**
	 * Gets appointments for a product by ID
	 *
	 * @param int $product_id The id of the product that we want appointments for
	 * @return array of WC_Appointment objects
	 */
	public static function get_appointments_for_product( $product_id, $status = [ WC_Appointments_Constants::STATUS_CONFIRMED, WC_Appointments_Constants::STATUS_PAID ] ) {
		wc_deprecated_function( __METHOD__, '4.7.0', 'WC_Appointment_Data_Store::get_appointments_for_product()' );
		return WC_Appointment_Data_Store::get_appointments_for_product( $product_id, $status );
	}

	/**
	 * Gets appointments for a user by ID
	 *
	 * @param  int   $user_id    The id of the user that we want appointments for
	 * @param  array $query_args The query arguments used to get appointment IDs
	 * @return array             Array of WC_Appointment objects
	 */
	public static function get_appointments_for_user( $user_id, $query_args = null ) {
		wc_deprecated_function( __METHOD__, '4.7.0', 'WC_Appointment_Data_Store::get_appointments_for_user()' );
		return WC_Appointment_Data_Store::get_appointments_for_user( $user_id, $query_args );
	}

	/**
	 * Gets appointments for a customer by ID
	 *
	 * @deprecated 2.4.9
	 * @deprecated Use get_appointments()
	 * @see get_appointments()
	 *
	 * @param  int   $customer_id    The id of the customer that we want appointments for
	 * @return array                 Array of WC_Appointment objects
	 */
	public static function get_appointments_for_customer( $customer_id ): array {
		wc_deprecated_function( __METHOD__, '2.4.9' );
		$appointment_ids = WC_Appointment_Data_Store::get_appointment_ids_by(
		    [
				'status'      => get_wc_appointment_statuses( 'customer' ),
				'object_id'   => $customer_id,
				'object_type' => 'customer',
			],
		);

		return array_map( 'get_wc_appointment', $appointment_ids );
	}

	/**
	 * Gets appointments for a staff
	 *
	 * @param  int $staff_id ID
	 * @param  array  $status
	 * @return array of WC_Appointment objects
	 */
	public static function get_appointments_for_staff( $staff_id, $status = [ WC_Appointments_Constants::STATUS_CONFIRMED, WC_Appointments_Constants::STATUS_PAID ] ): array {
		wc_deprecated_function( __METHOD__, '4.2.0' );
		$appointment_ids = WC_Appointment_Data_Store::get_appointment_ids_by(
		    [
				'object_id'   => $staff_id,
				'object_type' => 'staff',
				'status'      => $status,
			],
		);
		return array_map( 'get_wc_appointment', $appointment_ids );
	}

	/**
	 * Loop through given appointments to find those that are on or over lap the given date.
	 *
	 * @since 2.3.1
	 * @param  array $appointments
	 * @param  string $date
	 *
	 * @return array of appointment ids
	 */
	public static function filter_appointments_on_date( $appointments, $date ): array {
		wc_deprecated_function( __METHOD__, '4.2.0' );
		$appointments_on_date = [];
		foreach ( $appointments as $appointment ) {
			// Does the date we want to check fall on one of the days in the appointment?
			if ( $appointment->get_start() <= $date && $appointment->get_end() >= $date ) {
				$appointments_on_date[] = $appointment->get_qty();
			}
		}
		return $appointments_on_date;
	}
}
