<?php

use Automattic\WooCommerce\Bookings\Vendor\RRule\RSet;

/**
 * Class that parses and returns rules for bookable products.
 */
class WC_Product_Booking_Rule_Manager {

	/**
	 * Get a range and put value inside each day
	 *
	 * @param  string $from
	 * @param  string $to
	 * @param  mixed  $value
	 * @return array
	 */
	private static function get_custom_range( $from, $to, $value ) {
		$availability = array();
		$from_date    = strtotime( $from );
		$to_date      = strtotime( $to );

		if ( empty( $to ) || empty( $from ) || $to_date < $from_date ) {
			return;
		}
		// We have at least 1 day, even if from_date == to_date
		$number_of_days = 1 + ( $to_date - $from_date ) / 60 / 60 / 24;

		for ( $i = 0; $i < $number_of_days; $i ++ ) {
			$year  = date( 'Y', strtotime( "+{$i} days", $from_date ) );
			$month = date( 'n', strtotime( "+{$i} days", $from_date ) );
			$day   = date( 'j', strtotime( "+{$i} days", $from_date ) );

			$availability[ $year ][ $month ][ $day ] = $value;
		}

		return $availability;
	}

	/**
	 * Get a range and put value inside each day
	 *
	 * Generates availability data where time range starts on first date on the beginning
	 * time and ends on the last date at the end time.
	 *
	 * @since 1.13.0
	 *
	 * @param  string $from
	 * @param  string $to
	 * @param  mixed  $value
	 * @return array
	 */
	private static function get_custom_datetime_range( $from_day, $to_day, $time_range ) {
		$availability = array();
		$from_date    = strtotime( $from_day );
		$to_date      = strtotime( $to_day );

		if ( empty( $to_day ) || empty( $from_day ) || $to_date < $from_date ) {
			return;
		}
		// We have at least 1 day, even if from_date == to_date
		$number_of_days = 1 + ( $to_date - $from_date ) / 60 / 60 / 24;

		for ( $i = 0; $i < $number_of_days; $i ++ ) {
			$year  = date( 'Y', strtotime( "+{$i} days", $from_date ) );
			$month = date( 'n', strtotime( "+{$i} days", $from_date ) );
			$day   = date( 'j', strtotime( "+{$i} days", $from_date ) );

			// First day starts at start time, other days start at midnight.
			if ( 0 === $i ) {
				$start = $time_range['from'];
			} else {
				$start = '00:00';
			}

			// Last day ends at end time, other days end at midnight.
			if ( $number_of_days - 1 === $i ) {
				$end = $time_range['to'];
			} else {
				$end = '24:00';
			}

			$time_range_for_day = array(
				'from' => $start,
				'to'   => $end,
				'rule' => $time_range['rule'],
			);

			$availability[ $year ][ $month ][ $day ] = $time_range_for_day;
		}

		return $availability;
	}

	/**
	 * Get a range and put value inside each day
	 *
	 * @param  string $from
	 * @param  string $to
	 * @param  mixed  $value
	 * @return array
	 */
	private static function get_months_range( $from, $to, $value ) {
		$months = array();
		$diff   = $to - $from;
		$diff   = ( $diff < 0 ) ? 12 + $diff : $diff;
		$month  = $from;

		for ( $i = 0; $i <= $diff; $i ++ ) {
			$months[ $month ] = $value;

			$month ++;

			if ( $month > 52 ) {
				$month = 1;
			}
		}

		return $months;
	}

	/**
	 * Get a range and put value inside each day
	 *
	 * @param  string $from
	 * @param  string $to
	 * @param  mixed  $value
	 * @return array
	 */
	private static function get_weeks_range( $from, $to, $value ) {
		$weeks = array();
		$diff  = $to - $from;
		$diff  = ( $diff < 0 ) ? 52 + $diff : $diff;
		$week  = $from;

		for ( $i = 0; $i <= $diff; $i ++ ) {
			$weeks[ $week ] = $value;

			$week ++;

			if ( $week > 52 ) {
				$week = 1;
			}
		}

		return $weeks;
	}

	/**
	 * Get a range and put value inside each day
	 *
	 * @param  string $from
	 * @param  string $to
	 * @param  mixed  $value
	 * @return array
	 */
	private static function get_days_range( $from, $to, $value ) {
		$day_of_week = $from;
		$diff        = $to - $from;
		$diff        = ( $diff < 0 ) ? 7 + $diff : $diff;
		$days        = array();

		for ( $i = 0; $i <= $diff; $i ++ ) {
			$days[ $day_of_week ] = $value;

			$day_of_week ++;

			if ( $day_of_week > 7 ) {
				$day_of_week = 1;
			}
		}

		return $days;
	}

	/**
	 * Get a range and put value inside each day
	 *
	 * @param  string $from
	 * @param  string $to
	 * @param  mixed  $value
	 * @return array
	 */
	private static function get_time_range( $from, $to, $value, $day = 0 ) {
		return array(
			'from' => $from,
			'to'   => $to,
			'rule' => $value,
			'day'  => $day,
		);
	}

	/**
	 * Get a time range for a set of custom dates.
	 *
	 * Generates availability data where time range is repeated for each day in range.
	 *
	 * @param  string $from_date
	 * @param  string $to_date
	 * @param  string $from_time
	 * @param  string $to_time
	 * @param  mixed  $value
	 * @return array
	 */
	private static function get_time_range_for_custom_date( $from_date, $to_date, $from_time, $to_time, $value ) {
		$time_range = array(
			'from' => $from_time,
			'to'   => $to_time,
			'rule' => $value,
		);
		return self::get_custom_range( $from_date, $to_date, $time_range );
	}

	/**
	 * Get a time range for a set of custom dates.
	 *
	 * Generates availability data where time range starts on first date on the beginning
	 * time and ends on the last date at the end time.
	 *
	 * @since 1.13.0
	 *
	 * @param  string $from_date
	 * @param  string $to_date
	 * @param  string $from_time
	 * @param  string $to_time
	 * @param  mixed  $value
	 * @return array
	 */
	private static function get_time_range_for_custom_datetime( $from_date, $to_date, $from_time, $to_time, $value ) {
		$time_range = array(
			'from' => $from_time,
			'to'   => $to_time,
			'rule' => $value,
		);
		return self::get_custom_datetime_range( $from_date, $to_date, $time_range );
	}

	/**
	 * Get duration range
	 *
	 * @param  [type] $from
	 * @param  [type] $to
	 * @param  [type] $value
	 * @return [type]
	 */
	private static function get_duration_range( $from, $to, $value ) {
		return array(
			'from' => $from,
			'to'   => $to,
			'rule' => $value,
		);
	}

	/**
	 * Get Persons range
	 *
	 * @param  [type] $from
	 * @param  [type] $to
	 * @param  [type] $value
	 * @return [type]
	 */
	private static function get_persons_range( $from, $to, $value ) {
		return array(
			'from' => $from,
			'to'   => $to,
			'rule' => $value,
		);
	}

	/**
	 * Get blocks range
	 *
	 * @param  [type] $from
	 * @param  [type] $to
	 * @param  [type] $value
	 * @return [type]
	 */
	private static function get_blocks_range( $from, $to, $value ) {
		return array(
			'from' => $from,
			'to'   => $to,
			'rule' => $value,
		);
	}

	private static function get_rrule_range( $from, $to, $rrule, $value ) {
		return array(
			'from'  => $from,
			'to'    => $to,
			'rule'  => $value,
			'rrule' => $rrule,
		);
	}

	/**
	 * Process and return formatted cost rules.
	 *
	 * @param  $rules array
	 * @return array
	 */
	public static function process_cost_rules( $rules ) {
		$costs = array();
		$index = 1;
		// Go through rules
		foreach ( $rules as $key => $fields ) {
			if ( empty( $fields['cost'] ) && empty( $fields['base_cost'] ) ) {
				continue;
			}

			$cost          = apply_filters( 'woocommerce_bookings_process_cost_rules_cost', $fields['cost'], $fields, $key );
			$modifier      = $fields['modifier'];
			$base_cost     = apply_filters( 'woocommerce_bookings_process_cost_rules_base_cost', $fields['base_cost'], $fields, $key );
			$base_modifier = $fields['base_modifier'];

			$cost_array = array(
				'base'  => array( $base_modifier, $base_cost ),
				'block' => array( $modifier, $cost ),
			);

			// Updating end time 00:00 to 24:00, so that user can book slots till next midnight.
			if ( isset( $fields['to'] ) && '00:00' === $fields['to'] ) {
				$fields['to'] = '24:00';
			}

			$type_function = self::get_type_function( $fields['type'] );
			if ( 'get_time_range_for_custom_date' === $type_function ) {
				$type_costs = self::$type_function( $fields['from_date'], $fields['to_date'], $fields['from'], $fields['to'], $cost_array );
			} else {
				$type_costs = self::$type_function( $fields['from'], $fields['to'], $cost_array );
			}

			// Ensure day gets specified for time: rules
			if ( strrpos( $fields['type'], 'time:' ) === 0 && 'time:range' !== $fields['type'] ) {
				list( , $day )     = explode( ':', $fields['type'] );
				$type_costs['day'] = absint( $day );
			}

			if ( $type_costs ) {
				$costs[ $index ] = array( $fields['type'], $type_costs );
				$index ++;
			}
		}

		return $costs;
	}

	/**
	 * Returns a function name (for this class) that returns our time or date range
	 *
	 * @param  string $type rule type
	 * @return string       function name
	 */
	public static function get_type_function( $type ) {
		if ( 'time:range' === $type ) {
			return 'get_time_range_for_custom_date';
		}
		if ( 'custom:daterange' === $type ) {
			return 'get_time_range_for_custom_datetime';
		}
		return strrpos( $type, 'time:' ) === 0 ? 'get_time_range' : 'get_' . $type . '_range';
	}

	/**
	 * Split the store availability rule into one or more classic availability rules.
	 *
	 * @param WC_Global_Availability $rule Current Store availability rule.
	 * @param string                 $level Rule level, 'global', 'product', or 'resource.
	 * @param bool                   $hide_past Hide Past rules.
	 *
	 * @return array
	 */
	private static function process_store_availability_rule( WC_Global_Availability $rule, $level, $hide_past ) {

		$rules       = array();
		$start_times = array_filter( explode( ',', $rule->get_from_range() ) );
		$end_times   = array_filter( explode( ',', $rule->get_to_range() ) );

		$unbookable_start_times = array( '00:00' );
		$unbookable_end_times   = array();

		$rrule    = $rule->get_rrule();
		$bookable = $rule->get_bookable();
		if ( $start_times ) {
			foreach ( $start_times as $key => $start_time ) {
				$end_time = $end_times[ $key ];

				$unbookable_end_times[]   = $start_time;
				$unbookable_start_times[] = $end_time;
				if ( $rrule ) {
					$rules[] = array(
						'from'     => $rule->get_from_date() . ' ' . $start_time,
						'to'       => $rule->get_to_date() . ' ' . $end_time,
						'type'     => 'rrule',
						'rrule'    => $rrule,
						'bookable' => $bookable,
					);
				} else {
					$rules[] = array(
						'from_date' => $rule->get_from_date(),
						'to_date'   => $rule->get_to_date(),
						'from'      => $start_time,
						'to'        => $end_time,
						'type'      => 'time:range',
						'bookable'  => $bookable,
					);
				}
			}
			$unbookable_end_times[] = '23:59';

			foreach ( $unbookable_start_times as $key => $start_time ) {
				$end_time = $unbookable_end_times[ $key ];
				if ( $rrule ) {
					$rules[] = array(
						'from'     => $rule->get_from_date() . ' ' . $start_time,
						'to'       => $rule->get_to_date() . ' ' . $end_time,
						'type'     => 'rrule',
						'rrule'    => $rrule,
						'bookable' => 'yes' === $bookable ? 'no' : 'yes',
					);
				} else {
					$rules[] = array(
						'from_date' => $rule->get_from_date(),
						'to_date'   => $rule->get_to_date(),
						'from'      => $start_time,
						'to'        => $end_time,
						'type'      => 'time:range',
						'bookable'  => 'yes' === $bookable ? 'no' : 'yes',
					);
				}
			}
		} else {
			if ( $rrule ) {
				$rules[] = array(
					'from'     => $rule->get_from_date(),
					'to'       => $rule->get_to_date(),
					'type'     => 'rrule',
					'rrule'    => $rrule,
					'bookable' => $bookable,
				);
			} else {
				$rules[] = array(
					'from'     => $rule->get_from_date(),
					'to'       => $rule->get_to_date(),
					'type'     => 'custom',
					'bookable' => $bookable,
				);
			}
		}

		return static::process_availability_rules( $rules, $level, $hide_past );
	}

	/**
	 * Process and return formatted availability rules
	 *
	 * @version 1.10.7
	 * @param   array  $rules Rules to process.
	 * @param   string $level Resource, Product or Globally.
	 * @return  array
	 */
	public static function process_availability_rules( $rules, $level, $hide_past = true ) {
		$processed_rules = array();

		if ( empty( $rules ) ) {
			return $processed_rules;
		}

		// Go through rules.
		foreach ( $rules as $order_on_product => $fields ) {
			if ( empty( $fields['bookable'] ) ) {
				continue;
			}

			// Do not include dates that are in the past.

			if ( $hide_past && ( in_array( $fields['type'], array( 'custom', 'time:range' ), true ) ) ) {
				$to_date = ! empty( $fields['to_date'] ) ? $fields['to_date'] : $fields['to'];
				if ( strtotime( $to_date ) < strtotime( 'midnight -1 day' ) ) {
					continue;
				}
			}

			$type_function = self::get_type_function( $fields['type'] );
			$bookable      = 'yes' === $fields['bookable'] ? true : false;

			// Updating end time 00:00 to 24:00, so that user can book slots till next midnight.
			if ( isset( $fields['to'] ) && '00:00' === $fields['to'] ) {
				$fields['to'] = '24:00';
			}

			if ( defined( 'WC_BOOKINGS_ENABLE_STORE_AVAILABILITY_CALENDAR' ) && WC_BOOKINGS_ENABLE_STORE_AVAILABILITY_CALENDAR && ( 'store_availability' === $fields['type'] ) ) {
				$type_availability = false;
			} elseif ( 'get_rrule_range' === $type_function ) {
				$type_availability = self::$type_function( $fields['from'], $fields['to'], $fields['rrule'], $bookable );
			} elseif ( in_array( $type_function, array( 'get_time_range_for_custom_date', 'get_time_range_for_custom_datetime' ) ) ) {
				$type_availability = self::$type_function( $fields['from_date'], $fields['to_date'], $fields['from'], $fields['to'], $bookable );
			} else {
				$type_availability = self::$type_function( $fields['from'], $fields['to'], $bookable );
			}

			$priority = intval( ( isset( $fields['priority'] ) ? $fields['priority'] : 10 ) );

			// Ensure day gets specified for time: rules.
			if ( strrpos( $fields['type'], 'time:' ) === 0 && 'time:range' !== $fields['type'] ) {
				list( , $day )            = explode( ':', $fields['type'] );
				$type_availability['day'] = absint( $day );
			}

			if ( ! empty( $type_availability ) ) {
				$processed_rule = array(
					'type'     => $fields['type'],
					'range'    => $type_availability,
					'priority' => $priority,
					'level'    => $level,
					'order'    => $order_on_product,
				);

				if ( 'resource' === $level && ! empty( $fields['resource_id'] ) ) {
					$processed_rule['resource_id'] = $fields['resource_id'];
				}
				$processed_rules[] = $processed_rule;
			} elseif ( defined( 'WC_BOOKINGS_ENABLE_STORE_AVAILABILITY_CALENDAR' ) && WC_BOOKINGS_ENABLE_STORE_AVAILABILITY_CALENDAR && ( 'store_availability' === $fields['type'] ) ) {
				$rule            = new WC_Global_Availability( $fields['ID'] );
				$processed_rules = array_merge( $processed_rules, static::process_store_availability_rule( $rule, $level, $hide_past ) );
			}
		}

		return $processed_rules;
	}

	/**
	 * Get the minutes that should be available based on the rules and the date to check.
	 *
	 * The minutes are returned in a range from the start to increment minutes right up to the last available minute.
	 *
	 * This function expects the rules to be ordered in the sequence that is should be processed. Later rule minutes
	 * will override prior rule minutes in the order given.
	 *
	 * @since 1.9.14 moved from WC_Product_Booking.
	 *
	 * @param array $rules
	 * @param int $check_date
	 * @param array $bookable_minutes
	 *
	 * @return array $bookable_minutes
	 */
	public static function get_minutes_from_rules( $rules, $check_date, $bookable_minutes = array() ) {
		$resource_minutes = array();

		foreach ( $rules as $rule ) {
			// Something terribly wrong if a rule has no level.
			if ( ! isset( $rule['level'] ) ) {
				continue;
			}

			$data_for_rule = self::get_rule_minute_range( $rule, $check_date );

			// split up the rules on a resource level to be dealt with independently
			// after the rules loop. This ensure resource do not affect one another.
			if ( 'resource' === $rule['level'] ) {
				$resource_id      = $rule['resource_id'];
				$availability_key = $data_for_rule['is_bookable'] ? 'bookable' : 'not_bookable';
				// adding minutes in the order of the rules received, higher index higher override power.
				$resource_minutes[ $resource_id ][] = array( $availability_key => $data_for_rule['minutes'] );
				continue;
			}

			// At this point we assume all resource rules have been processed as they have a lower
			// override order in the $rules given.
			// Remove available resource minutes if being overridden at the product or global level.
			if ( ! self::check_timestamp_against_rule( $check_date, $rule, true ) ) {
				$resource_minutes = array();
			}

			if ( $data_for_rule['is_bookable'] ) {
				// If this time range is bookable, add to bookable minutes.
				$bookable_minutes = array_merge( $bookable_minutes, $data_for_rule['minutes'] );
				continue;
			}

			// Handle NON-resource removal of unavailable minutes.
			$bookable_minutes = array_diff( $bookable_minutes, $data_for_rule['minutes'] );

			// Handle resource specific removal of unavailable minutes.
			foreach ( $resource_minutes as $id => $minute_ranges ) {
				foreach ( $minute_ranges as $index => $minute_range ) {
					if ( ! isset( $minute_range['bookable'] ) || empty( $data_for_rule['minutes'] ) ) {
						continue;
					}
					$resource_minutes[ $id ][ $index ]['bookable'] = array_diff( $minute_range['bookable'], $data_for_rule['minutes'] );
				}
			}
		}

		// One resource should not override the other, when automatically assigned: as long as one is available.
		foreach ( $resource_minutes as $resource_id => $minutes_for_rule_order ) {
			$resource_minutes = array();

			foreach ( $minutes_for_rule_order as $rule_minutes_with_availability ) {
				$is_bookable = isset( $rule_minutes_with_availability['bookable'] );
				if ( $is_bookable ) {
					$resource_minutes = array_merge( $resource_minutes, $rule_minutes_with_availability['bookable'] );
				} else {
					$resource_minutes = array_diff( $resource_minutes, $rule_minutes_with_availability['not_bookable'] );
				}
			}

			$bookable_minutes = array_merge( $resource_minutes, $bookable_minutes );
		}

		$bookable_minutes = array_unique( array_values( $bookable_minutes ) );

		sort( $bookable_minutes );
		return $bookable_minutes;
	}

	/**
	 * This function is a mediator that simplifies the creation of
	 * a data object representing the range of rules minutes and the property of bookable or not.
	 *
	 * @since 1.10.10
	 *
	 * @param array $rule
	 * @param int   $check_date
	 *
	 * @return array $minute_range
	 */
	public static function get_rule_minute_range( $rule, $check_date ) {
		$minute_range = array(
			'is_bookable' => false,
			'minutes'     => array(),
		);

		if ( ( strpos( $rule['type'], 'time' ) > -1 ) || ( 'custom:daterange' === $rule['type'] ) ) {
			$minute_range = self::get_rule_minutes_for_time( $rule, $check_date );
		} elseif ( 'days' === $rule['type'] ) {
			$minute_range = self::get_rule_minutes_for_days( $rule, $check_date );
		} elseif ( 'weeks' === $rule['type'] ) {
			$minute_range = self::get_rule_minutes_for_weeks( $rule, $check_date );
		} elseif ( 'months' === $rule['type'] ) {
			$minute_range = self::get_rule_minutes_for_months( $rule, $check_date );
		} elseif ( 'custom' === $rule['type'] ) {
			$minute_range = self::get_rule_minutes_for_custom( $rule, $check_date );
		} elseif ( 'rrule' === $rule['type'] ) {
			$minute_range = self::get_rule_minutes_for_rrule( $rule, $check_date );
		}

		return $minute_range;
	}

	/**
	 * Calculate minutes range.
	 *
	 * @since 1.13.0
	 * @param $from
	 * @param $to
	 *
	 * @return array
	 */
	protected static function calculate_minute_range( $from, $to ) {
		$from_hour = absint( date( 'H', strtotime( $from ) ) );
		$from_min  = absint( date( 'i', strtotime( $from ) ) );
		$to_hour   = absint( date( 'H', strtotime( $to ) ) );
		$to_min    = absint( date( 'i', strtotime( $to ) ) );

		// If "to" is set to midnight, it is safe to assume they mean the end of the day
		// php wraps 24 hours to "12AM the next day"
		if ( 0 === $to_hour && 0 === $to_min ) {
			$to_hour = 24;
		}

		$minute_range = array( ( $from_hour * 60 ) + $from_min, ( $to_hour * 60 ) + $to_min );
		$merge_ranges = array();
		$minutes      = array();

		// if first time in range is larger than second, we
		// assume they want to go over midnight
		if ( $minute_range[0] > $minute_range[1] ) {
			$merge_ranges[] = array( $minute_range[0], 1440 );
			// fix for https://github.com/woothemes/woocommerce-bookings/issues/710
			$merge_ranges[] = array( $minute_range[0], ( 1440 + $minute_range[1] ) );
		} else {
			$merge_ranges[] = array( $minute_range[0], $minute_range[1] );
		}

		foreach ( $merge_ranges as $range ) {
			// Add ranges to minutes this rule affects.
			$minutes = array_merge( $minutes, range( $range[0], $range[1] ) );
		}

		return $minutes;
	}

	/**
	 * Get minutes from rules for a time rule type.
	 *
	 * @since 1.9.14
	 * @param $rule
	 * @param integer $check_date
	 *
	 * @return array
	 */
	public static function get_rule_minutes_for_time( $rule, $check_date ) {
		$minutes = array(
			'is_bookable' => false,
			'minutes'     => array(),
		);

		$type    = $rule['type'];
		$range   = $rule['range'];

		$year        = date( 'Y', $check_date );
		$month       = date( 'n', $check_date );
		$day         = date( 'j', $check_date );
		$day_of_week = date( 'N', $check_date );

		// Updating end time 00:00 to 24:00, so that user can book slots till next midnight.
		if ( isset( $range['to'] ) && '00:00' === $range['to'] ) {
			$range['to'] = '24:00';
		}

		if ( in_array( $type, array( 'time:range', 'custom:daterange' ) ) ) { // type: date range with time
			if ( ! isset( $range[ $year ][ $month ][ $day ] ) ) {
				return $minutes;
			} else {
				$range = $range[ $year ][ $month ][ $day ];
			}

			$from                   = $range['from'];
			$to                     = $range['to'];
			$minutes['is_bookable'] = $range['rule'];
		} elseif ( strpos( $rule['type'], 'time:' ) > -1 ) { // type: single week day with time
			if ( $day_of_week != $range['day'] ) {
				return $minutes;
			}

			$from                   = $range['from'];
			$to                     = $range['to'];
			$minutes['is_bookable'] = $range['rule'];
		} else {  // type: time all week per day
			$from                   = $range['from'];
			$to                     = $range['to'];
			$minutes['is_bookable'] = $range['rule'];
		}

		$calculated_minutes_range = self::calculate_minute_range( $from, $to );

		/**
		 * Consider an example of a clinic which is open from 11:00am to 4:00pm which offers 30 minute slots
		 * and also 1:00pm to 2:00pm is already booked.
		 *
		 * 11:00am = 11*60 = 660 (opening time)
		 * 04:00pm = 16*60 = 960 (closing time)
		 * 01:00pm = 13*60 = 780 (start of an existing booking)
		 * 02:00pm = 14*60 = 840 (end of an existing booking)
		 *
		 * @see https://github.com/woocommerce/woocommerce-bookings/blob/trunk/includes/class-wc-product-booking-rule-manager.php#L571
		 * The output of array_diff removes 780 and 840 from the range [660-960] which causes this issue:
		 * @see https://github.com/woocommerce/woocommerce-bookings/issues/2383.
		 * We need the 780 and 840 to be a part of bookable time array.
		 * What the below fix does is set the unbookable range as 1:01pm - 1:59pm (781 - 839).
		 * This is only for unbookable range, when the is_bookable is set to `false`.
		 */
		$minutes['overlapping_start_time'] = false;
		$minutes['overlapping_end_time']   = false;

		/**
		 * The front end pages usually only display the bookable range, but the
		 * admin pages like Bookings > Calendar display the unbookable range as well.
		 * And, since we are removing the first and the last minute from the unbookable range,
		 * it will display the time incorrectly. This is why we will also return these 2 removed
		 * values `overlapping_start_time` and `overlapping_end_time` with this method so that
		 * the unbookable range can be displayed correctly.
		 *
		 * @todo Since there are multiple PRs opened to fix the related issues: #2798, #2649 and #3160,
		 * concurrently, this fix is done here. Once tested and merged, we might want to create a
		 * separate utility function which takes care of this.
		 */

		if ( ! $minutes['is_bookable'] ) {
			$minutes['overlapping_start_time'] = array_shift( $calculated_minutes_range );
			$minutes['overlapping_end_time']   = array_pop( $calculated_minutes_range );
		}

		$minutes['minutes'] = $calculated_minutes_range;

		return $minutes;
	}

	/**
	 * Get minutes from rules for a 'rrule' rule type.
	 *
	 * @since 1.13.0
	 * @param $rule
	 * @param integer $check_date
	 *
	 * @return array
	 */
	public static function get_rule_minutes_for_rrule( $rule, $check_date ) {
		$start       = new WC_DateTime( $rule['range']['from'] );
		$end         = new WC_DateTime( $rule['range']['to'] );
		$is_all_day  = false === strpos( $rule['range']['from'], ':' );
		$date_format = $is_all_day ? 'Y-m-d' : 'Y-m-d g:i A';

		$minutes = array(
			'is_bookable' => false,
			'minutes'     => array(),
		);

		try {
			$rset = new RSet( $rule['range']['rrule'], $is_all_day ? $start->format( $date_format ) : $start );
		} catch ( Exception $e ) {
			return $minutes;
		}

		$duration = $start->diff( $end, true );

		$current_date  = ( new DateTime( '@' . $check_date ) )->modify( 'midnight' );
		$tomorrow_date = ( new DateTime( '@' . $check_date ) )->modify( 'tomorrow' );
		// Offset from the server's timezone, back to UTC.
		$current_date->modify( '-' . get_option( 'gmt_offset' ) . ' hours' );
		$tomorrow_date->modify( '-' . get_option( 'gmt_offset' ) . ' hours' );

		// Use a limit since this reccurence can potentially be infinite.
		$occurrences = $rset->getOccurrencesBetween( $current_date, $tomorrow_date );

		foreach ( $occurrences as $occurrence ) {
			if ( date( 'Y-m-d', $check_date ) !== $occurrence->format( 'Y-m-d' ) ) {
				continue;
			}

			// Format to remove timezone since it's already been offseted to the server's timezone.
			$from = $occurrence->format( 'H:i' );
			$to   = $occurrence->add( $duration )->format( 'H:i' );

			$minute_range = self::calculate_minute_range( $from, $to );

			/*
			 * Remove 1 min from start and 1 min from end of the unbookable range to keep those minutes bookable.
			 * Detailed information on this can be found in `get_rule_minutes_for_time` method.
			 * @see https://github.com/woocommerce/woocommerce-bookings/issues/3409
			 */
			$minutes['overlapping_start_time'] = false;
			$minutes['overlapping_end_time']   = false;
			if ( ! $minutes['is_bookable'] ) {
				$minutes['overlapping_start_time'] = array_shift( $minute_range );
				$minutes['overlapping_end_time']   = array_pop( $minute_range );
			}

			$minutes['minutes'] = array_merge( $minutes['minutes'], $minute_range );

		}

		return $minutes;
	}

	/**
	 * Get minutes from rules for days rule type.
	 *
	 * @since 1.9.14
	 * @param $rule
	 * @param integer $check_date
	 *
	 * @return array
	 */
	public static function get_rule_minutes_for_days( $rule, $check_date ) {
		$_rules      = $rule['range'];
		$minutes     = array();
		$is_bookable = false;
		$day_of_week = intval( date( 'N', $check_date ) );

		if ( isset( $_rules[ $day_of_week ] ) ) {
			$minutes     = range( 0, 1440 );
			$is_bookable = $_rules[ $day_of_week ];
		}

		return array(
			'is_bookable' => $is_bookable,
			'minutes'     => $minutes,
		);
	}

	/**
	 * Get minutes from rules for a weeks rule type.
	 *
	 * @since 1.9.14
	 * @param $rule
	 * @param integer $check_date
	 *
	 * @return array
	 */
	public static function get_rule_minutes_for_weeks( $rule, $check_date ) {

		$range       = $rule['range'];
		$week_number = intval( date( 'W', $check_date ) );
		$minutes     = array();
		$is_bookable = false;

		if ( isset( $range[ $week_number ] ) ) {
			$minutes     = range( 0, 1440 );
			$is_bookable = $range[ $week_number ];
		}

		return array(
			'is_bookable' => $is_bookable,
			'minutes'     => $minutes,
		);
	}

	/**
	 * Get minutes from rules for a months rule type.
	 *
	 * @since 1.9.14
	 * @param $rule
	 * @param integer $check_date
	 *
	 * @return array
	 */
	public static function get_rule_minutes_for_months( $rule, $check_date ) {

		$range       = $rule['range'];
		$month       = date( 'n', $check_date );
		$minutes     = array();
		$is_bookable = false;
		if ( isset( $range[ $month ] ) ) {
			$minutes     = range( 0, 1440 );
			$is_bookable = $range[ $month ];
		}

		return array(
			'is_bookable' => $is_bookable,
			'minutes'     => $minutes,
		);
	}

	/**
	 * Get minutes from rules for custom rule type.
	 *
	 * @since 1.9.14
	 * @param $rule
	 * @param integer $check_date
	 *
	 * @return array
	 */
	public static function get_rule_minutes_for_custom( $rule, $check_date ) {

		$range = $rule['range'];
		$year  = date( 'Y', $check_date );
		$month = date( 'n', $check_date );
		$day   = date( 'j', $check_date );

		$minutes     = array();
		$is_bookable = false;

		if ( isset( $range[ $year ][ $month ][ $day ] ) ) {
			$minutes     = range( 0, 1440 );
			$is_bookable = $range[ $year ][ $month ][ $day ];
		}

		return array(
			'is_bookable' => $is_bookable,
			'minutes'     => $minutes,
		);
	}

	/**
	 * Sort rules in order of precedence.
	 *
	 * @version 1.9.14 sort order reversed
	 * The order produced will be from the lowest to the highest.
	 * The elements with higher indexes overrides those with lower indexes e.g. `4` overrides `3`
	 * Index corresponds to override power. The higher the element index the higher the override power
	 *
	 * Level    : `global` > `product` > `product` (greater in terms off override power)
	 * Priority : within a level
	 * Order    : Within a priority The lower the order index higher the override power.
	 *
	 * @param array $rule1
	 * @param array $rule2
	 *
	 * @return integer
	 */
	public static function sort_rules_callback( $rule1, $rule2 ) {
		$level_weight = array(
			'resource' => 1,
			'product'  => 3,
			'global'   => 5,
		);

		// The override power goes from the outside inward.
		// Priority is outside which means it has the most weight when sorting.
		// Then level(global, product, resource)
		// Lastly order is applied within the level.
		if ( $rule1['priority'] === $rule2['priority'] ) {
			if ( $level_weight[ $rule1['level'] ] === $level_weight[ $rule2['level'] ] ) {
				// if `order index of 1` < `order index of 2` $rule1 one has a higher override power. So we
				// increase the index for $rule1 which corresponds to override power.
				return ( $rule1['order'] < $rule2['order'] ) ? 1 : -1;
			}

			// if `level of 1` < `level of 2` $rule1 must have lower override power. So we
			// decrease the index for 1 which corresponds to override power.
			return $level_weight[ $rule1['level'] ] < $level_weight[ $rule2['level'] ] ? -1 : 1;
		}

		// if `priority of 1` < `priority of 2` $rule1 must have lower override power. So we
		// decrease the index for 1 which corresponds to override power.
		return $rule1['priority'] < $rule2['priority'] ? 1 : -1;
	}

	/**
	 * Filter out all but time rules.
	 *
	 * @param  array $rule
	 * @return boolean
	 */
	private static function filter_time_rules( $rule ) {
		return ! empty( $rule['type'] ) && ! in_array( $rule['type'], array( 'days', 'custom', 'months', 'weeks' ) );
	}

	/**
	 * Check a bookable product's availability rules against a time range and return if bookable or not.
	 *
	 * @param  WC_Product_Booking $bookable_product
	 * @param  int                $resource_id
	 * @param  int                $start timestamp
	 * @param  int                $end timestamp
	 * @return boolean
	 */
	public static function check_range_availability_rules( $bookable_product, $resource_id, $start, $end ) {
		// This is a time range.
		if ( in_array( $bookable_product->get_duration_unit(), array( 'minute', 'hour' ) ) ) {
			return self::check_availability_rules_against_time( $start, $end, $resource_id, $bookable_product );
		} else {  // Else this is a date range (days).
			$timestamp = $start;

			while ( $timestamp < $end ) {
				if ( ! self::check_availability_rules_against_date( $bookable_product, $resource_id, $timestamp ) ) {
					return false;
				}
				if ( $bookable_product->get_check_start_block_only() ) {
					break; // Only need to check first day.
				}
				$timestamp = strtotime( '+1 day', $timestamp );
			}
		}

		return true;
	}

	/**
	 * Check a time against the time specific availability rules
	 *
	 * @param integer                                                     $slot_start_time
	 * @param integer                                                     $slot_end_time
	 * @param integer                                                     $resource_id
	 * @param WC_Product_Booking                                          $bookable_product
	 * @param bool|null If not null, it will default to the boolean value. If null, it will use product default availability.
	 *
	 * @return bool available or not
	 */
	public static function check_availability_rules_against_time( $slot_start_time, $slot_end_time, $resource_id, $bookable_product, $bookable = null ) {
		$rules = $bookable_product->get_availability_rules( $resource_id );
		if ( is_null( $bookable ) ) {
			$bookable = $bookable_product->get_default_availability();
		}

		if ( empty( $rules ) ) {
			return $bookable;
		}

		$slot_start_time = is_numeric( $slot_start_time ) ? $slot_start_time : strtotime( $slot_start_time );
		$slot_end_time   = is_numeric( $slot_end_time ) ? $slot_end_time : strtotime( $slot_end_time );

		// Get the date values for the slots being checked.
		$slot_year   = intval( date( 'Y', $slot_start_time ) );
		$slot_month  = intval( date( 'n', $slot_start_time ) );
		$slot_date   = intval( date( 'j', $slot_start_time ) );
		$slot_day_no = intval( date( 'N', $slot_start_time ) );
		$slot_week   = intval( date( 'W', $slot_start_time ) );

		// default from and to for the whole day.
		$from = strtotime( 'midnight', $slot_start_time );
		$to   = strtotime( 'midnight + 1 day', $slot_start_time );

		foreach ( $rules as $rule ) {
			$type  = $rule['type'];
			$range = $rule['range'];

			if ( 'rrule' === $type ) {
				/*
				 * If the rule has occurrences between the slot start and end time, use the rule value.
				 * @see https://github.com/woocommerce/woocommerce-bookings/issues/3796
				 *
				 * End time availability is checked from maximum duration to minimum duration.
				 * Therefore, it is necessary to check if rrule has occurrences between slots to keep the slots non-bookable until there are no blocked occurrences in between.
				 * eg: For a product with 1 hour block duration, it will be checked like:
				 *    10:00 AM - 15:00 PM
				 *    10:00 AM - 14:00 PM
				 *    ...
				 *    10:00 AM - 11:00 AM
				 */
				if ( self::rrule_has_occurrences_between_slots( $range, $slot_start_time + 1, $slot_end_time - 1 ) ) {
					$rule_val = $range['rule']; // For google calendar events bookable value will be always "no".
				} elseif ( self::rrule_matches_timestamp( $range, $slot_start_time + 1 ) || self::rrule_matches_timestamp( $range, $slot_end_time - 1 ) ) {
					$rule_val = self::check_timestamp_against_rule( $slot_start_time, $rule, $bookable_product->get_default_availability() );
				} else {
					continue;
				}
			}

			// handling none time specific rules first.
			if ( in_array( $type, array( 'days', 'custom', 'months', 'weeks' ), true ) ) {
				if ( 'days' === $type ) {
					if ( ! isset( $range[ $slot_day_no ] ) ) {
						continue;
					}
				} elseif ( 'custom' === $type ) {
					if ( ! isset( $range[ $slot_year ][ $slot_month ][ $slot_date ] ) ) {
						continue;
					}
				} elseif ( 'months' === $type ) {
					if ( ! isset( $range[ $slot_month ] ) ) {
						continue;
					}
				} elseif ( 'weeks' === $type ) {
					if ( ! isset( $range[ $slot_week ] ) ) {
						continue;
					}
				}

				$rule_val = self::check_timestamp_against_rule( $slot_start_time, $rule, $bookable_product->get_default_availability() );
			}

			// Handling all time specific rules.
			$apply_rule_times = false;
			if ( in_array( $type, array( 'time:range', 'custom:daterange' ) ) ) {
				if ( ! isset( $range[ $slot_year ][ $slot_month ][ $slot_date ] ) ) {
					continue;
				}
				$time_range_rule  = $range[ $slot_year ][ $slot_month ][ $slot_date ];
				$rule_val         = $time_range_rule['rule'];
				$from             = $time_range_rule['from'];
				$to               = $time_range_rule['to'];
				$apply_rule_times = true;
			} elseif ( false !== strpos( $type, 'time' ) ) {
				// if the day doesn't match and the day is not zero skip the rule
				// zero means all days. SO rule only apply for zero or a matching day.
				if ( ! empty( $range['day'] ) && $slot_day_no !== intval( $range['day'] ) ) {
					continue;
				}

				// check that the rule should be applied to the current slot
				// if not time it must be time:day_number
				if ( 'time' !== $type ) {
					if ( ! strpos( $type, (string) $slot_day_no ) ) {
						continue;
					}
				}

				$rule_val         = $range['rule'];
				$from             = $range['from'];
				$to               = $range['to'];
				$apply_rule_times = true;
			}

			$rule_start_time = $apply_rule_times ? strtotime( $from, $slot_start_time ) : $slot_start_time;
			$rule_end_time   = $apply_rule_times ? strtotime( $to, $slot_start_time ) : $slot_start_time;

			// Reverse time rule - The end time is tomorrow e.g. 16:00 today - 12:00 tomorrow
			if ( $rule_end_time <= $rule_start_time ) {
				if ( $slot_end_time > $rule_start_time ) {
					$bookable = $rule_val;
					continue;
				}
				if ( $slot_start_time >= $rule_start_time && $slot_end_time >= $rule_end_time ) {
					$bookable = $rule_val;
					continue;
				}
				// does this rule apply?
				// does slot start before rule start and end after rules start time {goes over start time}
				if ( $slot_start_time < $rule_start_time && $slot_end_time > $rule_start_time ) {
					$bookable = $rule_val;
					continue;
				}
			} else {
				// Normal rule.
				if ( $slot_start_time < $rule_end_time && $slot_end_time > $rule_start_time ) {
					if ( 'hour' === $bookable_product->get_duration_unit() || 'minute' === $bookable_product->get_duration_unit() ) {
						// If the rule makes the product available, check if slot range is inside the rule range.
						if ( $apply_rule_times && $rule_val ) {
							if ( $slot_start_time < $rule_start_time ) {
								continue;
							}

							// When availability setting is set to check against "The starting block only",
							// Only then, check if slot_end_time is inside the rule_end_time.
							if ( ! $bookable_product->get_check_start_block_only() && $slot_end_time > $rule_end_time ) {
								continue;
							}
						}
					}
					$bookable = $rule_val;
					continue;
				}

				// specific to hour duration types. If start time is in between
				// rule start and end times the rule should be applied.
				if ( 'hour' === $bookable_product->get_duration_unit()
				     && $slot_start_time > $rule_start_time
				     && $slot_start_time < $rule_end_time ) {

					$bookable = $rule_val;
					continue;

				}
			}
		}

		/**
		 * `check_availability_rules_against_time` function checks rules against day of $slot_start_time only.
		 * So, for multi-day availability check, we have to recursively call function for each in-between days.
		 *
		 * Example:
		 * Rule check against (11:00 PM - 20/04/2022) to (03:00 AM - 22/04/2022)
		 * will requires availability check against below times to consider rules of each in-between days.
		 *
		 * 1. 11:00 PM (20/04/2022) - 03:00 AM (22/04/2022)
		 * 2. 12:00 AM (21/04/2022) - 03:00 AM (22/04/2022)
		 * 3. 12:00 AM (22/04/2022) - 03:00 AM (22/04/2022)
		 *
		 * Check if $slot_end_time is in next or upcoming day and multi-day availability check is required.
		 * (For customer defined blocks & minutes/hours block durations only).
		 *
		 * @see https://github.com/woocommerce/woocommerce-bookings/issues/2878
		 */
		$next_day = strtotime( 'midnight + 1 day', $slot_start_time );
		if (
			$bookable &&
			'customer' === $bookable_product->get_duration_type() &&
			in_array( $bookable_product->get_duration_unit(), array( 'minute', 'hour' ), true ) &&
			$slot_end_time > $next_day
		) {
			return self::check_availability_rules_against_time( $next_day, $slot_end_time, $resource_id, $bookable_product, $bookable );
		}
		return $bookable;
	}

	/**
	 * Check a date against the availability rules
	 *
	 * @version 1.11.3 Added woocommerce_bookings_is_date_bookable filter hook
	 * @version 1.10.0  Moved to this class from WC_Product_Booking
	 *                  only apply rules if within their scope
	 *                  keep booking value alive within the loop to ensure the next rule with higher power can override
	 * @version 1.9.14  removed all calls to break 2 to ensure we get to the highest
	 *                  priority rules, otherwise higher order/priority rules will not
	 *                  override lower ones and the function exit with the wrong value.
	 *
	 * @param  WC_Product_Booking $bookable_product
	 * @param  int                $resource_id
	 * @param  int                $check_date timestamp
	 * @return bool available or not
	 */
	public static function check_availability_rules_against_date( $bookable_product, $resource_id, $check_date ) {
		$bookable = $bookable_product->get_default_availability();
		foreach ( $bookable_product->get_availability_rules( $resource_id ) as $rule ) {
			if ( self::does_rule_apply( $rule, $check_date ) ) {
				// passing $bookable into the next check as it overrides the previous value
				$bookable = self::check_timestamp_against_rule( $check_date, $rule, $bookable );
			}
		}

		/**
		 * Is date bookable hook.
		 *
		 * Filter allows for overriding whether or not date is bookable. Filters should return true
		 * if bookable or false if not.
		 *
		 * @since 1.11.3
		 *
		 * @param bool $bookable available or not
		 * @param WC_Product_Booking $bookable_product
		 * @param int $resource_id
		 * @param int $check_date timestamp
		 */
		return apply_filters( 'woocommerce_bookings_is_date_bookable', $bookable, $bookable_product, $resource_id, $check_date );
	}

	/**
	 * Does the time stamp fall within the scope of the rule?
	 *
	 * @param $rule
	 * @param $timestamp
	 * @return bool
	 */
	public static function does_rule_apply( $rule, $timestamp ) {
		$year        = intval( date( 'Y', $timestamp ) );
		$month       = intval( date( 'n', $timestamp ) );
		$day         = intval( date( 'j', $timestamp ) );
		$day_of_week = intval( date( 'N', $timestamp ) );
		$week        = intval( date( 'W', $timestamp ) );

		$range = $rule['range'];

		switch ( $rule['type'] ) {
			case 'months':
				if ( isset( $range[ $month ] ) ) {
					return true;
				}
				break;
			case 'weeks':
				if ( isset( $range[ $week ] ) ) {
					return true;
				}
				break;
			case 'days':
				if ( isset( $range[ $day_of_week ] ) ) {
					return true;
				}
				break;
			case 'custom':
				if ( isset( $range[ $year ][ $month ][ $day ] ) ) {
					return true;
				}
				break;
			case 'rrule':
				if ( self::rrule_matches_timestamp( $range, $timestamp ) ) {
					return true;
				}
				break;
			case 'time':
			case 'time:1':
			case 'time:2':
			case 'time:3':
			case 'time:4':
			case 'time:5':
			case 'time:6':
			case 'time:7':
				if ( $day_of_week === $range['day'] || 0 === $range['day'] ) {
					return true;
				}
				break;
			case 'custom:daterange':
			case 'time:range':
				if ( isset( $range[ $year ][ $month ][ $day ] ) ) {
					return true;
				}
				break;
		}

		return false;
	}

	/**
	 * Given a timestamp and rule check to see if the time stamp is bookable based on the rule.
	 *
	 * @since 1.10.0
	 *
	 * @param integer $timestamp
	 * @param array   $rule
	 * @param boolean $default
	 * @return boolean
	 */
	public static function check_timestamp_against_rule( $timestamp, $rule, $default ) {
		$year        = intval( date( 'Y', $timestamp ) );
		$month       = intval( date( 'n', $timestamp ) );
		$day         = intval( date( 'j', $timestamp ) );
		$day_of_week = intval( date( 'N', $timestamp ) );
		$week        = intval( date( 'W', $timestamp ) );

		$type     = $rule['type'];
		$range    = $rule['range'];
		$bookable = $default;

		switch ( $type ) {
			case 'months':
				if ( isset( $range[ $month ] ) ) {
					$bookable = $range[ $month ];
				}
				break;
			case 'weeks':
				if ( isset( $range[ $week ] ) ) {
					$bookable = $range[ $week ];
				}
				break;
			case 'days':
				if ( isset( $range[ $day_of_week ] ) ) {
					$bookable = $range[ $day_of_week ];
				}
				break;
			case 'custom':
				if ( isset( $range[ $year ][ $month ][ $day ] ) ) {
					$bookable = $range[ $year ][ $month ][ $day ];
				}
				break;
			case 'rrule':
				if ( self::rrule_matches_timestamp( $range, $timestamp ) ) {
					return $range['rule'];
				}
				break;
			case 'time':
			case 'time:1':
			case 'time:2':
			case 'time:3':
			case 'time:4':
			case 'time:5':
			case 'time:6':
			case 'time:7':
				if ( false === $default && ( $day_of_week === $range['day'] || 0 === $range['day'] ) ) {
					$bookable = $range['rule'];
				}
				break;
			case 'custom:daterange':
			case 'time:range':
				if ( false === $default && ( isset( $range[ $year ][ $month ][ $day ] ) ) ) {
					$bookable = $range[ $year ][ $month ][ $day ]['rule'];
				}
				break;
		}

		return $bookable;
	}

	/**
	 * Checks if the given rrule and event happens at the given timestamp.
	 *
	 * @param array $range Range and rrule to check.
	 * @param int   $timestamp Timestamp to check against.
	 *
	 * @return bool
	 */
	private static function rrule_matches_timestamp( $range, $timestamp ) {

		// This function is normally called twice with the same parameters so let's cache the result.
		static $cache = array();

		// This function will be called with the same rrules but different timestamps, so cache the rrule object and duration here.
		static $rrule_cache = array();

		$rrule_cache_key    = $range['from'] . ':' . $range['to'] . ':' . $range['rrule'];
		$cache_key    = $rrule_cache_key . ':' . $timestamp;

		if ( isset( $cache[ $cache_key ] ) ) {
			return $cache[ $cache_key ];
		}

		try {

			$datetime   = new DateTime( '@' . $timestamp );
			$gmt_offset = get_option( 'gmt_offset' );

			/**
			 * Converting float offset to human-readable format.
			 * For the $modifier parameter of DateTime's modify( $modifier ) function.
			 * Example, 5.5 => 5 hours 30 minutes.
			 */
			$gmt_offset_string = sprintf( '%02d hours %02d minutes', (int) $gmt_offset, fmod( $gmt_offset, 1 ) * 60 );

			if ( ! isset( $rrule_cache[ $rrule_cache_key ] ) ) {
				$is_all_day = false === strpos( $range['from'], ':' );
				$start      = new DateTime( $range['from'] );
				$end        = new DateTime( $range['to'] );
				$start->setTimezone( new DateTimeZone( 'GMT' ) );
				$end->setTimezone( new DateTimeZone( 'GMT' ) );
				$end->modify( $gmt_offset_string );
				$start->modify( $gmt_offset_string );

				$duration = $start->diff( $end, true );

				$rrule = new RSet( $range['rrule'], $is_all_day ? $start->format( 'Y-m-d' ) : $start );

				$rrule_cache[ $rrule_cache_key ] = array(
					'rrule_object' => $rrule,
					'duration'     => $duration
				);
			} else {
				$rrule    = $rrule_cache[ $rrule_cache_key ]['rrule_object'];
				$duration = $rrule_cache[ $rrule_cache_key ]['duration'];
			}

			foreach ( $rrule as $occurrence ) {
				if ( $occurrence <= $datetime && $datetime <= $occurrence->add( $duration ) ) {
					$cache[ $cache_key ] = true;
					return true;
				}
				if ( $occurrence > $datetime ) {
					break;
				}
			}
		} catch ( Exception $e ) {
			wc_get_logger()->error( $e->getMessage() );
		}
		$cache[ $cache_key ] = false;
		return false;
	}

	/**
	 * Checks if the given rrule and event happens between the given slot timestamps.
	 *
	 * @param array $range           Range and rrule to check.
	 * @param int   $slot_start_time Start Timestamp of the slot.
	 * @param int   $slot_end_time   End Timestamp of the slot.
	 *
	 * @return bool
	 */
	private static function rrule_has_occurrences_between_slots( $range, $slot_start_time, $slot_end_time ) {
		static $cache = array();
		$cache_key    = $range['from'] . ':' . $range['to'] . ':' . $range['rrule'] . ':' . $slot_start_time . ':' . $slot_end_time;

		if ( isset( $cache[ $cache_key ] ) ) {
			return $cache[ $cache_key ];
		}

		$gmt_offset = get_option( 'gmt_offset' );

		/**
		 * Converting float offset to human-readable format.
		 * For the $modifier parameter of DateTime's modify( $modifier ) function.
		 * Example, 5.5 => 5 hours 30 minutes.
		 */
		$gmt_offset_string = sprintf( '%02d hours %02d minutes', (int) $gmt_offset, fmod( $gmt_offset, 1 ) * 60 );
		$is_all_day        = false === strpos( $range['from'], ':' );
		$start             = new DateTime( $range['from'] );
		$start->setTimezone( new DateTimeZone( 'GMT' ) );
		$start->modify( $gmt_offset_string );
		$rrule       = new RSet( $range['rrule'], $is_all_day ? $start->format( 'Y-m-d' ) : $start );
		$occurrences = $rrule->getOccurrencesBetween( $slot_start_time, $slot_end_time, 1 );

		$has_ocurrences      = ! empty( $occurrences );
		$cache[ $cache_key ] = $has_ocurrences;

		return $has_ocurrences;
	}
}
