<?php
/**
 * Reservation pre-order settings and eligibility service.
 *
 * @package FoodOrd
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class Foodord_Table_Reservation_Preorders
 */
class Foodord_Table_Reservation_Preorders {
	public const PRODUCT_META_ENABLED = '_foodord_reservation_preorder_enabled';
	public const ORDER_META_ENABLED   = '_foodord_reservation_preorder';

	private const DB_VERSION           = '1.1.0';
	private const OPTION_DB_VERSION    = 'foodord_reservation_preorder_db_version';
	private const OPTION_SETTINGS      = 'foodord_table_reservation_preorder';
	private const DEFAULT_CUTOFF_HOURS = 24;
	private const DRAFT_TTL_SECONDS    = 900;
	private const ITEM_NOTE_MAX_LENGTH = 160;
	private const CUTOFF_OPTIONS       = array( 1, 2, 4, 8, 12, 24, 48, 72, 96, 120, 144, 168 );

	/**
	 * Active reservation pre-order pricing bypass depth.
	 *
	 * @var int
	 */
	private static $pricing_rules_bypass_depth = 0;

	/**
	 * Use regular WooCommerce prices and disable FoodOrd Pro pricing rules.
	 */
	public static function begin_pricing_rules_bypass(): void {
		++self::$pricing_rules_bypass_depth;
		if ( 1 !== self::$pricing_rules_bypass_depth ) {
			return;
		}

		add_filter( 'foodord_pro_pricing_rules_enabled', array( __CLASS__, 'disable_pricing_rules' ), PHP_INT_MAX );
		add_filter( 'woocommerce_product_get_price', array( __CLASS__, 'use_regular_product_price' ), PHP_INT_MAX, 2 );
		add_filter( 'woocommerce_product_get_sale_price', array( __CLASS__, 'use_regular_product_price' ), PHP_INT_MAX, 2 );
		add_filter( 'woocommerce_product_variation_get_price', array( __CLASS__, 'use_regular_product_price' ), PHP_INT_MAX, 2 );
		add_filter( 'woocommerce_product_variation_get_sale_price', array( __CLASS__, 'use_regular_product_price' ), PHP_INT_MAX, 2 );
		add_filter( 'woocommerce_product_is_on_sale', array( __CLASS__, 'disable_sale_status' ), PHP_INT_MAX, 2 );
		add_filter( 'woocommerce_product_variation_is_on_sale', array( __CLASS__, 'disable_sale_status' ), PHP_INT_MAX, 2 );
		add_filter( 'woocommerce_variation_prices_price', array( __CLASS__, 'use_regular_variation_price' ), PHP_INT_MAX, 3 );
		add_filter( 'woocommerce_variation_prices_sale_price', array( __CLASS__, 'use_regular_variation_price' ), PHP_INT_MAX, 3 );
		add_filter( 'woocommerce_get_variation_prices_hash', array( __CLASS__, 'add_regular_price_cache_context' ), PHP_INT_MAX, 3 );
	}

	/**
	 * Restore the normal WooCommerce and FoodOrd Pro pricing pipeline.
	 */
	public static function end_pricing_rules_bypass(): void {
		if ( self::$pricing_rules_bypass_depth <= 0 ) {
			return;
		}

		--self::$pricing_rules_bypass_depth;
		if ( 0 !== self::$pricing_rules_bypass_depth ) {
			return;
		}

		remove_filter( 'foodord_pro_pricing_rules_enabled', array( __CLASS__, 'disable_pricing_rules' ), PHP_INT_MAX );
		remove_filter( 'woocommerce_product_get_price', array( __CLASS__, 'use_regular_product_price' ), PHP_INT_MAX );
		remove_filter( 'woocommerce_product_get_sale_price', array( __CLASS__, 'use_regular_product_price' ), PHP_INT_MAX );
		remove_filter( 'woocommerce_product_variation_get_price', array( __CLASS__, 'use_regular_product_price' ), PHP_INT_MAX );
		remove_filter( 'woocommerce_product_variation_get_sale_price', array( __CLASS__, 'use_regular_product_price' ), PHP_INT_MAX );
		remove_filter( 'woocommerce_product_is_on_sale', array( __CLASS__, 'disable_sale_status' ), PHP_INT_MAX );
		remove_filter( 'woocommerce_product_variation_is_on_sale', array( __CLASS__, 'disable_sale_status' ), PHP_INT_MAX );
		remove_filter( 'woocommerce_variation_prices_price', array( __CLASS__, 'use_regular_variation_price' ), PHP_INT_MAX );
		remove_filter( 'woocommerce_variation_prices_sale_price', array( __CLASS__, 'use_regular_variation_price' ), PHP_INT_MAX );
		remove_filter( 'woocommerce_get_variation_prices_hash', array( __CLASS__, 'add_regular_price_cache_context' ), PHP_INT_MAX );
	}

	/**
	 * Disable FoodOrd Pro pricing rules for the active pre-order context.
	 *
	 * @param bool $enabled Current pricing rules state.
	 */
	public static function disable_pricing_rules( $enabled ): bool {
		unset( $enabled );

		return false;
	}

	/**
	 * Return a product's regular price while rendering reservation pre-orders.
	 *
	 * @param string|float $price   Current product price.
	 * @param WC_Product   $product Product instance.
	 * @return string|float
	 */
	public static function use_regular_product_price( $price, $product ) {
		if ( ! $product instanceof WC_Product ) {
			return $price;
		}

		$regular_price = $product->get_regular_price( 'edit' );

		return '' !== (string) $regular_price ? $regular_price : $price;
	}

	/**
	 * Prevent WooCommerce sale presentation in reservation pre-orders.
	 *
	 * @param bool       $is_on_sale Current sale state.
	 * @param WC_Product $product    Product instance.
	 */
	public static function disable_sale_status( $is_on_sale, $product ): bool {
		unset( $is_on_sale, $product );

		return false;
	}

	/**
	 * Return a variation's regular price in variation price collections.
	 *
	 * @param string               $price     Current variation price.
	 * @param WC_Product_Variation $variation Variation instance.
	 * @param WC_Product_Variable  $product   Parent product.
	 * @return string|float
	 */
	public static function use_regular_variation_price( $price, $variation, $product ) {
		unset( $product );

		return self::use_regular_product_price( $price, $variation );
	}

	/**
	 * Keep regular-price variation caches separate from normal storefront prices.
	 *
	 * @param array               $price_hash  Existing cache hash.
	 * @param WC_Product_Variable $product     Variable product.
	 * @param bool                $for_display Whether prices are for display.
	 * @return array
	 */
	public static function add_regular_price_cache_context( $price_hash, $product, $for_display ): array {
		unset( $product, $for_display );

		$price_hash['foodord_reservation_preorder'] = 'regular';

		return $price_hash;
	}

	/**
	 * Constructor.
	 */
	public function __construct() {
		self::maybe_install();
	}

	/**
	 * Install the pre-order draft table when required.
	 */
	public static function maybe_install(): void {
		if ( self::DB_VERSION === get_option( self::OPTION_DB_VERSION ) ) {
			return;
		}

		self::install();
	}

	/**
	 * Install or update the pre-order draft table.
	 */
	public static function install(): void {
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$db              = self::db();
		$table           = self::draft_table_name();
		$charset_collate = $db->get_charset_collate();

		dbDelta(
			"CREATE TABLE {$table} (
				id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
				draft_token char(64) NOT NULL,
				hold_token char(64) NOT NULL,
				reservation_id bigint(20) unsigned NOT NULL DEFAULT 0,
				order_id bigint(20) unsigned NOT NULL DEFAULT 0,
				customer_session varchar(64) NOT NULL DEFAULT '',
				status varchar(20) NOT NULL DEFAULT 'active',
				payload longtext NULL,
				expires_at_gmt datetime NOT NULL,
				created_at_gmt datetime NOT NULL,
				updated_at_gmt datetime NOT NULL,
				PRIMARY KEY  (id),
				UNIQUE KEY draft_token (draft_token),
				UNIQUE KEY hold_token (hold_token),
				KEY reservation_id (reservation_id),
				KEY order_id (order_id),
				KEY customer_session (customer_session),
				KEY status_expires (status,expires_at_gmt)
			) {$charset_collate};"
		);

		update_option( self::OPTION_DB_VERSION, self::DB_VERSION, false );
	}

	/**
	 * Start or resume a draft without extending the same hold more than once.
	 *
	 * @param string                     $hold_token       Hold token.
	 * @param string                     $customer_session Customer session key.
	 * @param Foodord_Table_Reservations $reservations    Reservation service.
	 * @return array<string,mixed>|WP_Error
	 */
	public function start_draft( string $hold_token, string $customer_session, Foodord_Table_Reservations $reservations ) {
		$hold_token       = self::sanitize_token( $hold_token );
		$customer_session = self::sanitize_session( $customer_session );
		if ( '' === $hold_token || '' === $customer_session ) {
			return new WP_Error( 'foodord_preorder_invalid_draft_request', __( 'Unable to start the pre-order. Please try again.', 'foodord' ) );
		}

		$this->cleanup_expired_drafts();
		$hold = $reservations->get_active_hold( $hold_token );
		if ( ! $this->hold_belongs_to_session( $hold, $customer_session ) ) {
			return new WP_Error( 'foodord_preorder_hold_expired', __( 'Your table hold has expired. Please select a time again.', 'foodord' ) );
		}

		if ( ! self::is_available_for_slot( (string) $hold->reservation_date, (string) $hold->reservation_time, (int) $hold->party_size ) ) {
			return new WP_Error( 'foodord_preorder_not_available', __( 'Pre-ordering is not available for this reservation.', 'foodord' ) );
		}

		$existing = $this->get_draft_by_hold( $hold_token, $customer_session );
		if ( $existing ) {
			if ( 'active' !== (string) $existing->status ) {
				$this->reactivate_draft( (int) $existing->id, (string) $hold->expires_at_gmt );
				$existing->status         = 'active';
				$existing->expires_at_gmt = (string) $hold->expires_at_gmt;
			}

			return $this->format_draft( $existing, $hold, false );
		}

		$db          = self::db();
		$table       = self::draft_table_name();
		$now         = current_time( 'mysql', true );
		$expires_at  = gmdate( 'Y-m-d H:i:s', time() + self::DRAFT_TTL_SECONDS );
		$draft_token = wp_generate_password( 64, false, false );
		$inserted    = $db->insert(
			$table,
			array(
				'draft_token'      => $draft_token,
				'hold_token'       => $hold_token,
				'customer_session' => $customer_session,
				'status'           => 'active',
				'payload'          => null,
				'expires_at_gmt'   => $expires_at,
				'created_at_gmt'   => $now,
				'updated_at_gmt'   => $now,
			),
			array( '%s', '%s', '%s', '%s', '%s', '%s', '%s', '%s' )
		);

		if ( false === $inserted ) {
			$existing = $this->get_draft_by_hold( $hold_token, $customer_session );
			if ( $existing ) {
				return $this->format_draft( $existing, $hold, false );
			}

			return new WP_Error( 'foodord_preorder_draft_failed', __( 'Unable to start the pre-order. Please try again.', 'foodord' ) );
		}

		$extended_hold = $reservations->extend_hold_expiration( $hold_token, $customer_session, self::DRAFT_TTL_SECONDS );
		if ( is_wp_error( $extended_hold ) ) {
			$db->delete( $table, array( 'draft_token' => $draft_token ), array( '%s' ) );

			return $extended_hold;
		}

		$draft = $this->get_draft_by_token( $draft_token, $customer_session );

		return $draft ? $this->format_draft( $draft, $extended_hold, true ) : new WP_Error( 'foodord_preorder_draft_failed', __( 'Unable to start the pre-order. Please try again.', 'foodord' ) );
	}

	/**
	 * Get an active draft with its active hold.
	 *
	 * @param string $draft_token      Draft token.
	 * @param string $customer_session Customer session key.
	 * @return array<string,mixed>|WP_Error
	 */
	public function resume_draft( string $draft_token, string $customer_session ) {
		$this->cleanup_expired_drafts();
		$draft = $this->get_draft_by_token( $draft_token, $customer_session );
		if ( ! $draft ) {
			return new WP_Error( 'foodord_preorder_draft_expired', __( 'Your pre-order session has expired. Please select a time again.', 'foodord' ) );
		}

		return $this->format_draft( $draft, $draft, false );
	}

	/**
	 * Clear an active food draft without releasing its table hold.
	 *
	 * @param string $draft_token      Draft token.
	 * @param string $customer_session Customer session key.
	 */
	public function cancel_draft( string $draft_token, string $customer_session ): bool {
		$draft = $this->get_draft_by_token( $draft_token, $customer_session );
		if ( ! $draft ) {
			return false;
		}

		$db = self::db();

		return false !== $db->update(
			self::draft_table_name(),
			array(
				'status'         => 'cancelled',
				'payload'        => null,
				'updated_at_gmt' => current_time( 'mysql', true ),
			),
			array( 'id' => (int) $draft->id ),
			array( '%s', '%s', '%s' ),
			array( '%d' )
		);
	}

	/**
	 * Resolve an authorized draft for a full reservation-flow cancellation.
	 *
	 * @param string $draft_token      Draft token.
	 * @param string $customer_session Customer session key.
	 * @return object|null
	 */
	public function get_authorized_draft( string $draft_token, string $customer_session ) {
		return $this->get_draft_by_token( $draft_token, $customer_session );
	}

	/**
	 * Get products available for reservation pre-orders.
	 *
	 * @return WC_Product[]
	 */
	public function get_catalog_products(): array {
		if ( ! function_exists( 'wc_get_products' ) ) {
			return array();
		}

		$db          = self::db();
		$product_ids = $db->get_col(
			$db->prepare(
				'SELECT products.ID FROM %i AS products WHERE products.post_type = %s AND products.post_status = %s AND NOT EXISTS (SELECT 1 FROM %i AS product_meta WHERE product_meta.post_id = products.ID AND product_meta.meta_key = %s AND product_meta.meta_value = %s) ORDER BY products.menu_order ASC, products.ID ASC',
				$db->posts,
				'product',
				'publish',
				$db->postmeta,
				self::PRODUCT_META_ENABLED,
				'no'
			)
		);
		$product_ids = array_values( array_filter( array_map( 'absint', $product_ids ) ) );
		if ( empty( $product_ids ) ) {
			return array();
		}

		$products = wc_get_products(
			array(
				'include' => $product_ids,
				'limit'   => count( $product_ids ),
				'orderby' => 'include',
			)
		);

		return array_values(
			array_filter(
				is_array( $products ) ? $products : array(),
				static function ( $product ): bool {
					return $product instanceof WC_Product
						&& in_array( $product->get_type(), array( 'simple', 'variable' ), true )
						&& $product->is_visible()
						&& $product->is_in_stock();
				}
			)
		);
	}

	/**
	 * Add a validated product configuration to a draft.
	 *
	 * @param string              $draft_token      Draft token.
	 * @param string              $customer_session Customer session key.
	 * @param array<string,mixed> $configuration    Raw product configuration.
	 * @return array<string,mixed>|WP_Error
	 */
	public function add_draft_item( string $draft_token, string $customer_session, array $configuration ) {
		$draft = $this->get_draft_by_token( $draft_token, $customer_session );
		if ( ! $draft ) {
			return new WP_Error( 'foodord_preorder_draft_expired', __( 'Your pre-order session has expired. Please select a time again.', 'foodord' ) );
		}

		$product_id = absint( $configuration['product_id'] ?? 0 );
		if ( ! self::is_product_enabled( $product_id ) ) {
			return new WP_Error( 'foodord_preorder_product_not_enabled', __( 'This product is not available for reservation pre-order.', 'foodord' ) );
		}

		$normalized = $this->normalize_product_configuration( $configuration );
		if ( is_wp_error( $normalized ) ) {
			return $normalized;
		}

		$items     = $this->get_draft_items( $draft );
		$signature = $this->build_item_signature( $normalized );
		$merged    = false;
		foreach ( $items as &$item ) {
			if ( isset( $item['configuration_signature'] ) && hash_equals( (string) $item['configuration_signature'], $signature ) ) {
				$item['quantity'] = min( 99, max( 1, absint( $item['quantity'] ?? 1 ) + absint( $normalized['quantity'] ) ) );
				$merged           = true;
				break;
			}
		}
		unset( $item );

		if ( ! $merged ) {
			$normalized['item_key']                = wp_generate_uuid4();
			$normalized['configuration_signature'] = $signature;
			$items[]                               = $normalized;
		}

		if ( ! $this->save_draft_items( (int) $draft->id, $items ) ) {
			return new WP_Error( 'foodord_preorder_draft_update_failed', __( 'Unable to update your pre-order. Please try again.', 'foodord' ) );
		}

		return $this->build_draft_summary( $items );
	}

	/**
	 * Update a draft item quantity or note, or remove it when quantity reaches zero.
	 *
	 * @param string      $draft_token      Draft token.
	 * @param string      $customer_session Customer session key.
	 * @param string      $item_key         Draft item key.
	 * @param int         $quantity         New quantity.
	 * @param string|null $note        New note, or null to leave the note unchanged.
	 * @return array<string,mixed>|WP_Error
	 */
	public function update_draft_item( string $draft_token, string $customer_session, string $item_key, int $quantity, ?string $note = null ) {
		$draft = $this->get_draft_by_token( $draft_token, $customer_session );
		if ( ! $draft ) {
			return new WP_Error( 'foodord_preorder_draft_expired', __( 'Your pre-order session has expired. Please select a time again.', 'foodord' ) );
		}

		$item_key = sanitize_text_field( $item_key );
		$quantity = max( 0, min( 99, $quantity ) );
		$items    = $this->get_draft_items( $draft );
		$found    = false;
		foreach ( $items as $index => &$item ) {
			if ( (string) ( $item['item_key'] ?? '' ) !== $item_key ) {
				continue;
			}

			$found = true;
			if ( 0 === $quantity ) {
				unset( $items[ $index ] );
			} else {
				$item['quantity'] = $quantity;
				if ( null !== $note ) {
					$item['note']                    = self::sanitize_item_note( $note );
					$item['configuration_signature'] = $this->build_item_signature( $item );
				}
			}
			break;
		}
		unset( $item );

		if ( ! $found ) {
			return new WP_Error( 'foodord_preorder_item_not_found', __( 'This pre-order item could not be found.', 'foodord' ) );
		}

		$items = array_values( $items );
		if ( ! $this->save_draft_items( (int) $draft->id, $items ) ) {
			return new WP_Error( 'foodord_preorder_draft_update_failed', __( 'Unable to update your pre-order. Please try again.', 'foodord' ) );
		}

		return $this->build_draft_summary( $items );
	}

	/**
	 * Remove every item from an active draft without ending the pre-order flow.
	 *
	 * @param string $draft_token      Draft token.
	 * @param string $customer_session Customer session key.
	 * @return array<string,mixed>|WP_Error
	 */
	public function clear_draft_items( string $draft_token, string $customer_session ) {
		$draft = $this->get_draft_by_token( $draft_token, $customer_session );
		if ( ! $draft ) {
			return new WP_Error( 'foodord_preorder_draft_expired', __( 'Your pre-order session has expired. Please select a time again.', 'foodord' ) );
		}

		if ( ! $this->save_draft_items( (int) $draft->id, array() ) ) {
			return new WP_Error( 'foodord_preorder_draft_update_failed', __( 'Unable to update your pre-order. Please try again.', 'foodord' ) );
		}

		return $this->build_draft_summary( array() );
	}

	/**
	 * Sanitize a pre-order item note using the cart note limit.
	 *
	 * @param string $note Raw note.
	 */
	private static function sanitize_item_note( string $note ): string {
		$note = html_entity_decode( $note, ENT_QUOTES | ENT_HTML5, 'UTF-8' );
		$note = trim( (string) preg_replace( '/\s+/u', ' ', wp_strip_all_tags( $note ) ) );
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $note, 0, self::ITEM_NOTE_MAX_LENGTH );
		}

		return substr( $note, 0, self::ITEM_NOTE_MAX_LENGTH );
	}

	/**
	 * Get the current draft cart summary.
	 *
	 * @param string $draft_token      Draft token.
	 * @param string $customer_session Customer session key.
	 * @return array<string,mixed>|WP_Error
	 */
	public function get_draft_summary( string $draft_token, string $customer_session ) {
		$draft = $this->get_draft_by_token( $draft_token, $customer_session );
		if ( ! $draft ) {
			return new WP_Error( 'foodord_preorder_draft_expired', __( 'Your pre-order session has expired. Please select a time again.', 'foodord' ) );
		}

		return $this->build_draft_summary( $this->get_draft_items( $draft ) );
	}

	/**
	 * Revalidate every draft item immediately before creating an order.
	 *
	 * @param string $draft_token      Draft token.
	 * @param string $customer_session Customer session key.
	 * @return array<int,array<string,mixed>>|WP_Error
	 */
	public function get_validated_draft_items( string $draft_token, string $customer_session ) {
		$draft = $this->get_draft_by_token( $draft_token, $customer_session );
		if ( ! $draft ) {
			return new WP_Error( 'foodord_preorder_draft_expired', __( 'Your pre-order session has expired. Please select a time again.', 'foodord' ) );
		}

		$validated = array();
		foreach ( $this->get_draft_items( $draft ) as $item ) {
			$product_id = absint( $item['product_id'] ?? 0 );
			if ( ! self::is_product_enabled( $product_id ) ) {
				return new WP_Error( 'foodord_preorder_product_not_enabled', __( 'A selected product is no longer available for reservation pre-order.', 'foodord' ) );
			}

			$item['foodord_toppings'] = $this->structured_toppings_to_raw( $item['toppings'] ?? array() );
			$normalized               = $this->normalize_product_configuration( $item );
			if ( is_wp_error( $normalized ) ) {
				return $normalized;
			}

			$normalized['item_key'] = sanitize_text_field( (string) ( $item['item_key'] ?? '' ) );
			$validated[]            = $normalized;
		}

		return $validated;
	}

	/**
	 * Convert an active hold draft into an unpaid payment-editing draft.
	 *
	 * @param string $draft_token      Draft token.
	 * @param string $customer_session Customer session key.
	 * @param int    $reservation_id   Reservation ID.
	 * @param int    $order_id         WooCommerce order ID.
	 * @param string $expires_at_gmt   Payment expiry in GMT.
	 */
	public function attach_payment_order( string $draft_token, string $customer_session, int $reservation_id, int $order_id, string $expires_at_gmt ): bool {
		$draft_token      = self::sanitize_token( $draft_token );
		$customer_session = self::sanitize_session( $customer_session );
		$reservation_id   = absint( $reservation_id );
		$order_id         = absint( $order_id );
		$expires_at_gmt   = sanitize_text_field( $expires_at_gmt );
		if ( '' === $draft_token || '' === $customer_session || $reservation_id <= 0 || $order_id <= 0 || false === strtotime( $expires_at_gmt . ' UTC' ) ) {
			return false;
		}

		$updated = self::db()->update(
			self::draft_table_name(),
			array(
				'reservation_id' => $reservation_id,
				'order_id'       => $order_id,
				'status'         => 'payment_edit',
				'expires_at_gmt' => $expires_at_gmt,
				'updated_at_gmt' => current_time( 'mysql', true ),
			),
			array(
				'draft_token'      => $draft_token,
				'customer_session' => $customer_session,
				'status'           => 'active',
			),
			array( '%d', '%d', '%s', '%s', '%s' ),
			array( '%s', '%s', '%s' )
		);

		return 1 === $updated;
	}

	/**
	 * Get an active payment-editing draft by WooCommerce order ID.
	 *
	 * @param int $order_id WooCommerce order ID.
	 * @return object|null
	 */
	public function get_payment_edit_draft_by_order( int $order_id ) {
		$order_id = absint( $order_id );
		if ( $order_id <= 0 ) {
			return null;
		}

		return self::db()->get_row(
			self::db()->prepare(
				'SELECT * FROM %i WHERE order_id = %d AND status = %s AND expires_at_gmt > %s',
				self::draft_table_name(),
				$order_id,
				'payment_edit',
				current_time( 'mysql', true )
			)
		);
	}

	/**
	 * Determine whether a draft belongs to an unpaid payment-editing flow.
	 *
	 * @param object|null $draft Draft row.
	 */
	public static function is_payment_edit_draft( $draft ): bool {
		return is_object( $draft )
			&& 'payment_edit' === (string) ( $draft->status ?? '' )
			&& absint( $draft->reservation_id ?? 0 ) > 0
			&& absint( $draft->order_id ?? 0 ) > 0;
	}

	/**
	 * Delete a payment-editing draft after payment reaches a terminal state.
	 *
	 * @param int $order_id WooCommerce order ID.
	 */
	public static function delete_draft_by_order_id( int $order_id ): void {
		$order_id = absint( $order_id );
		if ( $order_id <= 0 || self::DB_VERSION !== get_option( self::OPTION_DB_VERSION ) ) {
			return;
		}

		self::db()->delete( self::draft_table_name(), array( 'order_id' => $order_id ), array( '%d' ) );
	}

	/**
	 * Delete a draft attached to a released or converted hold.
	 *
	 * @param string $hold_token Hold token.
	 */
	public static function delete_draft_by_hold_token( string $hold_token ): void {
		$hold_token = self::sanitize_token( $hold_token );
		if ( '' === $hold_token || self::DB_VERSION !== get_option( self::OPTION_DB_VERSION ) ) {
			return;
		}

		self::db()->delete( self::draft_table_name(), array( 'hold_token' => $hold_token ), array( '%s' ) );
	}

	/**
	 * Remove expired and orphaned drafts.
	 *
	 * @return int Number of removed drafts.
	 */
	public function cleanup_expired_drafts(): int {
		$db           = self::db();
		$drafts       = self::draft_table_name();
		$tables       = Foodord_Table_Reservations::table_names();
		$holds        = $tables['holds'];
		$reservations = $tables['reservations'];
		$now          = current_time( 'mysql', true );

		return (int) $db->query(
			$db->prepare(
				'DELETE drafts FROM %i AS drafts LEFT JOIN %i AS holds ON holds.hold_token = drafts.hold_token LEFT JOIN %i AS reservations ON reservations.id = drafts.reservation_id WHERE drafts.expires_at_gmt <= %s OR (drafts.status = %s AND (holds.id IS NULL OR holds.expires_at_gmt <= %s)) OR (drafts.status = %s AND (reservations.id IS NULL OR reservations.order_id <> drafts.order_id OR reservations.payment_status <> %s))',
				$drafts,
				$holds,
				$reservations,
				$now,
				'active',
				$now,
				'payment_edit',
				'requires_payment'
			)
		);
	}

	/**
	 * Get pre-order mode options.
	 *
	 * @return array<string,string>
	 */
	public static function get_mode_options(): array {
		return array(
			'optional' => __( 'Optional', 'foodord' ),
			'required' => __( 'Required', 'foodord' ),
		);
	}

	/**
	 * Get pre-order applicability options.
	 *
	 * @return array<string,string>
	 */
	public static function get_applicability_options(): array {
		return array(
			'all_times'      => __( 'At All Times', 'foodord' ),
			'specific_times' => __( 'Specific Days/Times', 'foodord' ),
		);
	}

	/**
	 * Get supported cutoff values in hours.
	 *
	 * @return int[]
	 */
	public static function get_cutoff_options(): array {
		return self::CUTOFF_OPTIONS;
	}

	/**
	 * Get sanitized pre-order settings.
	 *
	 * @return array{enabled:bool,mode:string,minimum_party_size:int,cutoff_hours:int,applicable:string,schedule:array<int,array{enabled:bool,ranges:array<int,array{from:string,to:string}>}>}
	 */
	public static function get_settings(): array {
		$settings = get_option( self::OPTION_SETTINGS, array() );

		return self::sanitize_settings( is_array( $settings ) ? $settings : array() );
	}

	/**
	 * Save sanitized pre-order settings.
	 *
	 * @param array<string,mixed> $settings Raw settings.
	 */
	public static function save_settings( array $settings ): void {
		update_option( self::OPTION_SETTINGS, self::sanitize_settings( $settings ), false );
	}

	/**
	 * Check whether a product is available for reservation pre-orders.
	 *
	 * @param int $product_id Product ID.
	 */
	public static function is_product_enabled( int $product_id ): bool {
		$product_id = absint( $product_id );

		return $product_id > 0 && 'no' !== get_post_meta( $product_id, self::PRODUCT_META_ENABLED, true );
	}

	/**
	 * Check whether pre-order rules apply to a reservation slot.
	 *
	 * @param string $date       Reservation date in Y-m-d format.
	 * @param string $time       Reservation time in H:i format.
	 * @param int    $party_size Reservation party size.
	 */
	public static function rules_apply_to_slot( string $date, string $time, int $party_size ): bool {
		$settings = self::get_settings();
		if ( ! $settings['enabled'] || max( 1, absint( $party_size ) ) < $settings['minimum_party_size'] ) {
			return false;
		}

		$date_time = self::parse_reservation_datetime( $date, $time );
		if ( ! $date_time instanceof DateTimeImmutable ) {
			return false;
		}

		if ( 'specific_times' !== $settings['applicable'] ) {
			return true;
		}

		$weekday = (int) $date_time->format( 'w' );
		$day     = $settings['schedule'][ $weekday ] ?? array();

		return ! empty( $day['enabled'] ) && self::time_is_inside_ranges( $date_time->format( 'H:i' ), $day['ranges'] ?? array() );
	}

	/**
	 * Check whether the configured cutoff is satisfied for a reservation slot.
	 *
	 * @param string $date Reservation date in Y-m-d format.
	 * @param string $time Reservation time in H:i format.
	 */
	public static function cutoff_is_satisfied( string $date, string $time ): bool {
		$date_time = self::parse_reservation_datetime( $date, $time );
		if ( ! $date_time instanceof DateTimeImmutable ) {
			return false;
		}

		$settings        = self::get_settings();
		$cutoff_deadline = $date_time->modify( '-' . $settings['cutoff_hours'] . ' hours' );

		return current_datetime() < $cutoff_deadline;
	}

	/**
	 * Check whether pre-orders are available for a reservation slot.
	 *
	 * @param string $date       Reservation date in Y-m-d format.
	 * @param string $time       Reservation time in H:i format.
	 * @param int    $party_size Reservation party size.
	 */
	public static function is_available_for_slot( string $date, string $time, int $party_size ): bool {
		return self::rules_apply_to_slot( $date, $time, $party_size ) && self::cutoff_is_satisfied( $date, $time );
	}

	/**
	 * Check whether a reservation slot requires a pre-order.
	 *
	 * @param string $date       Reservation date in Y-m-d format.
	 * @param string $time       Reservation time in H:i format.
	 * @param int    $party_size Reservation party size.
	 */
	public static function is_required_for_slot( string $date, string $time, int $party_size ): bool {
		$settings = self::get_settings();

		return 'required' === $settings['mode'] && self::rules_apply_to_slot( $date, $time, $party_size );
	}

	/**
	 * Get the WordPress database adapter.
	 *
	 * @return wpdb
	 */
	private static function db() {
		global $wpdb;

		return $wpdb;
	}

	/**
	 * Get the pre-order draft table name.
	 */
	private static function draft_table_name(): string {
		return self::db()->prefix . 'foodord_reservation_preorder_drafts';
	}

	/**
	 * Get a draft by hold and session.
	 *
	 * @param string $hold_token       Hold token.
	 * @param string $customer_session Customer session key.
	 * @return object|null
	 */
	private function get_draft_by_hold( string $hold_token, string $customer_session ) {
		$db = self::db();

		return $db->get_row(
			$db->prepare(
				'SELECT * FROM %i WHERE hold_token = %s AND customer_session = %s',
				self::draft_table_name(),
				$hold_token,
				$customer_session
			)
		);
	}

	/**
	 * Get a draft by token and session, optionally requiring an active state.
	 *
	 * @param string $draft_token      Draft token.
	 * @param string $customer_session Customer session key.
	 * @return object|null
	 */
	private function get_draft_by_token( string $draft_token, string $customer_session ) {
		$db               = self::db();
		$draft_token      = self::sanitize_token( $draft_token );
		$customer_session = self::sanitize_session( $customer_session );
		if ( '' === $draft_token || '' === $customer_session ) {
			return null;
		}

		$drafts       = self::draft_table_name();
		$tables       = Foodord_Table_Reservations::table_names();
		$holds        = $tables['holds'];
		$reservations = $tables['reservations'];
		$now          = current_time( 'mysql', true );

		return $db->get_row(
			$db->prepare(
				'SELECT drafts.*, COALESCE(holds.table_id, reservations.table_id) AS table_id, COALESCE(holds.reservation_date, reservations.reservation_date) AS reservation_date, COALESCE(holds.reservation_time, reservations.reservation_time) AS reservation_time, COALESCE(holds.duration_minutes, reservations.duration_minutes) AS duration_minutes, COALESCE(holds.party_size, reservations.party_size) AS party_size, COALESCE(holds.expires_at_gmt, drafts.expires_at_gmt) AS hold_expires_at_gmt FROM %i AS drafts LEFT JOIN %i AS holds ON holds.hold_token = drafts.hold_token LEFT JOIN %i AS reservations ON reservations.id = drafts.reservation_id WHERE drafts.draft_token = %s AND drafts.customer_session = %s AND drafts.expires_at_gmt > %s AND ((drafts.status = %s AND holds.id IS NOT NULL AND holds.expires_at_gmt > %s) OR (drafts.status = %s AND reservations.id = drafts.reservation_id AND reservations.order_id = drafts.order_id AND reservations.status = %s AND reservations.payment_status = %s))',
				$drafts,
				$holds,
				$reservations,
				$draft_token,
				$customer_session,
				$now,
				'active',
				$now,
				'payment_edit',
				'paid_pending',
				'requires_payment'
			)
		);
	}

	/**
	 * Reactivate a previously cancelled draft without extending its hold.
	 *
	 * @param int    $draft_id      Draft ID.
	 * @param string $expires_at_gmt Current hold expiry.
	 */
	private function reactivate_draft( int $draft_id, string $expires_at_gmt ): void {
		self::db()->update(
			self::draft_table_name(),
			array(
				'status'         => 'active',
				'expires_at_gmt' => $expires_at_gmt,
				'updated_at_gmt' => current_time( 'mysql', true ),
			),
			array( 'id' => $draft_id ),
			array( '%s', '%s', '%s' ),
			array( '%d' )
		);
	}

	/**
	 * Check whether a hold belongs to the requesting browser session.
	 *
	 * @param object|null $hold             Hold row.
	 * @param string      $customer_session Customer session key.
	 */
	private function hold_belongs_to_session( $hold, string $customer_session ): bool {
		return is_object( $hold )
			&& '' !== (string) ( $hold->customer_session ?? '' )
			&& hash_equals( (string) $hold->customer_session, $customer_session );
	}

	/**
	 * Format a draft response for the frontend lifecycle client.
	 *
	 * @param object $draft    Draft row.
	 * @param object $hold     Hold row.
	 * @param bool   $extended Whether the hold was extended in this request.
	 * @return array<string,mixed>
	 */
	private function format_draft( $draft, $hold, bool $extended ): array {
		$expires_at = (string) ( $hold->hold_expires_at_gmt ?? $hold->expires_at_gmt ?? $draft->expires_at_gmt );

		return array(
			'draft_token'     => (string) $draft->draft_token,
			'hold_token'      => (string) $draft->hold_token,
			'expires_at_gmt'  => $expires_at,
			'ttl_seconds'     => max( 0, strtotime( $expires_at . ' UTC' ) - time() ),
			'hold_extended'   => $extended,
			'preorder_status' => (string) $draft->status,
		);
	}

	/**
	 * Sanitize a generated lifecycle token.
	 *
	 * @param string $token Raw token.
	 */
	private static function sanitize_token( string $token ): string {
		$token = sanitize_text_field( $token );

		return 1 === preg_match( '/^[A-Za-z0-9]{64}$/', $token ) ? $token : '';
	}

	/**
	 * Sanitize a customer session key.
	 *
	 * @param string $customer_session Raw session key.
	 */
	private static function sanitize_session( string $customer_session ): string {
		return substr( sanitize_text_field( $customer_session ), 0, 64 );
	}

	/**
	 * Normalize a product configuration through the shared product validator.
	 *
	 * @param array<string,mixed> $configuration Raw configuration.
	 * @return array<string,mixed>|WP_Error
	 */
	private function normalize_product_configuration( array $configuration ) {
		if ( ! class_exists( 'Foodord_Product_Modal' ) ) {
			return new WP_Error( 'foodord_preorder_product_validator_missing', __( 'Pre-ordering is not available right now.', 'foodord' ) );
		}

		$toppings  = isset( $configuration['foodord_toppings'] ) && is_array( $configuration['foodord_toppings'] )
			? self::sanitize_toppings_input( $configuration['foodord_toppings'] )
			: array();
		$validator = new Foodord_Product_Modal();

		self::begin_pricing_rules_bypass();
		try {
			$normalized = $validator->normalize_external_order_item(
				absint( $configuration['product_id'] ?? 0 ),
				absint( $configuration['variation_id'] ?? 0 ),
				absint( $configuration['quantity'] ?? 1 ),
				$toppings,
				sanitize_textarea_field( (string) ( $configuration['note'] ?? '' ) )
			);
		} finally {
			self::end_pricing_rules_bypass();
		}

		if ( is_wp_error( $normalized ) ) {
			return $normalized;
		}

		$product_id      = absint( $normalized['variation_id'] ?? 0 );
		$product_id      = $product_id > 0 ? $product_id : absint( $normalized['product_id'] ?? 0 );
		$product         = function_exists( 'wc_get_product' ) ? wc_get_product( $product_id ) : false;
		$regular_price   = $product instanceof WC_Product ? $product->get_regular_price( 'edit' ) : '';
		$toppings_extra  = max( 0.0, (float) ( $normalized['toppings_extra'] ?? 0 ) );
		$current_base    = max( 0.0, (float) ( $normalized['base_unit_price'] ?? 0 ) );
		$current_total   = max( 0.0, (float) ( $normalized['unit_price'] ?? 0 ) );
		$topping_tax_fee = max( 0.0, $current_total - $current_base );
		$base_unit_price = '' !== (string) $regular_price ? max( 0.0, (float) $regular_price ) + $toppings_extra : $current_base;

		$normalized['base_unit_price'] = $base_unit_price;
		$normalized['unit_price']      = $base_unit_price + $topping_tax_fee;

		return $normalized;
	}

	/**
	 * Get items stored in a draft payload.
	 *
	 * @param object $draft Draft row.
	 * @return array<int,array<string,mixed>>
	 */
	private function get_draft_items( $draft ): array {
		$payload = isset( $draft->payload ) && is_string( $draft->payload ) ? json_decode( $draft->payload, true ) : array();
		$items   = is_array( $payload ) && isset( $payload['items'] ) && is_array( $payload['items'] ) ? $payload['items'] : array();

		return array_values(
			array_filter(
				$items,
				static function ( $item ): bool {
					return is_array( $item ) && ! empty( $item['item_key'] ) && ! empty( $item['product_id'] );
				}
			)
		);
	}

	/**
	 * Persist draft items.
	 *
	 * @param int                            $draft_id Draft ID.
	 * @param array<int,array<string,mixed>> $items    Draft items.
	 */
	private function save_draft_items( int $draft_id, array $items ): bool {
		$payload = wp_json_encode( array( 'items' => array_values( $items ) ) );
		if ( false === $payload ) {
			return false;
		}

		return false !== self::db()->update(
			self::draft_table_name(),
			array(
				'payload'        => $payload,
				'updated_at_gmt' => current_time( 'mysql', true ),
			),
			array(
				'id' => absint( $draft_id ),
			),
			array( '%s', '%s' ),
			array( '%d' )
		);
	}

	/**
	 * Build a stable identity for mergeable draft items.
	 *
	 * @param array<string,mixed> $item Normalized item.
	 */
	private function build_item_signature( array $item ): string {
		return hash(
			'sha256',
			implode(
				'|',
				array(
					absint( $item['product_id'] ?? 0 ),
					absint( $item['variation_id'] ?? 0 ),
					sanitize_text_field( (string) ( $item['toppings_signature'] ?? '' ) ),
					sanitize_textarea_field( (string) ( $item['note'] ?? '' ) ),
				)
			)
		);
	}

	/**
	 * Build a public summary from canonical draft items.
	 *
	 * @param array<int,array<string,mixed>> $items Draft items.
	 * @return array<string,mixed>
	 */
	private function build_draft_summary( array $items ): array {
		$summary_items = array();
		$total         = 0.0;
		$count         = 0;

		foreach ( $items as $item ) {
			$quantity          = max( 1, absint( $item['quantity'] ?? 1 ) );
			$unit_price        = max( 0.0, (float) ( $item['unit_price'] ?? 0 ) );
			$line_total        = $unit_price * $quantity;
			$count            += $quantity;
			$total            += $line_total;
			$product_lookup_id = absint( $item['variation_id'] ?? 0 );
			if ( $product_lookup_id <= 0 ) {
				$product_lookup_id = absint( $item['product_id'] ?? 0 );
			}
			$product   = function_exists( 'wc_get_product' ) ? wc_get_product( $product_lookup_id ) : false;
			$image_id  = $product instanceof WC_Product ? (int) $product->get_image_id() : 0;
			$image_url = $image_id > 0 ? wp_get_attachment_image_url( $image_id, 'woocommerce_thumbnail' ) : false;

			$summary_items[] = array(
				'itemKey'       => sanitize_text_field( (string) ( $item['item_key'] ?? '' ) ),
				'productId'     => absint( $item['product_id'] ?? 0 ),
				'variationId'   => absint( $item['variation_id'] ?? 0 ),
				'name'          => sanitize_text_field( (string) ( $item['product_name'] ?? '' ) ),
				'variation'     => self::get_variation_labels( $product, (string) ( $item['variation_name'] ?? '' ) ),
				'quantity'      => $quantity,
				'unitPriceHtml' => function_exists( 'wc_price' ) ? wc_price( $unit_price ) : (string) $unit_price,
				'lineTotalHtml' => function_exists( 'wc_price' ) ? wc_price( $line_total ) : (string) $line_total,
				'unitPriceText' => self::format_money_text( $unit_price ),
				'lineTotalText' => self::format_money_text( $line_total ),
				'imageUrl'      => is_string( $image_url ) ? $image_url : '',
				'toppings'      => self::get_topping_labels( $item['toppings'] ?? array() ),
				'note'          => self::sanitize_item_note( (string) ( $item['note'] ?? '' ) ),
			);
		}

		return array(
			'items'     => $summary_items,
			'itemCount' => $count,
			'total'     => $total,
			'totalHtml' => function_exists( 'wc_price' ) ? wc_price( $total ) : (string) $total,
			'totalText' => self::format_money_text( $total ),
		);
	}

	/**
	 * Get variation attributes as individual display lines.
	 *
	 * @param WC_Product|false $product  Product used by the draft item.
	 * @param string           $fallback Stored formatted variation name.
	 * @return string[]
	 */
	private static function get_variation_labels( $product, string $fallback ): array {
		$labels = array();
		if ( $product instanceof WC_Product_Variation && function_exists( 'wc_get_formatted_variation' ) ) {
			$attributes = wc_get_formatted_variation( $product, false, true, false );
			if ( is_array( $attributes ) ) {
				foreach ( $attributes as $attribute_label => $attribute_value ) {
					$label = sanitize_text_field( wp_strip_all_tags( (string) $attribute_label ) );
					$value = sanitize_text_field( wp_strip_all_tags( (string) $attribute_value ) );
					if ( '' !== $value ) {
						$labels[] = '' !== $label ? $label . ': ' . $value : $value;
					}
				}
			}
		}

		$fallback = sanitize_text_field( $fallback );
		if ( empty( $labels ) && '' !== $fallback ) {
			$labels[] = $fallback;
		}

		return $labels;
	}

	/**
	 * Format a monetary amount as plain text for JSON responses.
	 *
	 * @param float $amount Amount.
	 */
	private static function format_money_text( float $amount ): string {
		$html = function_exists( 'wc_price' ) ? wc_price( $amount ) : (string) $amount;
		$text = preg_replace( '/\s+/u', ' ', html_entity_decode( wp_strip_all_tags( $html ), ENT_QUOTES, 'UTF-8' ) );

		return is_string( $text ) ? trim( $text ) : '';
	}

	/**
	 * Sanitize topping selections while bounding the request size.
	 *
	 * @param array<string,mixed> $toppings Raw toppings.
	 * @return array<string,mixed>
	 */
	private static function sanitize_toppings_input( array $toppings ): array {
		$result = array();
		foreach ( array_slice( $toppings, 0, 24, true ) as $set_id => $selection ) {
			$set_id = sanitize_text_field( (string) $set_id );
			if ( '' === $set_id ) {
				continue;
			}

			if ( is_array( $selection ) ) {
				$result[ $set_id ] = array_values(
					array_filter(
						array_map( 'sanitize_text_field', array_slice( $selection, 0, 50 ) )
					)
				);
				continue;
			}

			$result[ $set_id ] = sanitize_text_field( (string) $selection );
		}

		return $result;
	}

	/**
	 * Convert canonical topping sets back into a validation request map.
	 *
	 * @param mixed $sets Canonical topping sets.
	 * @return array<string,mixed>
	 */
	private function structured_toppings_to_raw( $sets ): array {
		$result = array();
		foreach ( is_array( $sets ) ? $sets : array() as $set ) {
			if ( ! is_array( $set ) || empty( $set['set_id'] ) ) {
				continue;
			}

			$selected = array();
			foreach ( isset( $set['items'] ) && is_array( $set['items'] ) ? $set['items'] : array() as $item ) {
				if ( is_array( $item ) && isset( $item['id'] ) ) {
					$selected[] = sanitize_text_field( (string) $item['id'] );
				}
			}

			$result[ sanitize_text_field( (string) $set['set_id'] ) ] = 'checkbox' === (string) ( $set['group_type'] ?? '' ) ? $selected : ( $selected[0] ?? '' );
		}

		return $result;
	}

	/**
	 * Get flattened topping labels for cart presentation.
	 *
	 * @param mixed $sets Canonical topping sets.
	 * @return string[]
	 */
	private static function get_topping_labels( $sets ): array {
		$labels = array();
		foreach ( is_array( $sets ) ? $sets : array() as $set ) {
			foreach ( is_array( $set ) && isset( $set['items'] ) && is_array( $set['items'] ) ? $set['items'] : array() as $item ) {
				if ( is_array( $item ) && ! empty( $item['label'] ) ) {
					$labels[] = sanitize_text_field( (string) $item['label'] );
				}
			}
		}

		return $labels;
	}

	/**
	 * Sanitize pre-order settings.
	 *
	 * @param array<string,mixed> $settings Raw settings.
	 * @return array{enabled:bool,mode:string,minimum_party_size:int,cutoff_hours:int,applicable:string,schedule:array<int,array{enabled:bool,ranges:array<int,array{from:string,to:string}>}>}
	 */
	private static function sanitize_settings( array $settings ): array {
		$mode       = sanitize_key( (string) ( $settings['mode'] ?? 'optional' ) );
		$applicable = sanitize_key( (string) ( $settings['applicable'] ?? 'all_times' ) );
		$cutoff     = absint( $settings['cutoff_hours'] ?? self::DEFAULT_CUTOFF_HOURS );

		if ( ! array_key_exists( $mode, self::get_mode_options() ) ) {
			$mode = 'optional';
		}

		if ( ! array_key_exists( $applicable, self::get_applicability_options() ) ) {
			$applicable = 'all_times';
		}

		if ( ! in_array( $cutoff, self::CUTOFF_OPTIONS, true ) ) {
			$cutoff = self::DEFAULT_CUTOFF_HOURS;
		}

		return array(
			'enabled'            => ! empty( $settings['enabled'] ),
			'mode'               => $mode,
			'minimum_party_size' => min( 999, max( 1, absint( $settings['minimum_party_size'] ?? 1 ) ) ),
			'cutoff_hours'       => $cutoff,
			'applicable'         => $applicable,
			'schedule'           => self::sanitize_schedule( $settings['schedule'] ?? array() ),
		);
	}

	/**
	 * Sanitize a weekly pre-order schedule.
	 *
	 * @param mixed $schedule Raw schedule.
	 * @return array<int,array{enabled:bool,ranges:array<int,array{from:string,to:string}>}>
	 */
	private static function sanitize_schedule( $schedule ): array {
		$schedule = is_array( $schedule ) ? $schedule : array();
		$result   = array();

		for ( $day = 0; $day <= 6; $day++ ) {
			$raw_day = isset( $schedule[ $day ] ) && is_array( $schedule[ $day ] ) ? $schedule[ $day ] : array();
			$ranges  = self::sanitize_ranges( $raw_day['ranges'] ?? array() );

			if ( empty( $ranges ) ) {
				$ranges[] = array(
					'from' => '18:00',
					'to'   => '23:00',
				);
			}

			$result[ $day ] = array(
				'enabled' => ! empty( $raw_day['enabled'] ),
				'ranges'  => $ranges,
			);
		}

		return $result;
	}

	/**
	 * Sanitize pre-order time ranges.
	 *
	 * @param mixed $ranges Raw ranges.
	 * @return array<int,array{from:string,to:string}>
	 */
	private static function sanitize_ranges( $ranges ): array {
		if ( ! is_array( $ranges ) ) {
			return array();
		}

		$result = array();
		foreach ( $ranges as $range ) {
			if ( ! is_array( $range ) ) {
				continue;
			}

			$from = self::sanitize_time( $range['from'] ?? '' );
			$to   = self::sanitize_time( $range['to'] ?? '' );
			if ( '' === $from || '' === $to || $from >= $to ) {
				continue;
			}

			$result[] = array(
				'from' => $from,
				'to'   => $to,
			);
		}

		return array_slice( $result, 0, 12 );
	}

	/**
	 * Check whether a time falls inside configured ranges.
	 *
	 * @param string $time   Time in H:i format.
	 * @param array  $ranges Time ranges.
	 */
	private static function time_is_inside_ranges( string $time, array $ranges ): bool {
		foreach ( $ranges as $range ) {
			$from = isset( $range['from'] ) ? (string) $range['from'] : '';
			$to   = isset( $range['to'] ) ? (string) $range['to'] : '';
			if ( '' !== $from && '' !== $to && $time >= $from && $time <= $to ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Parse a reservation date and time in the WordPress timezone.
	 *
	 * @param string $date Reservation date.
	 * @param string $time Reservation time.
	 * @return DateTimeImmutable|false
	 */
	private static function parse_reservation_datetime( string $date, string $time ) {
		$date      = sanitize_text_field( $date );
		$time      = self::sanitize_time( $time );
		$date_time = DateTimeImmutable::createFromFormat( '!Y-m-d H:i', $date . ' ' . $time, wp_timezone() );

		if ( ! $date_time instanceof DateTimeImmutable || $date_time->format( 'Y-m-d H:i' ) !== $date . ' ' . $time ) {
			return false;
		}

		return $date_time;
	}

	/**
	 * Sanitize a time value.
	 *
	 * @param mixed $time Raw time.
	 */
	private static function sanitize_time( $time ): string {
		$time = is_scalar( $time ) ? sanitize_text_field( (string) $time ) : '';
		if ( ! preg_match( '/^((?:[01]\d|2[0-3])):([0-5]\d)(?::[0-5]\d)?$/', $time, $matches ) ) {
			return '';
		}

		return $matches[1] . ':' . $matches[2];
	}
}
