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

/**
 * Class WC_Appointments_Availability_Data_Store
 *
 * @package Woocommerce/Appointments
 */

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

/**
 * WC Availability Data Store: Stored in Custom table.
 * @todo When 2.6 support is dropped, implement WC_Object_Data_Store_Interface
 */
class WC_Appointments_Availability_Data_Store extends WC_Data_Store_WP {

	public const TABLE_NAME       = 'wc_appointments_availability';
	public const CACHE_GROUP      = 'wc-appointments-availability';
	public const DEFAULT_MIN_DATE = '0000-00-00';
	public const DEFAULT_MAX_DATE = '9999-99-99';

	/**
	 * Create a new availability in the database.
	 *
	 * @param WC_Appointments_Availability $availability WC_Appointments_Availability instance.
	 */
	public function create( &$availability ): void {
		global $wpdb;

		$availability->apply_changes();

		$data = [
			'kind'                  => $availability->get_kind( 'edit' ),
			'kind_id'               => $availability->get_kind_id( 'edit' ),
			'event_id'              => $availability->get_event_id( 'edit' ),
			'parent_event_id'       => $availability->get_parent_event_id( 'edit' ),
			'title'                 => $availability->get_title( 'edit' ),
			'range_type'            => $availability->get_range_type( 'edit' ),
			'from_date'             => $availability->get_from_date( 'edit' ),
			'to_date'               => $availability->get_to_date( 'edit' ),
			'from_range'            => $availability->get_from_range( 'edit' ),
			'to_range'              => $availability->get_to_range( 'edit' ),
			'appointable'           => $availability->get_appointable( 'edit' ),
			'priority'              => $availability->get_priority( 'edit' ),
			'qty'                   => $availability->get_qty( 'edit' ),
			'ordering'              => $availability->get_ordering( 'edit' ),
			'rrule'                 => $availability->get_rrule( 'edit' ),
			'date_created'          => current_time( 'mysql' ),
			'date_modified'         => current_time( 'mysql' ),
		];

		$wpdb->insert( $wpdb->prefix . self::TABLE_NAME, $data );
		$availability->set_id( $wpdb->insert_id );
		WC_Appointments_Cache::invalidate_cache_group( self::CACHE_GROUP );
		WC_Appointments_Cache::delete_appointment_slots_transient();

		// Fire a create event for webhooks: `availability.created`.
		do_action( 'woocommerce_appointments_availability_created', (int) $availability->get_id(), $availability, $this );

		// Notify listeners (e.g., availability cache indexer) that a rule was saved.
		// This enables immediate re-indexing of the rule in the cache table.
		do_action( 'woocommerce_after_appointments_availability_object_save', $availability, $this );
	}

	/**
	 * Read availability from the database.
	 *
	 * @param  WC_Appointments_Availability $availability Instance.
	 * @throws Exception When webhook is invalid.
	 */
	public function read( &$availability ): void {
		$data = wp_cache_get( $availability->get_id(), self::CACHE_GROUP );

		if ( false === $data ) {
			global $wpdb;

			$data = $wpdb->get_row(
			    $wpdb->prepare(
			        'SELECT
							ID as id,
							kind,
							kind_id,
							event_id,
							parent_event_id,
							title,
							range_type,
							from_date,
							to_date,
							from_range,
							to_range,
							appointable,
							priority,
							qty,
							ordering,
							date_created,
							date_modified,
							rrule
						FROM ' . $wpdb->prefix . self::TABLE_NAME .
					' WHERE ID = %d LIMIT 1;',
			        $availability->get_id(),
			    ),
			    ARRAY_A,
			); // WPCS: unprepared SQL ok.

			if ( empty( $data ) ) {
				throw new Exception( __( 'Invalid event.', 'woocommerce-appointments' ) );
			}

			wp_cache_add( $availability->get_id(), $data, self::CACHE_GROUP );
		}

		if ( is_array( $data ) ) {
			$availability->set_props( $data );
			$availability->set_object_read( true );
		}
	}

	/**
	 * Update a webhook.
	 *
	 * @param WC_Appointments_Availability $availability Instance.
	 */
	public function update( &$availability ): void {
		global $wpdb;

		$changes = $availability->get_changes();

		$changes['date_modified'] = current_time( 'mysql' );

		$wpdb->update(
		    $wpdb->prefix . self::TABLE_NAME,
		    $changes,
		    [
				'ID' => $availability->get_id(),
			],
		);

		$availability->apply_changes();

		wp_cache_delete( $availability->get_id(), self::CACHE_GROUP );
		WC_Appointments_Cache::invalidate_cache_group( self::CACHE_GROUP );
		WC_Appointments_Cache::delete_appointment_slots_transient();

		// Fire an update event for webhooks: `availability.updated`.
		do_action( 'woocommerce_appointments_availability_updated', (int) $availability->get_id(), $availability, $changes, $this );

		// Notify listeners (e.g., availability cache indexer) that a rule was saved.
		// This enables immediate re-indexing of the updated rule in the cache table.
		do_action( 'woocommerce_after_appointments_availability_object_save', $availability, $this );
	}

	/**
	 * Remove a webhook from the database.
	 *
	 * @param WC_Appointments_Availability $availability Instance.
	 */
	public function delete( &$availability ): void {
		global $wpdb;

		do_action( 'woocommerce_appointments_before_delete_appointment_availability', $availability, $this ); // WC_Data::delete does not trigger an action like save() so we have to do it here.

		$wpdb->delete(
		    $wpdb->prefix . self::TABLE_NAME,
		    [
				'ID' => $availability->get_id(),
			],
		    [ '%d' ],
		);
		wp_cache_delete( $availability->get_id(), self::CACHE_GROUP );
		WC_Appointments_Cache::invalidate_cache_group( self::CACHE_GROUP );
		WC_Appointments_Cache::delete_appointment_slots_transient();

		// Also delete any indexed cache rows tied to this availability rule directly,
		// so we don't rely solely on hooks to keep the index in sync.
		if ( class_exists( 'WC_Appointments_Availability_Cache_Data_Store' ) ) {
			try {
				$data_store = new \WC_Appointments_Availability_Cache_Data_Store();
				$data_store->delete_by_source( 'availability', (int) $availability->get_id() );
			} catch ( \Exception $e ) {
				// Silently ignore; hook below will handle listeners.
			}
		}

		// Notify listeners (e.g., availability cache indexer) that a rule was deleted.
		// Pass the ID to match listener expectations.
		do_action( 'woocommerce_after_appointments_availability_object_delete', (int) $availability->get_id() );
	}

	/**
	 * Get all availabilties defined in the database as objetcs.
	 *
	 * @param array  $filters { @see self::build_query() }.
	 * @param string $min_date { @see self::build_query() }.
	 * @param string $max_date { @see self::build_query() }.
	 *
	 * @return WC_Appointments_Availability[]
	 * @throws Exception Validation fails.
	 */
	public function get_all( $filters = [], $min_date = self::DEFAULT_MIN_DATE, $max_date = self::DEFAULT_MAX_DATE ) {

		$data = $this->get_all_as_array( $filters, $min_date, $max_date );

		$availabilities = [];
		foreach ( $data as $row ) {
			$availability = get_wc_appointments_availability();
			$availability->set_object_read( false );
			$availability->set_props( $row );
			$availability->set_object_read( true );
			$availabilities[] = $availability;
		}

		return apply_filters( 'woocommerce_appointments_get_all_availabilities', $availabilities );
	}

	/**
	 * Get all availabilties defined in the database as objetcs.
	 *
	 * @param array  $filters { @see self::build_query() }.
	 * @param string $min_date { @see self::build_query() }.
	 * @param string $max_date { @see self::build_query() }.
	 *
	 * @return array rule IDs []
	 * @throws Exception Validation fails.
	 */
	public function get_all_availability_rule_ids() {
		// Fallback to direct query if CRUD method not available
		global $wpdb;
		$table = $wpdb->prefix . self::TABLE_NAME;
		$ids   = $wpdb->get_col( "SELECT ID FROM {$table} WHERE range_type NOT LIKE '%:expired'" );
		if ( empty( $ids ) ) {
			return [];
		}
		return array_map( 'intval', $ids );
	}

	/**
	 * Get global availability as array.
	 *
	 * @param array  $filters { @see self::build_query() }.
	 * @param string $min_date { @see self::build_query() }.
	 * @param string $max_date { @see self::build_query() }.
	 *
	 * @return array|null|object
	 */
	public function get_all_as_array( $filters = [], $min_date = self::DEFAULT_MIN_DATE, $max_date = self::DEFAULT_MAX_DATE ) {
		if ( ! is_array( $filters ) ) {
			$filters = []; // WC_Data_Store uses call_user_func_array to call this function so the default parameter is not used.
		}

		$sql = $this->build_query( $filters, $min_date, $max_date );

		$cache_key = WC_Cache_Helper::get_cache_prefix( self::CACHE_GROUP ) . 'get_all:' . md5( $sql );
		$array     = wp_cache_get( $cache_key, self::CACHE_GROUP );

		#echo '<pre>' . var_export( $array, true ) . '</pre>';

		if ( false === $array ) {
			global $wpdb;

			$array = $wpdb->get_results( $sql, ARRAY_A ); // WPCS: unprepared SQL ok.

			foreach ( $array as &$row ) {
				/*
				 * Simplify and set BC keys to use later in functions.
				 *
				 * 'range_type' is now 'type'
				 * 'to_range' is now 'to'
				 * 'from_range' is now 'from'
				 */
				$row['type'] = $row['range_type'];
				$row['to']   = $row['to_range'];
				$row['from'] = $row['from_range'];
			}

			wp_cache_add( $cache_key, $array, self::CACHE_GROUP );
		}

		return $array;
	}

	/**
     * Builds query string for availability.
     *
     * @param array $filters { @see self::build_query() }.
     * @param string $min_date Minimum date to select intersecting availability entries for (yyyy-mm-dd format).
     * @param string $max_date Maximum date to select intersecting availability entries for (yyyy-mm-dd format).
     */
    private function build_query( array $filters, $min_date, $max_date ): string {
		global $wpdb;

		/*
		 * Build list of fields with virtual fields 'start_date' and 'end_date'.
		 * 'start_date' shall be '0000-00-00' for recurring events.
		 * 'end_date' shall be '9999-99-99' for recurring events.
		 */
		$fields = [
			'ID',
			'kind',
			'kind_id',
			'event_id',
			'parent_event_id',
			'title',
			'range_type',
			'from_date',
			'to_date',
			'from_range',
			'to_range',
			'rrule',
			'appointable',
			'priority',
			'qty',
			'ordering',
			'date_created',
			'date_modified',
			'(CASE
				WHEN range_type = \'custom\' THEN from_range
				WHEN range_type = \'time:range\' THEN from_date
				WHEN range_type = \'custom:daterange\' THEN from_date
				WHEN range_type = \'store_availability\' THEN from_date
				ELSE \'0000-00-00\'
			END) AS start_date',
			'(CASE
				WHEN range_type = \'custom\' THEN to_range
				WHEN range_type = \'time:range\' THEN to_date
				WHEN range_type = \'custom:daterange\' THEN to_date
				WHEN range_type = \'store_availability\' THEN to_date
				ELSE \'9999-99-99\'
			END) AS end_date',
		];

		// Identity for WHERE clause.
		$where = [ '1' ];

		// Parse WHERE for SQL.
		foreach ( $filters as $filter ) {
			$compare = $this->validate_compare( $filter['compare'] );

			switch ( $compare ) {
				case 'IN':
				case 'NOT IN':
					$key     = esc_sql( $filter['key'] );
					$value   = implode( "','", array_map( 'esc_sql', $filter['value'] ) );
					$where[] = "`{$key}` {$compare} ('{$value}')";
					break;
				case 'BETWEEN':
				case 'NOT BETWEEN':
					$key     = esc_sql( $filter['key'] );
					$value   = implode( "' AND '", array_map( 'esc_sql', $filter['value'] ) );
					$where[] = "(`{$key}` {$compare} '{$value}')";
					break;
				default:
					$key     = esc_sql( $filter['key'] );
					$value   = esc_sql( $filter['value'] );
					$where[] = "`{$key}` {$compare} '{$value}'";
					break;
			}
		}

		// Query for dates that intersect with the min and max.
		if ( self::DEFAULT_MIN_DATE !== $min_date || self::DEFAULT_MAX_DATE !== $max_date ) {
			$min_max_dates       = [
				esc_sql( $min_date ),
				esc_sql( $max_date ),
			];
			$date_intersect_or   = [];
			$date_intersect_or[] = vsprintf( "( start_date BETWEEN '%s' AND '%s' )", $min_max_dates );
			$date_intersect_or[] = vsprintf( "( end_date BETWEEN '%s' AND '%s' )", $min_max_dates );
			$date_intersect_or[] = vsprintf( "( start_date <= '%s' AND end_date >= '%s' )", $min_max_dates );
			$where[]             = sprintf( "( %s )", implode( ' OR ', $date_intersect_or ) );
		}

		// Exclude expired rules.
		$where[] = "(range_type NOT LIKE '%:expired')";

		sort( $where );

		return sprintf(
		    'SELECT * FROM ( SELECT %s FROM %s ) AS a_data WHERE %s ORDER BY ordering ASC',
		    implode( ', ', $fields ),
		    $wpdb->prefix . self::TABLE_NAME,
		    implode( ' AND ', $where ),
		);
	}

	/**
	 * Validates query filter comparison (defaults to '=')
	 *
	 * @param string $compare Raw compare string.
	 * @return string Validated compare string.
	 */
	private function validate_compare( $compare ): string {

		$compare = strtoupper( $compare );

		if (! in_array(
		    $compare,
		    [
				'=',
				'!=',
				'>',
				'>=',
				'<',
				'<=',
				'LIKE',
				'NOT LIKE',
				'IN',
				'NOT IN',
				'BETWEEN',
				'NOT BETWEEN',
			],
		)) {
            return '=';
        }

		return $compare;
	}

	/**
	 * 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
	 * @param bool    $filters
	 *
	 * @return array Appointments and Availabilities (merged in one array)
	 */
	public static function get_events_in_date_range( $start_date, $end_date, $product_id = 0, $staff_id = 0, $check_in_cart = true, $filters = [] ) {
		$appointments = WC_Appointment_Data_Store::get_appointments_in_date_range( $start_date, $end_date, $product_id, $staff_id, $check_in_cart, $filters, true );
		$min_date     = date( 'Y-m-d', $start_date );
		$max_date     = date( 'Y-m-d', $end_date );

		// Filter only for events synced from Google Calendar.
		$availability_filters = [
			[
				'key'     => 'kind',
				'compare' => '=',
				'value'   => 'availability#global',
			],
			[
				'key'     => 'event_id',
				'compare' => '!=',
				'value'   => '',
			],
		];

		$global_availabilities = WC_Data_Store::load( 'appointments-availability' )->get_all( $availability_filters, $min_date, $max_date );

		return array_merge( $appointments, $global_availabilities );
	}

	/**
	 * 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 ) {
		// Filter only for events from Global availability.
		$filters = [
			[
				'key'     => 'kind',
				'compare' => '=',
				'value'   => 'availability#global',
			],
			[
				'key'     => 'event_id',
				'compare' => '==',
				'value'   => '',
			],
		];

		$min_date = date( 'Y-m-d', $start_date );
		$max_date = date( 'Y-m-d', $end_date );

		return WC_Data_Store::load( 'appointments-availability' )->get_all( $filters, $min_date, $max_date );
	}

	/**
	 * Get global availability rules.
	 *
	 * @param  bool $with_gcal
	 * @return array
	 */
	public static function get_global_availability( $with_gcal = true ) {
		if ( $with_gcal ) {
			$global_rules = WC_Data_Store::load( 'appointments-availability' )->get_all_as_array(
			    [
					[
						'key'     => 'kind',
						'compare' => 'IN',
						'value'   => [ 'availability#global' ],
					],
				],
			);
		} else {
			$global_rules = WC_Data_Store::load( 'appointments-availability' )->get_all_as_array(
			    [
					[
						'key'     => 'kind',
						'compare' => '=',
						'value'   => 'availability#global',
					],
					[
						'key'     => 'event_id',
						'compare' => '==',
						'value'   => '',
					],
				],
			);
		}

		return apply_filters( 'wc_appointments_global_availability', $global_rules );
	}

	/**
	 * Get staff availability rules.
	 *
	 * @param  array $staff_ids
	 * @return array
	 */
	public static function get_staff_availability( $staff_ids = [] ) {
		if ( $staff_ids && ! empty( $staff_ids ) && is_int( $staff_ids ) ) {
			$staff_rules = WC_Data_Store::load( 'appointments-availability' )->get_all_as_array(
			    [
					[
						'key'     => 'kind',
						'compare' => '=',
						'value'   => 'availability#staff',
					],
					[
						'key'     => 'kind_id',
						'compare' => '=',
						'value'   => $staff_ids,
					],
				],
			);
		} elseif ( $staff_ids && [] !== $staff_ids && is_array( $staff_ids ) ) {
			$staff_ids   = is_int( $staff_ids ) ? [ $staff_ids ] : $staff_ids;
			$staff_rules = WC_Data_Store::load( 'appointments-availability' )->get_all_as_array(
			    [
					[
						'key'     => 'kind',
						'compare' => '=',
						'value'   => 'availability#staff',
					],
					[
						'key'     => 'kind_id',
						'compare' => 'IN',
						'value'   => $staff_ids,
					],
				],
			);
		} else {
			$staff_rules = WC_Data_Store::load( 'appointments-availability' )->get_all_as_array(
			    [
					[
						'key'     => 'kind',
						'compare' => '=',
						'value'   => 'availability#staff',
					],
				],
			);
		}

		return apply_filters( 'wc_appointments_all_staff_availability', $staff_rules );
	}

	/**
	 * Decode availability rules from JSON, with robust unslash fallbacks.
	 *
	 * @param string $json Raw JSON string from POST.
	 * @return array Array of rule objects, or empty array.
	 */
	public function decode_rules_json( $json ) {
		$rules = [];
		if ( empty( $json ) ) {
			return $rules;
		}
		// First attempt: wp_unslash
		$decoded = json_decode( wp_unslash( $json ), true );
		if ( is_array( $decoded ) ) {
			return $decoded;
		}
		// Second attempt: stripslashes
		$decoded = json_decode( stripslashes( $json ), true );
		if ( is_array( $decoded ) ) {
			return $decoded;
		}
		return [];
	}

	/**
	 * Apply a single normalized JSON rule to an availability object.
	 *
	 * Does not set kind/kind_id; caller should set those contextually.
	 *
	 * @param WC_Appointments_Availability $availability Availability object to mutate.
	 * @param array                        $rule Normalized rule array.
	 */
	public function apply_json_rule_to_availability( &$availability, $rule ): void {
		if ( isset( $rule['appointable'] ) ) {
			$availability->set_appointable( wc_clean( $rule['appointable'] ) );
		}
		if ( isset( $rule['title'] ) ) {
			$availability->set_title( sanitize_text_field( $rule['title'] ) );
		}
		if ( isset( $rule['qty'] ) ) {
			$availability->set_qty( intval( $rule['qty'] ) );
		}
		if ( isset( $rule['priority'] ) ) {
			$availability->set_priority( intval( $rule['priority'] ) );
		}
		if ( isset( $rule['event_id'] ) ) {
			$availability->set_event_id( sanitize_text_field( $rule['event_id'] ) );
		}
        $range_type = isset( $rule['type'] ) ? wc_clean( $rule['type'] ) : $availability->get_range_type( 'edit' );

		switch ( $range_type ) {
			case 'custom':
				if ( isset( $rule['from_date'], $rule['to_date'] ) ) {
					$availability->set_from_range( wc_clean( $rule['from_date'] ) );
					$availability->set_to_range( wc_clean( $rule['to_date'] ) );
				}
				break;
			case 'months':
			case 'weeks':
			case 'days':
				if ( isset( $rule['from_range'], $rule['to_range'] ) ) {
					$availability->set_from_range( wc_clean( $rule['from_range'] ) );
					$availability->set_to_range( wc_clean( $rule['to_range'] ) );
				}
				break;
			case 'rrule':
				// Read-only; skip.
				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 ( isset( $rule['from_range'], $rule['to_range'] ) ) {
					$availability->set_from_range( wc_appointment_sanitize_time( $rule['from_range'] ) );
					$availability->set_to_range( wc_appointment_sanitize_time( $rule['to_range'] ) );
				}
				break;
			case 'time:range':
			case 'custom:daterange':
				if ( isset( $rule['from_range'], $rule['to_range'] ) ) {
					$availability->set_from_range( wc_appointment_sanitize_time( $rule['from_range'] ) );
					$availability->set_to_range( wc_appointment_sanitize_time( $rule['to_range'] ) );
				}
				if ( isset( $rule['from_date'], $rule['to_date'] ) ) {
					$availability->set_from_date( wc_clean( $rule['from_date'] ) );
					$availability->set_to_date( wc_clean( $rule['to_date'] ) );
				}
				break;
		}
	}

	/**
	 * Decode availability rules from the current POST request.
	 *
	 * Attempts JSON first; if unavailable or invalid, falls back to legacy array inputs.
	 *
	 * @return array Array of normalized rule arrays
	 */
	public function decode_rules_from_post() {
		$rules = [];
		// Try JSON input first.
		$json = $_POST['wc_appointment_availability_json'] ?? '';
		$rules = $this->decode_rules_json( $json );
		if ( ! empty( $rules ) ) {
			return $rules;
		}

		// Fallback to legacy arrays.
		return $this->decode_legacy_rules_from_post();
	}

	/**
	 * Check if a rule array is valid.
	 *
	 * Validates a rule array against required fields based on its type.
	 *
	 * @param array $rule Rule data.
	 * @return bool       True if valid.
	 */
	public function is_rule_valid( $rule ) {
		if ( empty( $rule ) || ! is_array( $rule ) ) {
			return false;
		}
		$type = $rule['type'] ?? '';
		if ( '' === $type ) {
			return false;
		}
		switch ( $type ) {
			case 'custom':
				return ! empty( $rule['from_date'] ) && ! empty( $rule['to_date'] );
			case 'months':
			case 'weeks':
			case 'days':
			case 'time':
			case 'time:1':
			case 'time:2':
			case 'time:3':
			case 'time:4':
			case 'time:5':
			case 'time:6':
			case 'time:7':
			case 'time:range':
				return ! empty( $rule['from_range'] ) && ! empty( $rule['to_range'] );
			case 'custom:daterange':
				return ! empty( $rule['from_range'] ) && ! empty( $rule['to_range'] ) && ! empty( $rule['from_date'] ) && ! empty( $rule['to_date'] );
			case 'rrule':
				return ! empty( $rule['rrule'] );
			default:
				return false;
		}
	}

	/**
	 * Build normalized rules from legacy availability POST arrays.
	 *
	 * @return array Array of normalized rule arrays
	 */
	protected function decode_legacy_rules_from_post() {
		$types    = isset( $_POST['wc_appointment_availability_type'] ) ? wp_unslash( $_POST['wc_appointment_availability_type'] ) : []; // phpcs:ignore WordPress.Security.NonceVerification.Missing
		$row_size = is_array( $types ) ? count( $types ) : 0;
		$rules    = [];

		for ( $i = 0; $i < $row_size; $i++ ) {
			$rule = [];
			$rule['type'] = isset( $types[ $i ] ) ? wc_clean( $types[ $i ] ) : '';
			// ID
			if ( isset( $_POST['wc_appointment_availability_id'][ $i ] ) ) {
				$rule['id'] = intval( wp_unslash( $_POST['wc_appointment_availability_id'][ $i ] ) );
			}
			// Common fields
			if ( isset( $_POST['wc_appointment_availability_appointable'][ $i ] ) ) {
				$rule['appointable'] = wc_clean( wp_unslash( $_POST['wc_appointment_availability_appointable'][ $i ] ) );
			}
			if ( isset( $_POST['wc_appointment_availability_title'][ $i ] ) ) {
				$rule['title'] = sanitize_text_field( wp_unslash( $_POST['wc_appointment_availability_title'][ $i ] ) );
			}
			if ( isset( $_POST['wc_appointment_availability_qty'][ $i ] ) ) {
				$rule['qty'] = intval( wp_unslash( $_POST['wc_appointment_availability_qty'][ $i ] ) );
			}
			if ( isset( $_POST['wc_appointment_availability_priority'][ $i ] ) ) {
				$rule['priority'] = intval( wp_unslash( $_POST['wc_appointment_availability_priority'][ $i ] ) );
			}

			// Range-specific mapping
			switch ( $rule['type'] ) {
				case 'custom':
					if ( isset( $_POST['wc_appointment_availability_from_date'][ $i ] ) && isset( $_POST['wc_appointment_availability_to_date'][ $i ] ) ) {
						$rule['from_date'] = wc_clean( wp_unslash( $_POST['wc_appointment_availability_from_date'][ $i ] ) );
						$rule['to_date']   = wc_clean( wp_unslash( $_POST['wc_appointment_availability_to_date'][ $i ] ) );
					}
					break;
				case 'months':
					if ( isset( $_POST['wc_appointment_availability_from_month'][ $i ] ) && isset( $_POST['wc_appointment_availability_to_month'][ $i ] ) ) {
						$rule['from_range'] = wc_clean( wp_unslash( $_POST['wc_appointment_availability_from_month'][ $i ] ) );
						$rule['to_range']   = wc_clean( wp_unslash( $_POST['wc_appointment_availability_to_month'][ $i ] ) );
					}
					break;
				case 'weeks':
					if ( isset( $_POST['wc_appointment_availability_from_week'][ $i ] ) && isset( $_POST['wc_appointment_availability_to_week'][ $i ] ) ) {
						$rule['from_range'] = wc_clean( wp_unslash( $_POST['wc_appointment_availability_from_week'][ $i ] ) );
						$rule['to_range']   = wc_clean( wp_unslash( $_POST['wc_appointment_availability_to_week'][ $i ] ) );
					}
					break;
				case 'days':
					if ( isset( $_POST['wc_appointment_availability_from_day_of_week'][ $i ] ) && isset( $_POST['wc_appointment_availability_to_day_of_week'][ $i ] ) ) {
						$rule['from_range'] = wc_clean( wp_unslash( $_POST['wc_appointment_availability_from_day_of_week'][ $i ] ) );
						$rule['to_range']   = wc_clean( wp_unslash( $_POST['wc_appointment_availability_to_day_of_week'][ $i ] ) );
					}
					break;
				case 'rrule':
					// Read-only; skip mapping.
					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 ( isset( $_POST['wc_appointment_availability_from_time'][ $i ] ) && isset( $_POST['wc_appointment_availability_to_time'][ $i ] ) ) {
						$rule['from_range'] = wc_appointment_sanitize_time( wp_unslash( $_POST['wc_appointment_availability_from_time'][ $i ] ) );
						$rule['to_range']   = wc_appointment_sanitize_time( wp_unslash( $_POST['wc_appointment_availability_to_time'][ $i ] ) );
					}
					break;
				case 'time:range':
				case 'custom:daterange':
					if ( isset( $_POST['wc_appointment_availability_from_time'][ $i ] ) && isset( $_POST['wc_appointment_availability_to_time'][ $i ] ) ) {
						$rule['from_range'] = wc_appointment_sanitize_time( wp_unslash( $_POST['wc_appointment_availability_from_time'][ $i ] ) );
						$rule['to_range']   = wc_appointment_sanitize_time( wp_unslash( $_POST['wc_appointment_availability_to_time'][ $i ] ) );
					}
					if ( isset( $_POST['wc_appointment_availability_from_date'][ $i ] ) && isset( $_POST['wc_appointment_availability_to_date'][ $i ] ) ) {
						$rule['from_date'] = wc_clean( wp_unslash( $_POST['wc_appointment_availability_from_date'][ $i ] ) );
						$rule['to_date']   = wc_clean( wp_unslash( $_POST['wc_appointment_availability_to_date'][ $i ] ) );
					}
					break;
			}

			$rules[] = $rule;
		}

		return $rules;
	}
}
