<?php
/**
 * Table reservations core storage and availability service.
 *
 * @package FoodOrd
 */

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

/**
 * Class Foodord_Table_Reservations
 */
class Foodord_Table_Reservations {
	private const DB_VERSION                = '1.3.3';
	private const OPTION_VERSION            = 'foodord_table_reservations_db_version';
	private const DEFAULT_DURATION_MINUTES  = 90;
	private const MIN_DURATION_MINUTES      = 15;
	private const MAX_DURATION_MINUTES      = 240;
	private const DURATION_STEP_MINUTES     = 15;
	private const HOLD_TTL_SECONDS          = 300;
	private const STAFF_HOLD_TTL_SECONDS    = 30;
	private const ALLOCATION_LOCK_TIMEOUT   = 3;
	private const HOLD_RATE_WINDOW_SECONDS  = 600;
	private const HOLD_RATE_MAX_STARTS      = 3;
	private const HOLD_STRIKE_RESET_SECONDS = DAY_IN_SECONDS;
	private const HOLD_COOLDOWN_SECONDS     = array(
		2 => 600,
		3 => 1800,
		4 => DAY_IN_SECONDS,
	);
	private const OPTION_BOOKING_SCHEDULE   = 'foodord_table_reservation_booking_schedule';
	private const OPTION_HOLIDAY_RANGES     = 'foodord_table_reservation_holiday_ranges';
	private const OPTION_AUTO_CONFIRM       = 'foodord_table_reservation_auto_confirm';
	private const OPTION_CUSTOMER_MANAGE    = 'foodord_table_reservation_customer_manage';
	private const OPTION_CANCEL_CUTOFF      = 'foodord_table_reservation_customer_cancel_cutoff';
	private const OPTION_MAX_ADVANCE_DAYS   = 'foodord_table_reservation_max_advance_days';
	private const OPTION_LATE_BOOKING       = 'foodord_table_reservation_late_booking';
	private const OPTION_CAPACITY_LIMITS    = 'foodord_table_reservation_capacity_limits';
	private const OPTION_TABLE_SELECTION    = 'foodord_table_reservation_table_selection';
	private const OPTION_RECAPTCHA          = 'foodord_table_reservation_recaptcha';
	private const OPTION_BLOCKLIST          = 'foodord_table_reservation_blocklist';
	private const OPTION_PRIVACY            = 'foodord_table_reservation_privacy';
	private const OPTION_DEPOSIT            = 'foodord_table_reservation_deposit';
	private const OPTION_SYNC_REVISION      = 'foodord_table_reservation_sync_revision';
	private const OPTION_FLOOR_PLAN_REV     = 'foodord_table_reservation_floor_plan_revision';
	private const OPTION_COMPLETION_LOCK    = 'foodord_table_reservation_completion_lock';
	private const DEFAULT_SLOT_INTERVAL     = 30;
	private const IMAGE_MARKER_WIDTH        = 9;
	private const IMAGE_MARKER_HEIGHT       = 7;
	private const COMPLETION_LOCK_SECONDS   = MINUTE_IN_SECONDS;
	private const LATE_BOOKING_SAME_DAY     = 'same_day_block';
	private const DEFAULT_RECAPTCHA_SCORE   = 0.5;
	private const RECAPTCHA_SITEVERIFY_URL  = 'https://www.google.com/recaptcha/api/siteverify';
	private const COMPLETION_CRON_HOOK      = 'foodord_table_reservations_complete_ended';

	/**
	 * Whether ended reservations were reconciled during the current request.
	 *
	 * @var bool
	 */
	private static $ended_reservations_reconciled = false;

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

	/**
	 * Register automatic reservation completion hooks.
	 */
	public static function init_status_automation(): void {
		add_action( self::COMPLETION_CRON_HOOK, array( __CLASS__, 'complete_ended_reservations' ) );

		if ( false === wp_next_scheduled( self::COMPLETION_CRON_HOOK ) ) {
			wp_schedule_event( time() + MINUTE_IN_SECONDS, 'hourly', self::COMPLETION_CRON_HOOK );
		}
	}

	/**
	 * Remove automatic reservation completion hooks.
	 */
	public static function clear_status_automation(): void {
		wp_clear_scheduled_hook( self::COMPLETION_CRON_HOOK );
	}

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

		return $wpdb;
	}

	/**
	 * Return database table names.
	 *
	 * @return array{floors:string,tables:string,holds:string,reservations:string,hold_abuse:string}
	 */
	public static function table_names(): array {
		$db = self::db();

		return array(
			'floors'       => $db->prefix . 'foodord_reservation_floors',
			'tables'       => $db->prefix . 'foodord_reservation_tables',
			'holds'        => $db->prefix . 'foodord_reservation_holds',
			'reservations' => $db->prefix . 'foodord_reservations',
			'hold_abuse'   => $db->prefix . 'foodord_reservation_hold_abuse',
		);
	}

	/**
	 * Install or update reservation database tables.
	 */
	public static function install(): void {
		require_once ABSPATH . 'wp-admin/includes/upgrade.php';

		$db              = self::db();
		$tables          = self::table_names();
		$charset_collate = $db->get_charset_collate();

		dbDelta(
			"CREATE TABLE {$tables['floors']} (
				id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
				name varchar(120) NOT NULL,
				sort_order int(11) NOT NULL DEFAULT 0,
				created_at_gmt datetime NOT NULL,
				updated_at_gmt datetime NOT NULL,
				PRIMARY KEY  (id),
				KEY sort_order (sort_order)
			) {$charset_collate};"
		);

		dbDelta(
			"CREATE TABLE {$tables['tables']} (
				id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
				floor_id bigint(20) unsigned NOT NULL DEFAULT 0,
				name varchar(120) NOT NULL,
				capacity smallint(5) unsigned NOT NULL DEFAULT 2,
				reservation_duration_minutes smallint(5) unsigned NOT NULL DEFAULT 90,
				is_active tinyint(1) NOT NULL DEFAULT 1,
				sort_order int(11) NOT NULL DEFAULT 0,
				created_at_gmt datetime NOT NULL,
				updated_at_gmt datetime NOT NULL,
				PRIMARY KEY  (id),
				KEY floor_id (floor_id),
				KEY is_active (is_active),
				KEY capacity (capacity),
				KEY sort_order (sort_order)
			) {$charset_collate};"
		);

		dbDelta(
			"CREATE TABLE {$tables['holds']} (
				id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
				hold_token char(64) NOT NULL,
				table_id bigint(20) unsigned NOT NULL,
				reservation_date date NOT NULL,
				reservation_time time NOT NULL,
				duration_minutes smallint(5) unsigned NOT NULL DEFAULT 90,
				party_size smallint(5) unsigned NOT NULL DEFAULT 1,
				customer_session varchar(64) NOT NULL DEFAULT '',
				abuse_identity_hash char(64) NOT NULL DEFAULT '',
				is_public_hard_hold tinyint(1) unsigned NOT NULL DEFAULT 0,
				expires_at_gmt datetime NOT NULL,
				created_at_gmt datetime NOT NULL,
				PRIMARY KEY  (id),
				UNIQUE KEY hold_token (hold_token),
				KEY table_slot (table_id,reservation_date,reservation_time),
				KEY expires_at_gmt (expires_at_gmt),
				KEY customer_session (customer_session),
				KEY abuse_identity_hash (abuse_identity_hash),
				KEY public_hard_hold (is_public_hard_hold,expires_at_gmt)
			) {$charset_collate};"
		);

		dbDelta(
			"CREATE TABLE {$tables['reservations']} (
				id bigint(20) unsigned NOT NULL AUTO_INCREMENT,
				reservation_code varchar(24) NOT NULL,
				table_id bigint(20) unsigned NOT NULL DEFAULT 0,
				hold_token char(64) NOT NULL DEFAULT '',
				hold_abuse_identity_hash char(64) NOT NULL DEFAULT '',
				order_id bigint(20) unsigned NOT NULL DEFAULT 0,
				quota_permit_token text NULL,
				quota_permit_status varchar(20) NOT NULL DEFAULT '',
				status varchar(24) NOT NULL DEFAULT 'pending',
				payment_status varchar(24) NOT NULL DEFAULT 'none',
				deposit_amount decimal(19,4) NOT NULL DEFAULT 0.0000,
				customer_name varchar(160) NOT NULL DEFAULT '',
				customer_email varchar(190) NOT NULL DEFAULT '',
				customer_phone varchar(80) NOT NULL DEFAULT '',
				party_size smallint(5) unsigned NOT NULL DEFAULT 1,
				duration_minutes smallint(5) unsigned NOT NULL DEFAULT 90,
				reservation_date date NOT NULL,
				reservation_time time NOT NULL,
				customer_note text NULL,
				cancellation_reason text NULL,
				privacy_consent tinyint(1) unsigned NOT NULL DEFAULT 0,
				privacy_consent_at_gmt datetime NULL DEFAULT NULL,
				privacy_consent_ip varchar(45) NOT NULL DEFAULT '',
				manage_token_hash char(64) NOT NULL DEFAULT '',
				manage_token_created_at_gmt datetime NULL DEFAULT NULL,
				created_at_gmt datetime NOT NULL,
				updated_at_gmt datetime NOT NULL,
				PRIMARY KEY  (id),
				UNIQUE KEY reservation_code (reservation_code),
				KEY status (status),
				KEY table_slot (table_id,reservation_date,reservation_time),
				KEY reservation_date (reservation_date),
				KEY manage_token_hash (manage_token_hash),
				KEY order_id (order_id)
			) {$charset_collate};"
		);

		dbDelta(
			"CREATE TABLE {$tables['hold_abuse']} (
				identity_hash char(64) NOT NULL,
				strike_count tinyint(3) unsigned NOT NULL DEFAULT 0,
				hold_start_count smallint(5) unsigned NOT NULL DEFAULT 0,
				window_started_at_gmt datetime NULL DEFAULT NULL,
				blocked_until_gmt datetime NULL DEFAULT NULL,
				last_failure_at_gmt datetime NULL DEFAULT NULL,
				updated_at_gmt datetime NOT NULL,
				PRIMARY KEY  (identity_hash),
				KEY blocked_until_gmt (blocked_until_gmt),
				KEY updated_at_gmt (updated_at_gmt)
			) {$charset_collate};"
		);

		self::assign_orphaned_tables_to_first_floor();
		self::normalize_stored_durations();

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

	/**
	 * Normalize existing duration values to the supported duration options.
	 */
	private static function normalize_stored_durations(): void {
		$db     = self::db();
		$tables = self::table_names();
		$fields = array(
			$tables['tables']       => 'reservation_duration_minutes',
			$tables['holds']        => 'duration_minutes',
			$tables['reservations'] => 'duration_minutes',
		);

		foreach ( $fields as $table_name => $field_name ) {
			$db->query(
				$db->prepare(
					'UPDATE %i SET %i = LEAST(%d, GREATEST(%d, CEILING(%i / %d) * %d)) WHERE %i < %d OR %i > %d OR MOD(%i, %d) <> 0',
					$table_name,
					$field_name,
					self::MAX_DURATION_MINUTES,
					self::MIN_DURATION_MINUTES,
					$field_name,
					self::DURATION_STEP_MINUTES,
					self::DURATION_STEP_MINUTES,
					$field_name,
					self::MIN_DURATION_MINUTES,
					$field_name,
					self::MAX_DURATION_MINUTES,
					$field_name,
					self::DURATION_STEP_MINUTES
				)
			);
		}
	}

	/**
	 * Install tables when the stored schema version is stale.
	 */
	public static function maybe_install(): void {
		if ( self::DB_VERSION === get_option( self::OPTION_VERSION ) ) {
			return;
		}

		self::install();
	}

	/**
	 * Assign legacy tables without a floor to the first existing floor.
	 */
	private static function assign_orphaned_tables_to_first_floor(): void {
		$db     = self::db();
		$tables = self::table_names();

		$default_floor_id = (int) $db->get_var(
			$db->prepare(
				'SELECT id FROM %i ORDER BY sort_order ASC, id ASC LIMIT 1',
				$tables['floors']
			)
		);

		if ( $default_floor_id > 0 ) {
			$db->query(
				$db->prepare(
					'UPDATE %i SET floor_id = %d WHERE floor_id = 0',
					$tables['tables'],
					$default_floor_id
				)
			);
		}
	}

	/**
	 * Get supported reservation statuses.
	 *
	 * @return array<string,string>
	 */
	public static function get_statuses(): array {
		return array(
			'pending'      => __( 'Pending', 'foodord' ),
			'paid_pending' => __( 'Awaiting payment', 'foodord' ),
			'confirmed'    => __( 'Confirmed', 'foodord' ),
			'cancelled'    => __( 'Cancelled', 'foodord' ),
			'completed'    => __( 'Completed', 'foodord' ),
			'no_show'      => __( 'No-show', 'foodord' ),
		);
	}

	/**
	 * Get supported status keys.
	 *
	 * @return string[]
	 */
	public static function get_status_keys(): array {
		return array_keys( self::get_statuses() );
	}

	/**
	 * Get supported reservation slot intervals.
	 *
	 * @return int[]
	 */
	public static function get_slot_interval_options(): array {
		return array( 15, 30, 45, 60 );
	}

	/**
	 * Get supported maximum advance booking options.
	 *
	 * @return array<int,string>
	 */
	public static function get_maximum_advance_booking_options(): array {
		return array(
			0  => __( 'No limit', 'foodord' ),
			7  => __( 'Up to 7 days ahead', 'foodord' ),
			14 => __( 'Up to 14 days ahead', 'foodord' ),
			30 => __( 'Up to 30 days ahead', 'foodord' ),
			60 => __( 'Up to 60 days ahead', 'foodord' ),
			90 => __( 'Up to 90 days ahead', 'foodord' ),
		);
	}

	/**
	 * Get supported late booking options.
	 *
	 * @return array<int|string,string>
	 */
	public static function get_late_booking_options(): array {
		return array(
			'0'                         => __( 'Up to the last minute', 'foodord' ),
			'15'                        => __( 'At least 15 minutes in advance', 'foodord' ),
			'30'                        => __( 'At least 30 minutes in advance', 'foodord' ),
			'45'                        => __( 'At least 45 minutes in advance', 'foodord' ),
			'60'                        => __( 'At least 1 hour in advance', 'foodord' ),
			'240'                       => __( 'At least 4 hours in advance', 'foodord' ),
			'1440'                      => __( 'At least 24 hours in advance', 'foodord' ),
			self::LATE_BOOKING_SAME_DAY => __( 'Block same-day bookings', 'foodord' ),
		);
	}

	/**
	 * Format a database date with the site's WordPress date format.
	 *
	 * @param string $date Date in Y-m-d format.
	 */
	public static function format_display_date( string $date ): string {
		if ( '' === $date || '0000-00-00' === $date ) {
			return '';
		}

		$timestamp = strtotime( $date . ' 00:00:00' );

		return $timestamp ? date_i18n( self::get_display_date_format(), $timestamp ) : $date;
	}

	/**
	 * Get a readable date placeholder based on the site's WordPress date format.
	 */
	public static function get_date_placeholder(): string {
		$timestamp = strtotime( '2026-12-31 00:00:00' );

		return $timestamp ? date_i18n( self::get_display_date_format(), $timestamp ) : '31-12-2026';
	}

	/**
	 * Get the jQuery UI datepicker format matching the site's WordPress date format.
	 */
	public static function get_jquery_datepicker_format(): string {
		return self::convert_php_date_format_to_jquery_ui( self::get_display_date_format() );
	}

	/**
	 * Get the site's WordPress date format.
	 */
	private static function get_display_date_format(): string {
		$format = (string) get_option( 'date_format', 'Y-m-d' );

		return '' !== $format ? $format : 'Y-m-d';
	}

	/**
	 * Convert a PHP date format to the nearest jQuery UI datepicker format.
	 *
	 * @param string $format PHP date format.
	 */
	private static function convert_php_date_format_to_jquery_ui( string $format ): string {
		$map = array(
			'd' => 'dd',
			'j' => 'd',
			'm' => 'mm',
			'n' => 'm',
			'Y' => 'yy',
			'y' => 'y',
			'F' => 'MM',
			'M' => 'M',
			'l' => 'DD',
			'D' => 'D',
		);

		$output  = '';
		$escaped = false;
		$length  = strlen( $format );

		for ( $index = 0; $index < $length; $index++ ) {
			$character = $format[ $index ];
			if ( $escaped ) {
				$output .= "'" . $character . "'";
				$escaped = false;
				continue;
			}

			if ( '\\' === $character ) {
				$escaped = true;
				continue;
			}

			if ( isset( $map[ $character ] ) ) {
				$output .= $map[ $character ];
				continue;
			}

			if ( ctype_alpha( $character ) ) {
				return 'yy-mm-dd';
			}

			$output .= $character;
		}

		return '' !== $output ? $output : 'yy-mm-dd';
	}

	/**
	 * Get booking hour and holiday settings.
	 *
	 * @return array{schedule:array<int,array{enabled:bool,ranges:array<int,array{from:string,to:string,interval:int}>}>,holiday_ranges:array<int,array{from:string,to:string}>,maximum_advance_days:int,late_booking:string,capacity_limits:array{enabled:bool,max_reservations:int,max_guests:int,admin_override:bool}}
	 */
	public static function get_booking_settings(): array {
		$schedule = get_option( self::OPTION_BOOKING_SCHEDULE, null );
		if ( ! is_array( $schedule ) ) {
			$schedule = self::get_default_booking_schedule();
		}

		return array(
			'schedule'             => self::sanitize_booking_schedule( $schedule, false ),
			'holiday_ranges'       => self::sanitize_holiday_ranges( get_option( self::OPTION_HOLIDAY_RANGES, array() ) ),
			'maximum_advance_days' => self::sanitize_maximum_advance_days( get_option( self::OPTION_MAX_ADVANCE_DAYS, 0 ) ),
			'late_booking'         => self::sanitize_late_booking( get_option( self::OPTION_LATE_BOOKING, '0' ) ),
			'capacity_limits'      => self::sanitize_capacity_limits( get_option( self::OPTION_CAPACITY_LIMITS, array() ) ),
		);
	}

	/**
	 * Save booking hour and holiday settings.
	 *
	 * @param array<string,mixed> $data Raw settings data.
	 */
	public static function save_booking_settings( array $data ): void {
		update_option(
			self::OPTION_BOOKING_SCHEDULE,
			self::sanitize_booking_schedule( $data['schedule'] ?? array(), true ),
			false
		);
		update_option(
			self::OPTION_HOLIDAY_RANGES,
			self::sanitize_holiday_ranges( $data['holiday_ranges'] ?? array() ),
			false
		);
		update_option(
			self::OPTION_MAX_ADVANCE_DAYS,
			self::sanitize_maximum_advance_days( $data['maximum_advance_days'] ?? 0 ),
			false
		);
		update_option(
			self::OPTION_LATE_BOOKING,
			self::sanitize_late_booking( $data['late_booking'] ?? '0' ),
			false
		);
		update_option(
			self::OPTION_CAPACITY_LIMITS,
			self::sanitize_capacity_limits( $data['capacity_limits'] ?? array() ),
			false
		);
	}

	/**
	 * Check whether the current administrator can bypass capacity limits.
	 */
	public static function can_current_user_override_capacity_limits(): bool {
		$limits = self::get_booking_settings()['capacity_limits'];

		return current_user_can( 'manage_options' ) && $limits['enabled'] && $limits['admin_override'];
	}

	/**
	 * Get floor plan and customer table selection settings.
	 *
	 * @return array{enabled:bool,frontend_enabled:bool,layouts:array<int,array<string,mixed>>}
	 */
	public static function get_table_selection_settings(): array {
		return self::sanitize_table_selection_settings( get_option( self::OPTION_TABLE_SELECTION, array() ) );
	}

	/**
	 * Check whether floor plans are enabled for restaurant staff.
	 */
	public static function is_table_selection_enabled(): bool {
		return self::get_table_selection_settings()['enabled'];
	}

	/**
	 * Check whether customers can choose a table from the floor plan.
	 */
	public static function is_frontend_table_selection_enabled(): bool {
		$settings = self::get_table_selection_settings();

		return $settings['enabled'] && $settings['frontend_enabled'];
	}

	/**
	 * Save floor plan and customer table selection settings.
	 *
	 * @param array<string,mixed> $data Raw settings data.
	 */
	public static function save_table_selection_settings( array $data ): void {
		$settings = self::sanitize_table_selection_settings( $data );
		if ( $settings['enabled'] && ! self::has_renderable_table_selection_layout( $settings ) ) {
			$settings['enabled'] = false;
		}

		$updated = update_option(
			self::OPTION_TABLE_SELECTION,
			$settings,
			false
		);

		if ( $updated ) {
			self::increment_floor_plan_revision();
			self::increment_sync_revision();
		}
	}

	/**
	 * Check whether settings contain at least one renderable active table.
	 *
	 * @param array<string,mixed> $settings Table selection settings.
	 */
	public static function has_renderable_table_selection_layout( array $settings ): bool {
		$service = new self();

		return ! empty(
			$service->build_floor_plan_layouts(
				$service->get_tables( false ),
				self::sanitize_table_selection_settings( $settings )
			)
		);
	}

	/**
	 * Remove a deleted floor from the customer table selection layout.
	 *
	 * @param int $floor_id Floor ID.
	 */
	private static function remove_table_selection_floor_layout( int $floor_id ): void {
		$floor_id = absint( $floor_id );
		if ( $floor_id <= 0 ) {
			return;
		}

		$settings = self::get_table_selection_settings();
		if ( ! isset( $settings['layouts'][ $floor_id ] ) ) {
			return;
		}

		unset( $settings['layouts'][ $floor_id ] );
		self::save_table_selection_settings( $settings );
	}

	/**
	 * Remove a deleted table marker from every customer floor plan.
	 *
	 * @param int $table_id Table ID.
	 */
	private static function remove_table_selection_marker( int $table_id ): void {
		$table_id = absint( $table_id );
		if ( $table_id <= 0 ) {
			return;
		}

		$settings = self::get_table_selection_settings();
		$changed  = false;
		foreach ( $settings['layouts'] as $floor_id => $layout ) {
			foreach ( array( 'tables', 'grid_tables' ) as $table_collection ) {
				if ( ! isset( $layout[ $table_collection ][ $table_id ] ) ) {
					continue;
				}

				unset( $settings['layouts'][ $floor_id ][ $table_collection ][ $table_id ] );
				$changed = true;
			}
		}

		if ( $changed ) {
			self::save_table_selection_settings( $settings );
		}
	}

	/**
	 * Keep a table marker aligned with its current floor.
	 *
	 * @param int $table_id Table ID.
	 * @param int $floor_id Floor ID.
	 */
	private function sync_table_selection_marker( int $table_id, int $floor_id ): void {
		$table_id = absint( $table_id );
		$floor_id = absint( $floor_id );
		if ( $table_id <= 0 || $floor_id <= 0 ) {
			return;
		}

		$settings = self::get_table_selection_settings();
		$changed  = false;

		foreach ( $settings['layouts'] as $layout_floor_id => $layout ) {
			if ( absint( $layout_floor_id ) === $floor_id ) {
				continue;
			}

			foreach ( array( 'tables', 'grid_tables' ) as $table_collection ) {
				if ( ! isset( $layout[ $table_collection ][ $table_id ] ) ) {
					continue;
				}

				unset( $settings['layouts'][ $layout_floor_id ][ $table_collection ][ $table_id ] );
				$changed = true;
			}
		}

		if ( ! isset( $settings['layouts'][ $floor_id ] ) ) {
			$settings['layouts'][ $floor_id ] = array(
				'mode'         => 'image',
				'image_id'     => 0,
				'tables'       => array(),
				'grid_tables'  => array(),
				'objects'      => array(),
				'snap_to_grid' => true,
				'grid_density' => 'coarse',
			);
		}

		if ( ! isset( $settings['layouts'][ $floor_id ]['tables'][ $table_id ] ) ) {
			$settings['layouts'][ $floor_id ]['tables'][ $table_id ] = $this->get_default_table_selection_marker_position( $floor_id, $table_id );
			$changed = true;
		}

		if ( $changed ) {
			self::save_table_selection_settings( $settings );
		}
	}

	/**
	 * Calculate a stable initial position for a table marker.
	 *
	 * @param int $floor_id Floor ID.
	 * @param int $table_id Table ID.
	 * @return array{x:float,y:float,width:float,height:float,rotation:float,shape:string,fill:string,text_color:string}
	 */
	private function get_default_table_selection_marker_position( int $floor_id, int $table_id ): array {
		$floor_tables = array_values(
			array_filter(
				$this->get_tables( true ),
				static function ( $table ) use ( $floor_id ): bool {
					return absint( $table->floor_id ?? 0 ) === $floor_id;
				}
			)
		);
		$table_index  = 0;

		foreach ( $floor_tables as $index => $table ) {
			if ( absint( $table->id ?? 0 ) === $table_id ) {
				$table_index = (int) $index;
				break;
			}
		}

		$total   = max( 1, count( $floor_tables ) );
		$columns = min( 4, $total );
		$rows    = (int) ceil( $total / $columns );
		$column  = $table_index % $columns;
		$row     = (int) floor( $table_index / $columns );

		return array(
			'x'          => self::sanitize_table_layout_coordinate( ( ( $column + 1 ) / ( $columns + 1 ) ) * 100 ),
			'y'          => self::sanitize_table_layout_coordinate( ( ( $row + 1 ) / ( $rows + 1 ) ) * 100 ),
			'width'      => self::IMAGE_MARKER_WIDTH,
			'height'     => self::IMAGE_MARKER_HEIGHT,
			'rotation'   => 0.0,
			'shape'      => 'rectangle',
			'fill'       => '',
			'text_color' => '',
		);
	}

	/**
	 * Get deposit type options.
	 *
	 * @return array<string,string>
	 */
	public static function get_deposit_type_options(): array {
		return array(
			'per_reservation' => __( 'Per Reservation', 'foodord' ),
			'per_guest'       => __( 'Per Guest', 'foodord' ),
		);
	}

	/**
	 * Get deposit applicability options.
	 *
	 * @return array<string,string>
	 */
	public static function get_deposit_applicable_options(): array {
		return array(
			'all_times'      => __( 'At All Times', 'foodord' ),
			'specific_times' => __( 'Specific Days/Times', 'foodord' ),
			'party_size'     => __( 'Groups Over a Certain Size', 'foodord' ),
		);
	}

	/**
	 * Get deposit settings.
	 *
	 * @return array{enabled:bool,type:string,amount:float,applicable:string,party_size:int,schedule:array<int,array{enabled:bool,ranges:array<int,array{from:string,to:string,interval:int}>}>}
	 */
	public static function get_deposit_settings(): array {
		$settings = get_option( self::OPTION_DEPOSIT, array() );
		$settings = is_array( $settings ) ? $settings : array();

		return self::sanitize_deposit_settings( $settings );
	}

	/**
	 * Save deposit settings.
	 *
	 * @param array<string,mixed> $settings Raw settings.
	 */
	public static function save_deposit_settings( array $settings ): void {
		update_option( self::OPTION_DEPOSIT, self::sanitize_deposit_settings( $settings ), false );
	}

	/**
	 * Calculate the reservation deposit amount for a slot.
	 *
	 * @param string $date       Reservation date.
	 * @param string $time       Reservation time.
	 * @param int    $party_size Reservation party size.
	 */
	public static function calculate_deposit_amount( string $date, string $time, int $party_size ): float {
		$settings   = self::get_deposit_settings();
		$party_size = max( 1, absint( $party_size ) );
		$amount     = (float) $settings['amount'];

		if ( empty( $settings['enabled'] ) || $amount <= 0 || ! self::is_deposit_applicable( $settings, $date, $time, $party_size ) ) {
			return 0.0;
		}

		if ( 'per_guest' === $settings['type'] ) {
			return round( $amount * $party_size, 2 );
		}

		return round( $amount, 2 );
	}

	/**
	 * Format a money amount as plain text.
	 *
	 * @param float $amount Money amount.
	 */
	public static function format_money( float $amount ): string {
		if ( function_exists( 'wc_price' ) ) {
			return html_entity_decode( wp_strip_all_tags( wc_price( $amount ) ), ENT_QUOTES, 'UTF-8' );
		}

		return number_format_i18n( $amount, 2 );
	}

	/**
	 * Get the frontend datepicker minimum date offset.
	 */
	public static function get_datepicker_minimum_offset(): int {
		$settings = self::get_booking_settings();

		return self::LATE_BOOKING_SAME_DAY === $settings['late_booking'] ? 1 : 0;
	}

	/**
	 * Get the frontend datepicker maximum date offset.
	 *
	 * @return int|null
	 */
	public static function get_datepicker_maximum_offset() {
		$settings = self::get_booking_settings();
		$days     = absint( $settings['maximum_advance_days'] );

		return $days > 0 ? $days : null;
	}

	/**
	 * Get weekdays and holiday ranges disabled for reservation date selection.
	 *
	 * @return array{weekdays:array<int,int>,ranges:array<int,array{from:string,to:string}>}
	 */
	public static function get_datepicker_disabled_rules(): array {
		$settings = self::get_booking_settings();
		$weekdays = array();

		foreach ( array_keys( $settings['schedule'] ) as $weekday ) {
			if ( ! self::weekday_has_booking_slots( $settings['schedule'], absint( $weekday ) ) ) {
				$weekdays[] = absint( $weekday );
			}
		}

		return array(
			'weekdays' => array_values( array_unique( $weekdays ) ),
			'ranges'   => array_values( $settings['holiday_ranges'] ),
		);
	}

	/**
	 * Check whether new reservations should be confirmed automatically.
	 */
	public static function is_auto_confirm_enabled(): bool {
		return '1' === (string) get_option( self::OPTION_AUTO_CONFIRM, '0' );
	}

	/**
	 * Save automatic confirmation setting.
	 *
	 * @param bool $enabled Whether auto-confirm is enabled.
	 */
	public static function save_auto_confirm_enabled( bool $enabled ): void {
		update_option( self::OPTION_AUTO_CONFIRM, $enabled ? '1' : '0', false );
	}

	/**
	 * Check whether customers can manage reservations from email links.
	 */
	public static function is_customer_management_enabled(): bool {
		return '1' === (string) get_option( self::OPTION_CUSTOMER_MANAGE, '0' );
	}

	/**
	 * Save customer reservation management setting.
	 *
	 * @param bool $enabled Whether customer management links are enabled.
	 */
	public static function save_customer_management_enabled( bool $enabled ): void {
		update_option( self::OPTION_CUSTOMER_MANAGE, $enabled ? '1' : '0', false );
	}

	/**
	 * Get customer cancellation cutoff options in minutes.
	 *
	 * @return array<int,string>
	 */
	public static function get_customer_cancellation_cutoff_options(): array {
		return array(
			0    => __( 'Until reservation time', 'foodord' ),
			15   => __( '15 minutes before', 'foodord' ),
			30   => __( '30 minutes before', 'foodord' ),
			60   => __( '1 hour before', 'foodord' ),
			120  => __( '2 hours before', 'foodord' ),
			240  => __( '4 hours before', 'foodord' ),
			720  => __( '12 hours before', 'foodord' ),
			1440 => __( '24 hours before', 'foodord' ),
			2880 => __( '48 hours before', 'foodord' ),
		);
	}

	/**
	 * Get the selected customer cancellation cutoff in minutes.
	 */
	public static function get_customer_cancellation_cutoff_minutes(): int {
		$minutes = absint( get_option( self::OPTION_CANCEL_CUTOFF, 0 ) );

		return array_key_exists( $minutes, self::get_customer_cancellation_cutoff_options() ) ? $minutes : 0;
	}

	/**
	 * Save the customer cancellation cutoff in minutes.
	 *
	 * @param int|string $minutes Cancellation cutoff in minutes.
	 */
	public static function save_customer_cancellation_cutoff_minutes( $minutes ): void {
		$minutes = absint( $minutes );
		if ( ! array_key_exists( $minutes, self::get_customer_cancellation_cutoff_options() ) ) {
			$minutes = 0;
		}

		update_option( self::OPTION_CANCEL_CUTOFF, (string) $minutes, false );
	}

	/**
	 * Get reservation privacy settings.
	 *
	 * @return array{enabled:bool,statement:string,privacy_page_id:int,store_ip:bool}
	 */
	public static function get_privacy_settings(): array {
		$settings  = get_option( self::OPTION_PRIVACY, array() );
		$settings  = is_array( $settings ) ? $settings : array();
		$enabled   = ! empty( $settings['enabled'] );
		$statement = sanitize_textarea_field(
			(string) ( $settings['statement'] ?? __( 'I agree to the privacy statement.', 'foodord' ) )
		);
		$page_id   = absint( $settings['privacy_page_id'] ?? get_option( 'wp_page_for_privacy_policy', 0 ) );

		if ( '' === $statement ) {
			$statement = __( 'I agree to the privacy statement.', 'foodord' );
		}

		if ( $page_id > 0 && 'page' !== get_post_type( $page_id ) ) {
			$page_id = 0;
		}

		return array(
			'enabled'         => $enabled,
			'statement'       => $statement,
			'privacy_page_id' => $page_id,
			'store_ip'        => $enabled && ! empty( $settings['store_ip'] ),
		);
	}

	/**
	 * Save reservation privacy settings.
	 *
	 * @param array<string,mixed> $settings Raw settings.
	 */
	public static function save_privacy_settings( array $settings ): void {
		$page_id = absint( $settings['privacy_page_id'] ?? 0 );
		if ( $page_id > 0 && 'page' !== get_post_type( $page_id ) ) {
			$page_id = 0;
		}

		$statement = sanitize_textarea_field( (string) ( $settings['statement'] ?? '' ) );
		if ( '' === $statement ) {
			$statement = __( 'I agree to the privacy statement.', 'foodord' );
		}

		update_option(
			self::OPTION_PRIVACY,
			array(
				'enabled'         => ! empty( $settings['enabled'] ) ? '1' : '0',
				'statement'       => $statement,
				'privacy_page_id' => $page_id,
				'store_ip'        => ! empty( $settings['store_ip'] ) ? '1' : '0',
			),
			false
		);
	}

	/**
	 * Get reCAPTCHA settings.
	 *
	 * @return array{enabled:bool,site_key:string,secret_key:string,score_threshold:float}
	 */
	public static function get_recaptcha_settings(): array {
		$settings = get_option( self::OPTION_RECAPTCHA, array() );
		$settings = is_array( $settings ) ? $settings : array();

		return array(
			'enabled'         => ! empty( $settings['enabled'] ),
			'site_key'        => sanitize_text_field( (string) ( $settings['site_key'] ?? '' ) ),
			'secret_key'      => sanitize_text_field( (string) ( $settings['secret_key'] ?? '' ) ),
			'score_threshold' => self::DEFAULT_RECAPTCHA_SCORE,
		);
	}

	/**
	 * Save reCAPTCHA settings.
	 *
	 * @param array<string,mixed> $settings Raw settings.
	 */
	public static function save_recaptcha_settings( array $settings ): void {
		update_option(
			self::OPTION_RECAPTCHA,
			array(
				'enabled'    => ! empty( $settings['enabled'] ) ? '1' : '0',
				'site_key'   => sanitize_text_field( (string) ( $settings['site_key'] ?? '' ) ),
				'secret_key' => sanitize_text_field( (string) ( $settings['secret_key'] ?? '' ) ),
			),
			false
		);
	}

	/**
	 * Get reservation security blocklist settings.
	 *
	 * @return array{blocked_emails:array<int,string>,blocked_ips:array<int,string>}
	 */
	public static function get_security_blocklist(): array {
		$settings = get_option( self::OPTION_BLOCKLIST, array() );
		$settings = is_array( $settings ) ? $settings : array();

		return array(
			'blocked_emails' => self::sanitize_email_blocklist( $settings['blocked_emails'] ?? array() ),
			'blocked_ips'    => self::sanitize_ip_blocklist( $settings['blocked_ips'] ?? array() ),
		);
	}

	/**
	 * Save reservation security blocklist settings.
	 *
	 * @param array<string,mixed> $settings Raw settings.
	 */
	public static function save_security_blocklist( array $settings ): void {
		update_option(
			self::OPTION_BLOCKLIST,
			array(
				'blocked_emails' => self::sanitize_email_blocklist( $settings['blocked_emails'] ?? array() ),
				'blocked_ips'    => self::sanitize_ip_blocklist( $settings['blocked_ips'] ?? array() ),
			),
			false
		);
	}

	/**
	 * Check whether reCAPTCHA protection is ready to run.
	 */
	public static function is_recaptcha_enabled(): bool {
		$settings = self::get_recaptcha_settings();

		return $settings['enabled'] && '' !== $settings['site_key'] && '' !== $settings['secret_key'];
	}

	/**
	 * Check whether an email address is blocked from creating reservations.
	 *
	 * @param string $email Email address.
	 */
	public static function is_reservation_email_blocked( string $email ): bool {
		$email = strtolower( sanitize_email( $email ) );
		if ( '' === $email || ! is_email( $email ) ) {
			return false;
		}

		$domain = substr( strrchr( $email, '@' ), 1 );
		if ( false === $domain || '' === $domain ) {
			return false;
		}

		foreach ( self::get_security_blocklist()['blocked_emails'] as $blocked_email ) {
			if ( $email === $blocked_email ) {
				return true;
			}

			if ( str_starts_with( $blocked_email, '@' ) && substr( $blocked_email, 1 ) === $domain ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Check whether an IP address is blocked from creating reservations.
	 *
	 * @param string $ip_address IP address.
	 */
	public static function is_reservation_ip_blocked( string $ip_address ): bool {
		$ip_address = self::sanitize_ip_block_entry( $ip_address );
		if ( '' === $ip_address ) {
			return false;
		}

		return in_array( $ip_address, self::get_security_blocklist()['blocked_ips'], true );
	}

	/**
	 * Get the current request IP address.
	 */
	public static function get_request_ip_address(): string {
		$ip_address = filter_input( INPUT_SERVER, 'REMOTE_ADDR', FILTER_VALIDATE_IP );

		return is_string( $ip_address ) ? $ip_address : '';
	}

	/**
	 * Verify a reCAPTCHA v3 token.
	 *
	 * @param string $token           Response token.
	 * @param string $expected_action Expected action name.
	 * @return true|WP_Error
	 */
	public static function verify_recaptcha_token( string $token, string $expected_action ) {
		if ( ! self::is_recaptcha_enabled() ) {
			return true;
		}

		$settings = self::get_recaptcha_settings();
		$token    = sanitize_text_field( $token );
		if ( '' === $token ) {
			return new WP_Error( 'foodord_recaptcha_missing_token', __( 'Security verification failed. Please try again.', 'foodord' ) );
		}

		$body = array(
			'secret'   => $settings['secret_key'],
			'response' => $token,
		);

		$remote_ip = filter_input( INPUT_SERVER, 'REMOTE_ADDR', FILTER_VALIDATE_IP );
		if ( is_string( $remote_ip ) && '' !== $remote_ip ) {
			$body['remoteip'] = $remote_ip;
		}

		$response = wp_remote_post(
			self::RECAPTCHA_SITEVERIFY_URL,
			array(
				'timeout' => 8,
				'body'    => $body,
			)
		);

		if ( is_wp_error( $response ) ) {
			return new WP_Error( 'foodord_recaptcha_request_failed', __( 'Security verification failed. Please try again.', 'foodord' ) );
		}

		$data = json_decode( wp_remote_retrieve_body( $response ), true );
		if ( ! is_array( $data ) || empty( $data['success'] ) ) {
			return new WP_Error( 'foodord_recaptcha_failed', __( 'Security verification failed. Please try again.', 'foodord' ) );
		}

		$action = isset( $data['action'] ) ? sanitize_key( (string) $data['action'] ) : '';
		if ( sanitize_key( $expected_action ) !== $action ) {
			return new WP_Error( 'foodord_recaptcha_action_mismatch', __( 'Security verification failed. Please try again.', 'foodord' ) );
		}

		$score = isset( $data['score'] ) && is_numeric( $data['score'] ) ? (float) $data['score'] : 0.0;
		if ( $score < $settings['score_threshold'] ) {
			return new WP_Error( 'foodord_recaptcha_low_score', __( 'Security verification failed. Please try again.', 'foodord' ) );
		}

		return true;
	}

	/**
	 * Get default booking schedule.
	 *
	 * @return array<int,array{enabled:bool,ranges:array<int,array{from:string,to:string,interval:int}>}>
	 */
	private static function get_default_booking_schedule(): array {
		$schedule = array();

		for ( $day = 0; $day <= 6; $day++ ) {
			$schedule[ $day ] = array(
				'enabled' => true,
				'ranges'  => array(
					array(
						'from'     => '10:00',
						'to'       => '23:00',
						'interval' => self::DEFAULT_SLOT_INTERVAL,
					),
				),
			);
		}

		return $schedule;
	}

	/**
	 * Sanitize weekly booking schedule.
	 *
	 * @param mixed $schedule    Raw schedule.
	 * @param bool  $from_submit Whether the schedule is coming from a submitted form.
	 * @return array<int,array{enabled:bool,ranges:array<int,array{from:string,to:string,interval:int}>}>
	 */
	private static function sanitize_booking_schedule( $schedule, bool $from_submit ): array {
		$schedule = is_array( $schedule ) ? $schedule : array();
		$defaults = self::get_default_booking_schedule();
		$result   = array();

		for ( $day = 0; $day <= 6; $day++ ) {
			$raw_day = isset( $schedule[ $day ] ) && is_array( $schedule[ $day ] ) ? $schedule[ $day ] : array();
			if ( empty( $raw_day ) && ! $from_submit ) {
				$raw_day = $defaults[ $day ];
			}

			$enabled = $from_submit ? ! empty( $raw_day['enabled'] ) : ! array_key_exists( 'enabled', $raw_day ) || ! empty( $raw_day['enabled'] );
			$ranges  = self::sanitize_booking_ranges( $raw_day['ranges'] ?? array() );

			if ( empty( $ranges ) && ! $from_submit ) {
				$ranges = $defaults[ $day ]['ranges'];
			}

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

		return $result;
	}

	/**
	 * Sanitize deposit settings.
	 *
	 * @param mixed $settings Raw settings.
	 * @return array{enabled:bool,type:string,amount:float,applicable:string,party_size:int,schedule:array<int,array{enabled:bool,ranges:array<int,array{from:string,to:string,interval:int}>}>}
	 */
	private static function sanitize_deposit_settings( $settings ): array {
		$settings   = is_array( $settings ) ? $settings : array();
		$type       = sanitize_key( (string) ( $settings['type'] ?? 'per_reservation' ) );
		$applicable = sanitize_key( (string) ( $settings['applicable'] ?? 'all_times' ) );

		if ( ! array_key_exists( $type, self::get_deposit_type_options() ) ) {
			$type = 'per_reservation';
		}

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

		return array(
			'enabled'    => ! empty( $settings['enabled'] ),
			'type'       => $type,
			'amount'     => self::sanitize_money_amount( $settings['amount'] ?? 0 ),
			'applicable' => $applicable,
			'party_size' => max( 1, absint( $settings['party_size'] ?? 6 ) ),
			'schedule'   => self::sanitize_deposit_schedule( $settings['schedule'] ?? array() ),
		);
	}

	/**
	 * Sanitize a positive money amount.
	 *
	 * @param mixed $amount Raw amount.
	 */
	private static function sanitize_money_amount( $amount ): float {
		$amount = is_scalar( $amount ) ? (string) $amount : '0';
		if ( function_exists( 'wc_format_decimal' ) ) {
			$amount = wc_format_decimal( $amount );
		}

		return max( 0.0, round( (float) $amount, 2 ) );
	}

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

		for ( $day = 0; $day <= 6; $day++ ) {
			$raw_day = array();
			if ( isset( $schedule[ $day ] ) && is_array( $schedule[ $day ] ) ) {
				$raw_day = $schedule[ $day ];
			}

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

		return $result;
	}

	/**
	 * Check whether deposit rules apply to a reservation slot.
	 *
	 * @param array  $settings Deposit settings.
	 * @param string $date Reservation date.
	 * @param string $time Reservation time.
	 * @param int    $party_size Reservation party size.
	 */
	private static function is_deposit_applicable( array $settings, string $date, string $time, int $party_size ): bool {
		if ( 'party_size' === $settings['applicable'] ) {
			return $party_size >= max( 1, absint( $settings['party_size'] ) );
		}

		if ( 'specific_times' === $settings['applicable'] ) {
			$date = self::normalize_date_value( $date );
			$time = self::sanitize_slot_time( $time );
			if ( '' === $date || '' === $time ) {
				return false;
			}

			return self::time_is_inside_schedule( $date, $time, $settings['schedule'] );
		}

		return true;
	}

	/**
	 * Check whether a calendar slot falls inside a weekly operational schedule.
	 *
	 * @param string                                                                                     $date Reservation date.
	 * @param string                                                                                     $time Reservation time.
	 * @param array<int,array{enabled:bool,ranges:array<int,array{from:string,to:string,interval:int}>}> $schedule Weekly schedule.
	 */
	private static function time_is_inside_schedule( string $date, string $time, array $schedule ): bool {
		$parsed_date = DateTimeImmutable::createFromFormat( '!Y-m-d', $date, wp_timezone() );
		if ( ! $parsed_date instanceof DateTimeImmutable ) {
			return false;
		}

		$time_minutes = self::time_to_minutes( $time );
		$weekday      = (int) $parsed_date->format( 'w' );
		$day          = $schedule[ $weekday ] ?? array();
		if ( ! empty( $day['enabled'] ) ) {
			foreach ( $day['ranges'] as $range ) {
				$start = self::time_to_minutes( (string) $range['from'] );
				$end   = self::time_to_minutes( (string) $range['to'] );
				if ( self::booking_range_is_overnight( $range ) ? $time_minutes >= $start : $time_minutes >= $start && $time_minutes <= $end ) {
					return true;
				}
			}
		}

		$previous_day = $schedule[ ( $weekday + 6 ) % 7 ] ?? array();
		if ( empty( $previous_day['enabled'] ) ) {
			return false;
		}

		foreach ( $previous_day['ranges'] as $range ) {
			if ( ! self::booking_range_is_overnight( $range ) ) {
				continue;
			}

			if ( $time_minutes <= self::time_to_minutes( (string) $range['to'] ) ) {
				return true;
			}
		}

		return false;
	}

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

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

			$from     = self::sanitize_slot_time( (string) ( $range['from'] ?? '' ) );
			$to       = self::sanitize_slot_time( (string) ( $range['to'] ?? '' ) );
			$interval = self::sanitize_slot_interval( $range['interval'] ?? self::DEFAULT_SLOT_INTERVAL );
			if ( '' === $from || '' === $to ) {
				continue;
			}

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

		return $sanitized;
	}

	/**
	 * Sanitize holiday date ranges.
	 *
	 * @param mixed $ranges Raw ranges.
	 * @return array<int,array{from:string,to:string}>
	 */
	private static function sanitize_holiday_ranges( $ranges ): array {
		if ( ! is_array( $ranges ) ) {
			return array();
		}

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

			$from = self::normalize_date_value( (string) ( $range['from'] ?? '' ) );
			$to   = self::normalize_date_value( (string) ( $range['to'] ?? '' ) );
			if ( '' === $from && '' === $to ) {
				continue;
			}

			if ( '' === $from ) {
				$from = $to;
			}

			if ( '' === $to ) {
				$to = $from;
			}

			if ( $to < $from ) {
				$temporary = $from;
				$from      = $to;
				$to        = $temporary;
			}

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

		return $sanitized;
	}

	/**
	 * Sanitize a reservation time slot value.
	 *
	 * @param string $time Raw time.
	 */
	private static function sanitize_slot_time( string $time ): string {
		$time = sanitize_text_field( $time );
		if ( preg_match( '/^\d{2}:\d{2}:\d{2}$/', $time ) ) {
			$time = substr( $time, 0, 5 );
		}

		if ( ! preg_match( '/^\d{2}:\d{2}$/', $time ) ) {
			return '';
		}

		$hours   = (int) substr( $time, 0, 2 );
		$minutes = (int) substr( $time, 3, 2 );
		if ( $hours < 0 || $hours > 23 || $minutes < 0 || $minutes > 59 ) {
			return '';
		}

		return sprintf( '%02d:%02d', $hours, $minutes );
	}

	/**
	 * Sanitize the slot interval.
	 *
	 * @param mixed $interval Raw interval.
	 */
	private static function sanitize_slot_interval( $interval ): int {
		$interval = absint( $interval );

		return in_array( $interval, self::get_slot_interval_options(), true ) ? $interval : self::DEFAULT_SLOT_INTERVAL;
	}

	/**
	 * Sanitize the maximum advance booking option.
	 *
	 * @param mixed $days Raw day value.
	 */
	private static function sanitize_maximum_advance_days( $days ): int {
		$days    = absint( $days );
		$options = array_keys( self::get_maximum_advance_booking_options() );

		return in_array( $days, $options, true ) ? $days : 0;
	}

	/**
	 * Sanitize concurrent reservation capacity limits.
	 *
	 * @param mixed $limits Raw capacity limit settings.
	 * @return array{enabled:bool,max_reservations:int,max_guests:int,admin_override:bool}
	 */
	private static function sanitize_capacity_limits( $limits ): array {
		$limits = is_array( $limits ) ? $limits : array();

		return array(
			'enabled'          => ! empty( $limits['enabled'] ),
			'max_reservations' => self::sanitize_capacity_limit( $limits['max_reservations'] ?? 0 ),
			'max_guests'       => self::sanitize_capacity_limit( $limits['max_guests'] ?? 0 ),
			'admin_override'   => ! empty( $limits['admin_override'] ),
		);
	}

	/**
	 * Sanitize a capacity limit value.
	 *
	 * A value of zero leaves that individual limit unrestricted.
	 *
	 * @param mixed $limit Raw capacity limit.
	 */
	private static function sanitize_capacity_limit( $limit ): int {
		return min( 10000, absint( $limit ) );
	}

	/**
	 * Sanitize floor plan and customer table selection settings.
	 *
	 * @param mixed $settings Raw table selection settings.
	 * @return array{enabled:bool,frontend_enabled:bool,layouts:array<int,array<string,mixed>>}
	 */
	private static function sanitize_table_selection_settings( $settings ): array {
		$settings = is_array( $settings ) ? $settings : array();
		$layouts  = isset( $settings['layouts'] ) && is_array( $settings['layouts'] ) ? $settings['layouts'] : array();
		$clean    = array();

		foreach ( $layouts as $floor_id => $layout ) {
			$floor_id = absint( $floor_id );
			if ( $floor_id <= 0 || ! is_array( $layout ) ) {
				continue;
			}

			$has_explicit_mode = isset( $layout['mode'] );
			$mode              = 'grid' === sanitize_key( (string) ( $layout['mode'] ?? 'image' ) ) ? 'grid' : 'image';
			$image_id          = absint( $layout['image_id'] ?? 0 );
			$positions         = self::sanitize_image_table_layouts( $layout['tables'] ?? array() );

			$grid_tables = self::sanitize_grid_table_layouts( $layout['grid_tables'] ?? array() );
			$objects     = self::sanitize_floor_plan_objects( $layout['objects'] ?? array() );

			if ( $has_explicit_mode || $image_id > 0 || ! empty( $positions ) || ! empty( $grid_tables ) || ! empty( $objects ) ) {
				$clean[ $floor_id ] = array(
					'mode'         => $mode,
					'image_id'     => $image_id,
					'tables'       => $positions,
					'grid_tables'  => $grid_tables,
					'objects'      => $objects,
					'snap_to_grid' => ! isset( $layout['snap_to_grid'] ) || ! empty( $layout['snap_to_grid'] ),
					'grid_density' => 'fine' === sanitize_key( (string) ( $layout['grid_density'] ?? 'coarse' ) ) ? 'fine' : 'coarse',
				);
			}
		}

		$enabled          = ! empty( $settings['enabled'] );
		$frontend_enabled = array_key_exists( 'frontend_enabled', $settings )
			? ! empty( $settings['frontend_enabled'] )
			: $enabled;

		return array(
			'enabled'          => $enabled,
			'frontend_enabled' => $frontend_enabled,
			'layouts'          => $clean,
		);
	}

	/**
	 * Sanitize a percentage coordinate used for a table marker.
	 *
	 * @param mixed $coordinate Raw coordinate.
	 */
	private static function sanitize_table_layout_coordinate( $coordinate ): float {
		return round( min( 97, max( 3, (float) $coordinate ) ), 2 );
	}

	/**
	 * Sanitize tables positioned over an uploaded floor-plan image.
	 *
	 * @param mixed $tables Raw image marker layouts.
	 * @return array<int,array{x:float,y:float,width:float,height:float,rotation:float,shape:string,fill:string,text_color:string}>
	 */
	private static function sanitize_image_table_layouts( $tables ): array {
		if ( ! is_array( $tables ) ) {
			return array();
		}

		$clean  = array();
		$shapes = array( 'rectangle', 'square', 'round' );

		foreach ( $tables as $table_id => $geometry ) {
			$table_id = absint( $table_id );
			if ( $table_id <= 0 || ! is_array( $geometry ) || ! isset( $geometry['x'], $geometry['y'] ) || ! is_numeric( $geometry['x'] ) || ! is_numeric( $geometry['y'] ) ) {
				continue;
			}

			$shape  = sanitize_key( (string) ( $geometry['shape'] ?? 'rectangle' ) );
			$shape  = in_array( $shape, $shapes, true ) ? $shape : 'rectangle';
			$width  = self::sanitize_floor_plan_dimension( $geometry['width'] ?? self::IMAGE_MARKER_WIDTH, self::IMAGE_MARKER_WIDTH );
			$height = self::sanitize_floor_plan_dimension( $geometry['height'] ?? self::IMAGE_MARKER_HEIGHT, self::IMAGE_MARKER_HEIGHT );

			if ( in_array( $shape, array( 'square', 'round' ), true ) ) {
				$height = $width;
			}

			$clean[ $table_id ] = array(
				'x'          => self::sanitize_table_layout_coordinate( $geometry['x'] ),
				'y'          => self::sanitize_table_layout_coordinate( $geometry['y'] ),
				'width'      => $width,
				'height'     => $height,
				'rotation'   => self::sanitize_floor_plan_rotation( $geometry['rotation'] ?? 0 ),
				'shape'      => $shape,
				'fill'       => self::sanitize_floor_plan_color( $geometry['fill'] ?? '', '' ),
				'text_color' => self::sanitize_floor_plan_color( $geometry['text_color'] ?? '', '' ),
			);
		}

		return $clean;
	}

	/**
	 * Sanitize tables placed on a generated floor-plan grid.
	 *
	 * @param mixed $tables Raw grid table layouts.
	 * @return array<int,array<string,mixed>>
	 */
	private static function sanitize_grid_table_layouts( $tables ): array {
		if ( ! is_array( $tables ) ) {
			return array();
		}

		$clean  = array();
		$shapes = array( 'rectangle', 'square', 'round' );

		foreach ( $tables as $table_id => $geometry ) {
			$table_id = absint( $table_id );
			if ( $table_id <= 0 || ! is_array( $geometry ) ) {
				continue;
			}

			$shape  = sanitize_key( (string) ( $geometry['shape'] ?? 'rectangle' ) );
			$shape  = in_array( $shape, $shapes, true ) ? $shape : 'rectangle';
			$width  = self::sanitize_floor_plan_dimension( $geometry['width'] ?? 16, 16 );
			$height = self::sanitize_floor_plan_dimension( $geometry['height'] ?? 12, 12 );
			if ( in_array( $shape, array( 'square', 'round' ), true ) ) {
				$height = $width;
			}

			$clean[ $table_id ] = array(
				'x'           => self::sanitize_floor_plan_coordinate( $geometry['x'] ?? 50 ),
				'y'           => self::sanitize_floor_plan_coordinate( $geometry['y'] ?? 50 ),
				'width'       => $width,
				'height'      => $height,
				'rotation'    => self::sanitize_floor_plan_rotation( $geometry['rotation'] ?? 0 ),
				'shape'       => $shape,
				'seat_mode'   => isset( $geometry['seat_mode'] )
					? ( 'even' === sanitize_key( (string) $geometry['seat_mode'] ) ? 'even' : 'sides' )
					: ( 'round' === $shape ? 'even' : 'sides' ),
				'seat_top'    => self::sanitize_floor_plan_seat_count( $geometry['seat_top'] ?? 0 ),
				'seat_right'  => self::sanitize_floor_plan_seat_count( $geometry['seat_right'] ?? 0 ),
				'seat_bottom' => self::sanitize_floor_plan_seat_count( $geometry['seat_bottom'] ?? 0 ),
				'seat_left'   => self::sanitize_floor_plan_seat_count( $geometry['seat_left'] ?? 0 ),
			);
		}

		return $clean;
	}

	/**
	 * Sanitize a visual seat count used around a generated table.
	 *
	 * @param mixed $count Raw seat count.
	 */
	private static function sanitize_floor_plan_seat_count( $count ): int {
		return min( 16, max( 0, absint( $count ) ) );
	}

	/**
	 * Sanitize decorative objects placed on a generated floor plan.
	 *
	 * @param mixed $objects Raw floor-plan objects.
	 * @return array<string,array<string,mixed>>
	 */
	private static function sanitize_floor_plan_objects( $objects ): array {
		if ( ! is_array( $objects ) ) {
			return array();
		}

		$clean          = array();
		$allowed_types  = array( 'rectangle', 'circle', 'line', 'text', 'arrow', 'plant', 'door', 'stairs' );
		$object_counter = 0;

		foreach ( $objects as $object_id => $object ) {
			if ( $object_counter >= 100 || ! is_array( $object ) ) {
				break;
			}

			$object_id = sanitize_key( (string) $object_id );
			$type      = sanitize_key( (string) ( $object['type'] ?? '' ) );
			if ( '' === $object_id || ! in_array( $type, $allowed_types, true ) ) {
				continue;
			}

			$clean[ $object_id ] = array(
				'id'           => $object_id,
				'type'         => $type,
				'x'            => self::sanitize_floor_plan_coordinate( $object['x'] ?? 50 ),
				'y'            => self::sanitize_floor_plan_coordinate( $object['y'] ?? 50 ),
				'width'        => self::sanitize_floor_plan_dimension( $object['width'] ?? 16, 16 ),
				'height'       => self::sanitize_floor_plan_dimension( $object['height'] ?? 12, 12 ),
				'rotation'     => self::sanitize_floor_plan_rotation( $object['rotation'] ?? 0 ),
				'fill'         => self::sanitize_floor_plan_color( $object['fill'] ?? '#f3f4f6', '#f3f4f6' ),
				'stroke'       => self::sanitize_floor_plan_color( $object['stroke'] ?? '#6b7280', '#6b7280' ),
				'border_width' => round( min( 6, max( 0, (float) ( $object['border_width'] ?? 1 ) ) ), 1 ),
				'text'         => wp_html_excerpt( sanitize_text_field( (string) ( $object['text'] ?? '' ) ), 80, '' ),
				'flip_x'       => ! empty( $object['flip_x'] ),
			);
			++$object_counter;
		}

		return $clean;
	}

	/**
	 * Sanitize a generated floor-plan coordinate.
	 *
	 * @param mixed $coordinate Raw coordinate.
	 */
	private static function sanitize_floor_plan_coordinate( $coordinate ): float {
		return round( min( 98, max( 2, (float) $coordinate ) ), 2 );
	}

	/**
	 * Sanitize a generated floor-plan dimension.
	 *
	 * @param mixed $dimension Raw dimension.
	 * @param float $fallback  Default dimension.
	 */
	private static function sanitize_floor_plan_dimension( $dimension, float $fallback ): float {
		$dimension = is_numeric( $dimension ) ? (float) $dimension : $fallback;

		return round( min( 60, max( 4, $dimension ) ), 2 );
	}

	/**
	 * Sanitize a generated floor-plan rotation.
	 *
	 * @param mixed $rotation Raw rotation.
	 */
	private static function sanitize_floor_plan_rotation( $rotation ): float {
		$rotation = is_numeric( $rotation ) ? (float) $rotation : 0;
		$rotation = fmod( $rotation, 360 );

		return round( $rotation < -180 ? $rotation + 360 : ( $rotation > 180 ? $rotation - 360 : $rotation ), 1 );
	}

	/**
	 * Sanitize a generated floor-plan color.
	 *
	 * @param mixed  $color    Raw color.
	 * @param string $fallback Fallback color.
	 */
	private static function sanitize_floor_plan_color( $color, string $fallback ): string {
		$color = sanitize_hex_color( (string) $color );

		return is_string( $color ) && '' !== $color ? $color : $fallback;
	}

	/**
	 * Sanitize the late booking option.
	 *
	 * @param mixed $limit Raw late booking limit.
	 */
	private static function sanitize_late_booking( $limit ): string {
		$limit   = sanitize_key( (string) $limit );
		$options = array_map( 'strval', array_keys( self::get_late_booking_options() ) );

		return in_array( $limit, $options, true ) ? $limit : '0';
	}

	/**
	 * Sanitize blocked email and domain entries.
	 *
	 * @param mixed $entries Raw entries.
	 * @return array<int,string>
	 */
	private static function sanitize_email_blocklist( $entries ): array {
		$sanitized = array();

		foreach ( self::normalize_blocklist_entries( $entries ) as $entry ) {
			$entry = self::sanitize_email_block_entry( $entry );
			if ( '' !== $entry ) {
				$sanitized[] = $entry;
			}
		}

		return array_values( array_unique( $sanitized ) );
	}

	/**
	 * Sanitize blocked IP entries.
	 *
	 * @param mixed $entries Raw entries.
	 * @return array<int,string>
	 */
	private static function sanitize_ip_blocklist( $entries ): array {
		$sanitized = array();

		foreach ( self::normalize_blocklist_entries( $entries ) as $entry ) {
			$entry = self::sanitize_ip_block_entry( $entry );
			if ( '' !== $entry ) {
				$sanitized[] = $entry;
			}
		}

		return array_values( array_unique( $sanitized ) );
	}

	/**
	 * Normalize textarea or array blocklist entries.
	 *
	 * @param mixed $entries Raw entries.
	 * @return array<int,string>
	 */
	private static function normalize_blocklist_entries( $entries ): array {
		if ( is_array( $entries ) ) {
			return array_map( 'strval', $entries );
		}

		$entries = (string) $entries;
		$lines   = preg_split( '/\R+/', $entries );

		return is_array( $lines ) ? $lines : array();
	}

	/**
	 * Sanitize a blocked email or domain entry.
	 *
	 * @param string $entry Raw entry.
	 */
	private static function sanitize_email_block_entry( string $entry ): string {
		$entry = strtolower( trim( sanitize_text_field( wp_unslash( $entry ) ) ) );
		if ( '' === $entry ) {
			return '';
		}

		if ( str_starts_with( $entry, '@' ) ) {
			$domain = substr( $entry, 1 );

			return self::is_valid_blocked_domain( $domain ) ? '@' . $domain : '';
		}

		$email = sanitize_email( $entry );

		return is_email( $email ) ? strtolower( $email ) : '';
	}

	/**
	 * Sanitize a blocked IP entry.
	 *
	 * @param string $entry Raw entry.
	 */
	private static function sanitize_ip_block_entry( string $entry ): string {
		$entry = trim( sanitize_text_field( wp_unslash( $entry ) ) );

		return false !== filter_var( $entry, FILTER_VALIDATE_IP ) ? $entry : '';
	}

	/**
	 * Check whether a blocked domain entry is valid.
	 *
	 * @param string $domain Domain without leading @.
	 */
	private static function is_valid_blocked_domain( string $domain ): bool {
		$domain = trim( strtolower( $domain ) );
		if ( '' === $domain || str_contains( $domain, '@' ) ) {
			return false;
		}

		return 1 === preg_match( '/\A[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+\z/', $domain );
	}

	/**
	 * Convert an H:i time value to minutes after midnight.
	 *
	 * @param string $time Time in H:i format.
	 */
	private static function time_to_minutes( string $time ): int {
		return ( (int) substr( $time, 0, 2 ) * 60 ) + (int) substr( $time, 3, 2 );
	}

	/**
	 * Check whether a configured range crosses midnight.
	 *
	 * Equal opening and closing times represent a full overnight cycle, matching
	 * the delivery and pickup business-hours behavior.
	 *
	 * @param array{from:string,to:string,interval:int} $range Booking range.
	 */
	private static function booking_range_is_overnight( array $range ): bool {
		return self::time_to_minutes( (string) $range['to'] ) <= self::time_to_minutes( (string) $range['from'] );
	}

	/**
	 * Check whether a weekday has its own slots or receives overnight spillover.
	 *
	 * @param array<int,array{enabled:bool,ranges:array<int,array{from:string,to:string,interval:int}>}> $schedule Weekly schedule.
	 * @param int                                                                                        $weekday Weekday number, Sunday = 0.
	 */
	private static function weekday_has_booking_slots( array $schedule, int $weekday ): bool {
		$weekday = $weekday % 7;
		$day     = $schedule[ $weekday ] ?? array();
		if ( ! empty( $day['enabled'] ) && ! empty( $day['ranges'] ) ) {
			return true;
		}

		$previous_day = $schedule[ ( $weekday + 6 ) % 7 ] ?? array();
		if ( empty( $previous_day['enabled'] ) || empty( $previous_day['ranges'] ) ) {
			return false;
		}

		foreach ( $previous_day['ranges'] as $range ) {
			if ( ! self::booking_range_is_overnight( $range ) ) {
				continue;
			}

			$start    = self::time_to_minutes( (string) $range['from'] );
			$end      = 1440 + self::time_to_minutes( (string) $range['to'] );
			$interval = max( 1, absint( $range['interval'] ) );
			$steps    = (int) ceil( max( 0, 1440 - $start ) / $interval );
			if ( $start + ( $steps * $interval ) <= $end ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Build booking periods that intersect one calendar date.
	 *
	 * The result includes ranges that start on the requested date and overnight
	 * spillover from the previous date. Period boundaries retain their real
	 * datetimes so duration validation can cross midnight safely.
	 *
	 * @param string $date Reservation date.
	 * @return array<int,array{start:DateTimeImmutable,end:DateTimeImmutable,interval:int}>
	 */
	private function get_booking_periods_for_date( string $date ): array {
		$date = $this->normalize_date( $date );
		if ( '' === $date ) {
			return array();
		}

		$target_start = DateTimeImmutable::createFromFormat( '!Y-m-d', $date, wp_timezone() );
		if ( ! $target_start instanceof DateTimeImmutable ) {
			return array();
		}

		$target_end = $target_start->modify( '+1 day' );
		$settings   = self::get_booking_settings();
		$periods    = array();

		foreach ( array( $target_start, $target_start->modify( '-1 day' ) ) as $source_date ) {
			$weekday = (int) $source_date->format( 'w' );
			$day     = $settings['schedule'][ $weekday ] ?? array();
			if ( empty( $day['enabled'] ) || empty( $day['ranges'] ) ) {
				continue;
			}

			foreach ( $day['ranges'] as $range ) {
				$start_minutes = self::time_to_minutes( (string) $range['from'] );
				$end_minutes   = self::time_to_minutes( (string) $range['to'] );
				$start         = $source_date->setTime( (int) floor( $start_minutes / 60 ), $start_minutes % 60 );
				$end_date      = self::booking_range_is_overnight( $range ) ? $source_date->modify( '+1 day' ) : $source_date;
				$end           = $end_date->setTime( (int) floor( $end_minutes / 60 ), $end_minutes % 60 );

				if ( $start >= $target_end || $end < $target_start ) {
					continue;
				}

				$periods[] = array(
					'start'    => $start,
					'end'      => $end,
					'interval' => max( 1, absint( $range['interval'] ) ),
				);
			}
		}

		return $periods;
	}

	/**
	 * Get booking slots and display ranges for one calendar date.
	 *
	 * @param string $date Reservation date.
	 * @return array{options:string[],ranges:array<int,array{from:string,to:string,interval:int}>}
	 */
	private function get_booking_schedule_for_date( string $date ): array {
		$date    = $this->normalize_date( $date );
		$options = array();
		$ranges  = array();

		foreach ( $this->get_booking_periods_for_date( $date ) as $period ) {
			$period_options = array();
			$cursor         = $period['start'];
			while ( $cursor <= $period['end'] ) {
				if ( $date === $cursor->format( 'Y-m-d' ) ) {
					$period_options[] = $cursor->format( 'H:i' );
				}
				$cursor = $cursor->modify( '+' . $period['interval'] . ' minutes' );
			}

			if ( empty( $period_options ) ) {
				continue;
			}

			$options  = array_merge( $options, $period_options );
			$ranges[] = array(
				'from'     => (string) reset( $period_options ),
				'to'       => (string) end( $period_options ),
				'interval' => $period['interval'],
			);
		}

		$options = array_values( array_unique( $options ) );
		sort( $options );
		usort(
			$ranges,
			static function ( array $first, array $second ): int {
				return strcmp( $first['from'], $second['from'] );
			}
		);

		return array(
			'options' => $options,
			'ranges'  => $ranges,
		);
	}

	/**
	 * Get available time options for a date.
	 *
	 * @param string                 $date                   Reservation date.
	 * @param int                    $party_size             Optional party size for table availability filtering.
	 * @param int                    $exclude_reservation_id Optional reservation ID to ignore during availability checks.
	 * @param bool                   $ignore_capacity_limits Whether concurrent capacity limits should be bypassed.
	 * @param bool                   $allow_over_capacity    Whether staff may exceed individual table capacity.
	 * @param int                    $duration_minutes       Optional staff-selected duration in minutes.
	 * @param array<string,int>|null $default_durations Optional configured duration map keyed by time.
	 * @param bool                   $ignore_booking_limits  Whether maximum advance and late booking limits should be bypassed.
	 * @return string[]
	 */
	public function get_time_options_for_date( string $date, int $party_size = 0, int $exclude_reservation_id = 0, bool $ignore_capacity_limits = false, bool $allow_over_capacity = false, int $duration_minutes = 0, ?array &$default_durations = null, bool $ignore_booking_limits = false ): array {
		$date                   = $this->normalize_date( $date );
		$party_size             = absint( $party_size );
		$exclude_reservation_id = absint( $exclude_reservation_id );
		$duration_minutes       = self::sanitize_optional_reservation_duration_minutes( $duration_minutes );
		if ( null !== $default_durations ) {
			$default_durations = array();
		}
		if ( '' === $date || ! $this->is_booking_date_available( $date, $ignore_booking_limits ) ) {
			return array();
		}

		if ( $party_size > 0 && ! $allow_over_capacity ) {
			$max_capacity = $this->get_max_active_table_capacity();
			if ( $max_capacity > 0 && $party_size > $max_capacity ) {
				return array();
			}
		}

		$schedule = $this->get_booking_schedule_for_date( $date );
		$options  = $schedule['options'];

		$options = array_values(
			array_filter(
				$options,
				function ( string $time ) use ( $date, $ignore_booking_limits ): bool {
					return $this->is_booking_time_within_limits( $date, $time, $ignore_booking_limits );
				}
			)
		);

		if ( $party_size > 0 ) {
			$this->cleanup_expired_holds();
			$options = array_values(
				array_filter(
					$options,
					function ( string $time ) use ( $date, $party_size, $exclude_reservation_id, $ignore_capacity_limits, $allow_over_capacity, $duration_minutes, &$default_durations, $ignore_booking_limits ): bool {
						if ( $duration_minutes > 0 && ! $ignore_booking_limits && ! $this->reservation_duration_fits_booking_hours( $date, $time, $duration_minutes ) ) {
							return false;
						}

						$table = $allow_over_capacity
							? $this->find_available_table_for_staff( $date, $time, $party_size, $exclude_reservation_id, $duration_minutes )
							: $this->find_available_table( $date, $time, $party_size, $exclude_reservation_id, $duration_minutes );
						if ( ! $table ) {
							return false;
						}

						$configured_duration = self::sanitize_reservation_duration_minutes( $table->reservation_duration_minutes ?? self::DEFAULT_DURATION_MINUTES );
						if ( is_array( $default_durations ) ) {
							$default_durations[ $time ] = $configured_duration;
						}

						if ( $ignore_capacity_limits ) {
							return true;
						}

						$duration = $duration_minutes > 0
							? $duration_minutes
							: $configured_duration;

						return ! is_wp_error( $this->validate_capacity_limits( $date, $time, $duration, $party_size, $exclude_reservation_id ) );
					}
				)
			);
		}

		return $options;
	}

	/**
	 * Get available slots in operational-day order.
	 *
	 * Overnight slots remain at the end of the selected operational day while
	 * retaining their actual next-day reservation date.
	 *
	 * @param string $date                   Operational date.
	 * @param int    $party_size             Optional party size for table availability filtering.
	 * @param int    $exclude_reservation_id Optional reservation ID to ignore during availability checks.
	 * @param bool   $ignore_capacity_limits Whether concurrent capacity limits should be bypassed.
	 * @param bool   $allow_over_capacity    Whether staff may exceed individual table capacity.
	 * @param int    $duration_minutes       Optional staff-selected duration in minutes.
	 * @param bool   $ignore_booking_limits  Whether maximum advance and late booking limits should be bypassed.
	 * @return array<int,array{date:string,time:string,default_duration_minutes:int}>
	 */
	public function get_operational_time_options_for_date( string $date, int $party_size = 0, int $exclude_reservation_id = 0, bool $ignore_capacity_limits = false, bool $allow_over_capacity = false, int $duration_minutes = 0, bool $ignore_booking_limits = false ): array {
		$date        = $this->normalize_date( $date );
		$parsed_date = DateTimeImmutable::createFromFormat( '!Y-m-d', $date, wp_timezone() );
		if ( '' === $date || ! $parsed_date instanceof DateTimeImmutable ) {
			return array();
		}

		$schedule = $this->get_timeline_day_schedule( $date, true );
		$ranges   = isset( $schedule['ranges'] ) && is_array( $schedule['ranges'] ) ? $schedule['ranges'] : array();
		if ( empty( $ranges ) ) {
			return array();
		}

		usort(
			$ranges,
			static function ( array $first, array $second ): int {
				$first_start  = ( absint( $first['start_day_offset'] ?? 0 ) * 1440 ) + self::time_to_minutes( (string) ( $first['from'] ?? '' ) );
				$second_start = ( absint( $second['start_day_offset'] ?? 0 ) * 1440 ) + self::time_to_minutes( (string) ( $second['from'] ?? '' ) );

				return $first_start <=> $second_start;
			}
		);

		$available_by_date = array();
		$durations_by_date = array();
		$seen              = array();
		$slots             = array();

		foreach ( $ranges as $range ) {
			$start_offset = absint( $range['start_day_offset'] ?? 0 );
			$end_offset   = absint( $range['end_day_offset'] ?? $start_offset );
			$start        = ( $start_offset * 1440 ) + self::time_to_minutes( (string) ( $range['from'] ?? '' ) );
			$end          = ( $end_offset * 1440 ) + self::time_to_minutes( (string) ( $range['to'] ?? '' ) );
			$interval     = max( 1, absint( $range['interval'] ?? 0 ) );
			if ( $end < $start ) {
				continue;
			}

			for ( $slot_minutes = $start; $slot_minutes <= $end; $slot_minutes += $interval ) {
				$day_offset = (int) floor( $slot_minutes / 1440 );
				$time_value = $slot_minutes % 1440;
				$slot_date  = $parsed_date->modify( '+' . $day_offset . ' days' )->format( 'Y-m-d' );
				$slot_time  = sprintf( '%02d:%02d', (int) floor( $time_value / 60 ), $time_value % 60 );
				$slot_key   = $slot_date . '|' . $slot_time;
				if ( isset( $seen[ $slot_key ] ) ) {
					continue;
				}

				if ( ! isset( $available_by_date[ $slot_date ] ) ) {
					$default_durations               = array();
					$available_times                 = $this->get_time_options_for_date(
						$slot_date,
						$party_size,
						$exclude_reservation_id,
						$ignore_capacity_limits,
						$allow_over_capacity,
						$duration_minutes,
						$default_durations,
						$ignore_booking_limits
					);
					$available_by_date[ $slot_date ] = array_fill_keys( $available_times, true );
					$durations_by_date[ $slot_date ] = $default_durations;
				}

				if ( ! isset( $available_by_date[ $slot_date ][ $slot_time ] ) ) {
					continue;
				}

				$seen[ $slot_key ] = true;
				$slots[]           = array(
					'date'                     => $slot_date,
					'time'                     => $slot_time,
					'default_duration_minutes' => absint( $durations_by_date[ $slot_date ][ $slot_time ] ?? 0 ),
				);
			}
		}

		return $slots;
	}

	/**
	 * Get the maximum duration allowed by the booking-hours range containing a reservation.
	 *
	 * @param object $reservation Reservation row.
	 */
	public function get_maximum_duration_for_reservation( $reservation ): int {
		$current_duration = self::sanitize_reservation_duration_minutes( $reservation->duration_minutes ?? self::DEFAULT_DURATION_MINUTES );
		$date             = $this->normalize_date( (string) ( $reservation->reservation_date ?? '' ) );
		$time             = $this->normalize_time( (string) ( $reservation->reservation_time ?? '' ) );
		$start            = $this->get_booking_slot_datetime( $date, $time );
		$range_end        = $this->get_booking_range_end_datetime( $date, $time );

		if ( ! $start instanceof DateTimeImmutable || ! $range_end instanceof DateTimeImmutable ) {
			return $current_duration;
		}

		$available_minutes  = max( 0, (int) floor( ( $range_end->getTimestamp() - $start->getTimestamp() ) / MINUTE_IN_SECONDS ) );
		$available_duration = (int) ( floor( $available_minutes / self::DURATION_STEP_MINUTES ) * self::DURATION_STEP_MINUTES );
		$maximum_duration   = min( self::MAX_DURATION_MINUTES, max( self::MIN_DURATION_MINUTES, $available_duration ) );

		return max( $current_duration, $maximum_duration );
	}

	/**
	 * Sanitize reservation status.
	 *
	 * @param string $status Raw status.
	 * @return string
	 */
	public static function sanitize_status( string $status ): string {
		$status = sanitize_key( $status );

		return in_array( $status, self::get_status_keys(), true ) ? $status : 'pending';
	}

	/**
	 * Sanitize the reservation duration in minutes.
	 *
	 * @param mixed $duration Raw duration value.
	 */
	public static function sanitize_reservation_duration_minutes( $duration ): int {
		$duration = absint( $duration );
		if ( $duration <= 0 ) {
			$duration = self::DEFAULT_DURATION_MINUTES;
		}
		$duration = (int) ( ceil( $duration / self::DURATION_STEP_MINUTES ) * self::DURATION_STEP_MINUTES );

		return min( self::MAX_DURATION_MINUTES, max( self::MIN_DURATION_MINUTES, $duration ) );
	}

	/**
	 * Sanitize an optional staff-selected reservation duration.
	 *
	 * A zero value preserves the duration configured on the assigned table.
	 *
	 * @param mixed $duration Raw duration value.
	 */
	public static function sanitize_optional_reservation_duration_minutes( $duration ): int {
		$duration = absint( $duration );
		if ( 0 === $duration ) {
			return 0;
		}

		return self::sanitize_reservation_duration_minutes( $duration );
	}

	/**
	 * Get supported reservation duration values.
	 *
	 * @return array<int,int>
	 */
	public static function get_reservation_duration_options(): array {
		return range( self::MIN_DURATION_MINUTES, self::MAX_DURATION_MINUTES, self::DURATION_STEP_MINUTES );
	}

	/**
	 * Get the minimum supported reservation duration.
	 */
	public static function get_min_reservation_duration_minutes(): int {
		return self::MIN_DURATION_MINUTES;
	}

	/**
	 * Get selectable tables and duration limits for an exact staff slot.
	 *
	 * @param string $date                  Reservation date.
	 * @param string $time                  Reservation time.
	 * @param int    $party_size            Guest count.
	 * @param bool   $allow_over_capacity   Whether table capacity may be exceeded.
	 * @param bool   $ignore_booking_limits Whether maximum advance and late booking limits should be bypassed.
	 * @return array<int,array<string,mixed>>|WP_Error
	 */
	public function get_staff_table_options_for_slot( string $date, string $time, int $party_size, bool $allow_over_capacity = false, bool $ignore_booking_limits = false ) {
		$date       = $this->normalize_date( $date );
		$time       = $this->normalize_time( $time );
		$party_size = max( 1, absint( $party_size ) );

		if ( '' === $date || '' === $time ) {
			return new WP_Error( 'foodord_reservation_invalid_slot', __( 'Invalid reservation date or time.', 'foodord' ) );
		}

		$slot_validation = $this->validate_booking_slot( $date, $time, $ignore_booking_limits );
		if ( is_wp_error( $slot_validation ) ) {
			return $slot_validation;
		}

		$this->cleanup_expired_holds();
		$availability_party_size = $allow_over_capacity ? 1 : $party_size;
		$tables                  = $this->get_available_tables_for_slot( $date, $time, $availability_party_size, 0, self::MIN_DURATION_MINUTES );
		$max_durations           = $this->get_max_reservable_staff_durations(
			array_map(
				static function ( $table ): int {
					return absint( $table->id ?? 0 );
				},
				$tables
			),
			$date,
			$time
		);
		$options                 = array();

		foreach ( $tables as $table ) {
			$table_id     = absint( $table->id ?? 0 );
			$max_duration = absint( $max_durations[ $table_id ] ?? 0 );
			if ( $table_id <= 0 || $max_duration < self::MIN_DURATION_MINUTES ) {
				continue;
			}

			$configured_duration = self::sanitize_reservation_duration_minutes( $table->reservation_duration_minutes ?? self::DEFAULT_DURATION_MINUTES );
			$duration_options    = array_values(
				array_filter(
					self::get_reservation_duration_options(),
					static function ( int $duration ) use ( $max_duration ): bool {
						return $duration <= $max_duration;
					}
				)
			);

			$options[] = array(
				'id'                       => $table_id,
				'name'                     => (string) ( $table->name ?? '' ),
				'floor_name'               => (string) ( $table->floor_name ?? '' ),
				'capacity'                 => absint( $table->capacity ?? 0 ),
				'default_duration_minutes' => min( $configured_duration, $max_duration ),
				'max_duration_minutes'     => $max_duration,
				'duration_options'         => $duration_options,
			);
		}

		return $options;
	}

	/**
	 * Sanitize an optional cancellation reason.
	 *
	 * @param string $reason Raw cancellation reason.
	 */
	public static function sanitize_cancellation_reason( string $reason ): string {
		$reason = trim( sanitize_textarea_field( $reason ) );

		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $reason, 0, 500 );
		}

		return substr( $reason, 0, 500 );
	}

	/**
	 * Create a floor.
	 *
	 * @param array<string,mixed> $data Raw floor data.
	 * @return int|WP_Error
	 */
	public function create_floor( array $data ) {
		$db      = self::db();
		$payload = $this->sanitize_floor_payload( $data );
		if ( is_wp_error( $payload ) ) {
			return $payload;
		}

		if ( $this->floor_name_exists( $payload['name'], 0 ) ) {
			return new WP_Error( 'foodord_reservation_floor_duplicate', __( 'A floor with this name already exists.', 'foodord' ) );
		}

		$tables = self::table_names();
		$now    = current_time( 'mysql', true );

		$inserted = $db->insert(
			$tables['floors'],
			array(
				'name'           => $payload['name'],
				'sort_order'     => $payload['sort_order'],
				'created_at_gmt' => $now,
				'updated_at_gmt' => $now,
			),
			array( '%s', '%d', '%s', '%s' )
		);

		if ( false === $inserted ) {
			return new WP_Error( 'foodord_reservation_floor_insert_failed', __( 'Unable to save the floor.', 'foodord' ) );
		}

		self::increment_floor_plan_revision();
		self::increment_sync_revision();

		return (int) $db->insert_id;
	}

	/**
	 * Update a floor.
	 *
	 * @param int                 $floor_id Floor ID.
	 * @param array<string,mixed> $data     Raw floor data.
	 * @return bool|WP_Error
	 */
	public function update_floor( int $floor_id, array $data ) {
		$db       = self::db();
		$floor_id = absint( $floor_id );
		if ( $floor_id <= 0 ) {
			return new WP_Error( 'foodord_reservation_floor_invalid_id', __( 'Invalid floor.', 'foodord' ) );
		}

		$payload = $this->sanitize_floor_payload( $data );
		if ( is_wp_error( $payload ) ) {
			return $payload;
		}

		if ( $this->floor_name_exists( $payload['name'], $floor_id ) ) {
			return new WP_Error( 'foodord_reservation_floor_duplicate', __( 'A floor with this name already exists.', 'foodord' ) );
		}

		$tables  = self::table_names();
		$updated = $db->update(
			$tables['floors'],
			array(
				'name'           => $payload['name'],
				'sort_order'     => $payload['sort_order'],
				'updated_at_gmt' => current_time( 'mysql', true ),
			),
			array( 'id' => $floor_id ),
			array( '%s', '%d', '%s' ),
			array( '%d' )
		);

		if ( false === $updated ) {
			return new WP_Error( 'foodord_reservation_floor_update_failed', __( 'Unable to update the floor.', 'foodord' ) );
		}

		self::increment_floor_plan_revision();
		self::increment_sync_revision();

		return true;
	}

	/**
	 * Delete a floor when it has no tables.
	 *
	 * @param int $floor_id Floor ID.
	 * @return bool|WP_Error
	 */
	public function delete_floor( int $floor_id ) {
		$db       = self::db();
		$floor_id = absint( $floor_id );
		if ( $floor_id <= 0 ) {
			return new WP_Error( 'foodord_reservation_floor_invalid_id', __( 'Invalid floor.', 'foodord' ) );
		}

		$tables      = self::table_names();
		$table_count = (int) $db->get_var(
			$db->prepare(
				'SELECT COUNT(*) FROM %i WHERE floor_id = %d',
				$tables['tables'],
				$floor_id
			)
		);

		if ( $table_count > 0 ) {
			return new WP_Error( 'foodord_reservation_floor_in_use', __( 'This floor has assigned tables.', 'foodord' ) );
		}

		$deleted = $db->delete( $tables['floors'], array( 'id' => $floor_id ), array( '%d' ) );
		if ( false === $deleted ) {
			return new WP_Error( 'foodord_reservation_floor_delete_failed', __( 'Unable to delete the floor.', 'foodord' ) );
		}

		self::remove_table_selection_floor_layout( $floor_id );
		self::increment_floor_plan_revision();
		self::increment_sync_revision();

		return true;
	}

	/**
	 * Get configured floors.
	 *
	 * @return array<int,object>
	 */
	public function get_floors(): array {
		$db     = self::db();
		$tables = self::table_names();
		$floors = $db->get_results(
			$db->prepare(
				'SELECT * FROM %i ORDER BY sort_order ASC, name ASC, id ASC',
				$tables['floors']
			)
		);

		return is_array( $floors ) ? $floors : array();
	}

	/**
	 * Get one configured floor.
	 *
	 * @param int $floor_id Floor ID.
	 * @return object|null
	 */
	public function get_floor( int $floor_id ) {
		$db       = self::db();
		$floor_id = absint( $floor_id );
		if ( $floor_id <= 0 ) {
			return null;
		}

		$tables = self::table_names();

		return $db->get_row(
			$db->prepare(
				'SELECT * FROM %i WHERE id = %d',
				$tables['floors'],
				$floor_id
			)
		);
	}

	/**
	 * Create a reservation table.
	 *
	 * @param array<string,mixed> $data Raw table data.
	 * @return int|WP_Error
	 */
	public function create_table( array $data ) {
		$db      = self::db();
		$payload = $this->sanitize_table_payload( $data );
		if ( is_wp_error( $payload ) ) {
			return $payload;
		}

		$tables = self::table_names();
		$now    = current_time( 'mysql', true );

		if ( $this->table_name_exists_in_floor( $payload['name'], $payload['floor_id'], 0 ) ) {
			return new WP_Error( 'foodord_reservation_table_duplicate', __( 'A table with this name already exists on the selected floor.', 'foodord' ) );
		}

		$inserted = $db->insert(
			$tables['tables'],
			array(
				'floor_id'                     => $payload['floor_id'],
				'name'                         => $payload['name'],
				'capacity'                     => $payload['capacity'],
				'reservation_duration_minutes' => $payload['reservation_duration_minutes'],
				'is_active'                    => $payload['is_active'],
				'sort_order'                   => $payload['sort_order'],
				'created_at_gmt'               => $now,
				'updated_at_gmt'               => $now,
			),
			array( '%d', '%s', '%d', '%d', '%d', '%d', '%s', '%s' )
		);

		if ( false === $inserted ) {
			return new WP_Error( 'foodord_reservation_table_insert_failed', __( 'Unable to save the table.', 'foodord' ) );
		}

		$table_id = (int) $db->insert_id;
		$this->sync_table_selection_marker( $table_id, $payload['floor_id'] );
		self::increment_floor_plan_revision();
		self::increment_sync_revision();

		return $table_id;
	}

	/**
	 * Update a reservation table.
	 *
	 * @param int                 $table_id Table ID.
	 * @param array<string,mixed> $data     Raw table data.
	 * @return bool|WP_Error
	 */
	public function update_table( int $table_id, array $data ) {
		$db       = self::db();
		$table_id = absint( $table_id );
		if ( $table_id <= 0 ) {
			return new WP_Error( 'foodord_reservation_table_invalid_id', __( 'Invalid table.', 'foodord' ) );
		}

		$payload = $this->sanitize_table_payload( $data );
		if ( is_wp_error( $payload ) ) {
			return $payload;
		}

		$tables = self::table_names();
		if ( $this->table_name_exists_in_floor( $payload['name'], $payload['floor_id'], $table_id ) ) {
			return new WP_Error( 'foodord_reservation_table_duplicate', __( 'A table with this name already exists on the selected floor.', 'foodord' ) );
		}

		$updated = $db->update(
			$tables['tables'],
			array(
				'floor_id'                     => $payload['floor_id'],
				'name'                         => $payload['name'],
				'capacity'                     => $payload['capacity'],
				'reservation_duration_minutes' => $payload['reservation_duration_minutes'],
				'is_active'                    => $payload['is_active'],
				'sort_order'                   => $payload['sort_order'],
				'updated_at_gmt'               => current_time( 'mysql', true ),
			),
			array( 'id' => $table_id ),
			array( '%d', '%s', '%d', '%d', '%d', '%d', '%s' ),
			array( '%d' )
		);

		if ( false === $updated ) {
			return new WP_Error( 'foodord_reservation_table_update_failed', __( 'Unable to update the table.', 'foodord' ) );
		}

		$this->sync_table_selection_marker( $table_id, $payload['floor_id'] );
		self::increment_floor_plan_revision();
		self::increment_sync_revision();

		return true;
	}

	/**
	 * Delete a reservation table when it has no dependent reservations or holds.
	 *
	 * @param int $table_id Table ID.
	 * @return bool|WP_Error
	 */
	public function delete_table( int $table_id ) {
		$db       = self::db();
		$table_id = absint( $table_id );
		if ( $table_id <= 0 ) {
			return new WP_Error( 'foodord_reservation_table_invalid_id', __( 'Invalid table.', 'foodord' ) );
		}

		$tables = self::table_names();
		$this->cleanup_expired_holds();

		$dependency_count = (int) $db->get_var(
			$db->prepare(
				'SELECT (
					(SELECT COUNT(*) FROM %i WHERE table_id = %d) +
					(SELECT COUNT(*) FROM %i WHERE table_id = %d)
				)',
				$tables['reservations'],
				$table_id,
				$tables['holds'],
				$table_id
			)
		);

		if ( $dependency_count > 0 ) {
			return new WP_Error( 'foodord_reservation_table_in_use', __( 'This table already has reservations or active holds.', 'foodord' ) );
		}

		$deleted = $db->delete( $tables['tables'], array( 'id' => $table_id ), array( '%d' ) );
		if ( false === $deleted ) {
			return new WP_Error( 'foodord_reservation_table_delete_failed', __( 'Unable to delete the table.', 'foodord' ) );
		}

		self::remove_table_selection_marker( $table_id );
		self::increment_floor_plan_revision();
		self::increment_sync_revision();

		return true;
	}

	/**
	 * Get configured tables.
	 *
	 * @param bool $include_inactive Include inactive tables.
	 * @return array<int,object>
	 */
	public function get_tables( bool $include_inactive = true ): array {
		$db     = self::db();
		$tables = self::table_names();

		if ( $include_inactive ) {
			return $db->get_results(
				$db->prepare(
					'SELECT reservation_tables.*, reservation_floors.name AS floor_name
					FROM %i reservation_tables
					LEFT JOIN %i reservation_floors ON reservation_floors.id = reservation_tables.floor_id
					ORDER BY reservation_floors.sort_order ASC, reservation_floors.name ASC, reservation_tables.sort_order ASC, reservation_tables.capacity ASC, reservation_tables.id ASC',
					$tables['tables'],
					$tables['floors']
				)
			);
		}

		return $db->get_results(
			$db->prepare(
				'SELECT reservation_tables.*, reservation_floors.name AS floor_name
				FROM %i reservation_tables
				LEFT JOIN %i reservation_floors ON reservation_floors.id = reservation_tables.floor_id
				WHERE reservation_tables.is_active = 1
				ORDER BY reservation_floors.sort_order ASC, reservation_floors.name ASC, reservation_tables.sort_order ASC, reservation_tables.capacity ASC, reservation_tables.id ASC',
				$tables['tables'],
				$tables['floors']
			)
		);
	}

	/**
	 * Get one configured table.
	 *
	 * @param int $table_id Table ID.
	 * @return object|null
	 */
	public function get_table( int $table_id ) {
		$db       = self::db();
		$table_id = absint( $table_id );
		if ( $table_id <= 0 ) {
			return null;
		}

		$tables = self::table_names();

		return $db->get_row(
			$db->prepare(
				'SELECT reservation_tables.*, reservation_floors.name AS floor_name
				FROM %i reservation_tables
				LEFT JOIN %i reservation_floors ON reservation_floors.id = reservation_tables.floor_id
				WHERE reservation_tables.id = %d',
				$tables['tables'],
				$tables['floors'],
				$table_id
			)
		);
	}

	/**
	 * Get reservations with filters and pagination.
	 *
	 * @param array<string,mixed> $args Query args.
	 * @return array{items:array<int,object>,total:int,page:int,per_page:int,total_pages:int}
	 */
	public function get_reservations( array $args = array() ): array {
		self::reconcile_ended_reservations();

		$db     = self::db();
		$tables = self::table_names();

		$page      = max( 1, absint( $args['page'] ?? 1 ) );
		$per_page  = min( 100, max( 1, absint( $args['per_page'] ?? 20 ) ) );
		$offset    = ( $page - 1 ) * $per_page;
		$status    = isset( $args['status'] ) ? sanitize_key( (string) $args['status'] ) : '';
		$date      = isset( $args['date'] ) ? $this->normalize_date( (string) $args['date'] ) : '';
		$date_from = isset( $args['date_from'] ) ? $this->normalize_date( (string) $args['date_from'] ) : '';
		$date_to   = isset( $args['date_to'] ) ? $this->normalize_date( (string) $args['date_to'] ) : '';
		$floor_id  = absint( $args['floor_id'] ?? 0 );
		$search    = isset( $args['search'] ) ? sanitize_text_field( (string) $args['search'] ) : '';

		if ( '' !== $date_from && '' !== $date_to && $date_to < $date_from ) {
			$temporary = $date_from;
			$date_from = $date_to;
			$date_to   = $temporary;
		}

		$where        = array( '1=1' );
		$where_values = array();

		if ( '' !== $status && in_array( $status, self::get_status_keys(), true ) ) {
			$where[]        = 'reservations.status = %s';
			$where_values[] = $status;
		}

		if ( '' !== $date ) {
			$where[]        = 'reservations.reservation_date = %s';
			$where_values[] = $date;
		} elseif ( '' !== $date_from && '' !== $date_to ) {
			$where[]        = 'reservations.reservation_date BETWEEN %s AND %s';
			$where_values[] = $date_from;
			$where_values[] = $date_to;
		} elseif ( '' !== $date_from ) {
			$where[]        = 'reservations.reservation_date >= %s';
			$where_values[] = $date_from;
		} elseif ( '' !== $date_to ) {
			$where[]        = 'reservations.reservation_date <= %s';
			$where_values[] = $date_to;
		}

		if ( $floor_id > 0 ) {
			$where[]        = 'reservation_tables.floor_id = %d';
			$where_values[] = $floor_id;
		}

		if ( '' !== $search ) {
			$like           = '%' . $db->esc_like( $search ) . '%';
			$where[]        = '(reservations.reservation_code LIKE %s OR reservations.customer_name LIKE %s OR reservations.customer_email LIKE %s OR reservations.customer_phone LIKE %s)';
			$where_values[] = $like;
			$where_values[] = $like;
			$where_values[] = $like;
			$where_values[] = $like;
		}

		$where_sql = implode( ' AND ', $where );

		$total = (int) $db->get_var(
			$db->prepare(
				"SELECT COUNT(*)
				FROM %i reservations
				LEFT JOIN %i reservation_tables ON reservation_tables.id = reservations.table_id
				LEFT JOIN %i reservation_floors ON reservation_floors.id = reservation_tables.floor_id
				WHERE {$where_sql}",
				array_merge(
					array(
						$tables['reservations'],
						$tables['tables'],
						$tables['floors'],
					),
					$where_values
				)
			)
		);

		$items = $db->get_results(
			$db->prepare(
				"SELECT reservations.*, reservation_tables.name AS table_name, reservation_floors.name AS floor_name
				FROM %i reservations
				LEFT JOIN %i reservation_tables ON reservation_tables.id = reservations.table_id
				LEFT JOIN %i reservation_floors ON reservation_floors.id = reservation_tables.floor_id
				WHERE {$where_sql}
				ORDER BY reservations.created_at_gmt DESC, reservations.id DESC
				LIMIT %d OFFSET %d",
				array_merge(
					array(
						$tables['reservations'],
						$tables['tables'],
						$tables['floors'],
					),
					$where_values,
					array(
						$per_page,
						$offset,
					)
				)
			)
		);

		return array(
			'items'       => is_array( $items ) ? $items : array(),
			'total'       => $total,
			'page'        => $page,
			'per_page'    => $per_page,
			'total_pages' => max( 1, (int) ceil( $total / $per_page ) ),
		);
	}

	/**
	 * Get reservations between two dates for calendar-style views.
	 *
	 * @param array<string,mixed> $args Query arguments.
	 * @return array<int,object>
	 */
	public function get_reservations_for_range( array $args = array() ): array {
		self::reconcile_ended_reservations();

		$db     = self::db();
		$tables = self::table_names();

		$date_from = isset( $args['date_from'] ) ? $this->normalize_date( (string) $args['date_from'] ) : '';
		$date_to   = isset( $args['date_to'] ) ? $this->normalize_date( (string) $args['date_to'] ) : '';
		if ( '' === $date_from || '' === $date_to ) {
			return array();
		}

		if ( $date_to < $date_from ) {
			$temporary = $date_from;
			$date_from = $date_to;
			$date_to   = $temporary;
		}

		$status   = isset( $args['status'] ) ? sanitize_key( (string) $args['status'] ) : '';
		$search   = isset( $args['search'] ) ? sanitize_text_field( (string) $args['search'] ) : '';
		$floor_id = absint( $args['floor_id'] ?? 0 );
		$limit    = min( 500, max( 1, absint( $args['limit'] ?? 300 ) ) );

		$where        = array( 'reservations.reservation_date BETWEEN %s AND %s' );
		$where_values = array( $date_from, $date_to );

		if ( '' !== $status && in_array( $status, self::get_status_keys(), true ) ) {
			$where[]        = 'reservations.status = %s';
			$where_values[] = $status;
		}

		if ( $floor_id > 0 ) {
			$where[]        = 'reservation_tables.floor_id = %d';
			$where_values[] = $floor_id;
		}

		if ( '' !== $search ) {
			$like           = '%' . $db->esc_like( $search ) . '%';
			$where[]        = '(reservations.reservation_code LIKE %s OR reservations.customer_name LIKE %s OR reservations.customer_email LIKE %s OR reservations.customer_phone LIKE %s)';
			$where_values[] = $like;
			$where_values[] = $like;
			$where_values[] = $like;
			$where_values[] = $like;
		}

		$where_sql = implode( ' AND ', $where );
		$items     = $db->get_results(
			$db->prepare(
				"SELECT reservations.*, reservation_tables.name AS table_name, reservation_floors.name AS floor_name
				FROM %i reservations
				LEFT JOIN %i reservation_tables ON reservation_tables.id = reservations.table_id
				LEFT JOIN %i reservation_floors ON reservation_floors.id = reservation_tables.floor_id
				WHERE {$where_sql}
				ORDER BY reservations.reservation_date ASC, reservations.reservation_time ASC, reservations.id ASC
				LIMIT %d",
				array_merge(
					array(
						$tables['reservations'],
						$tables['tables'],
						$tables['floors'],
					),
					$where_values,
					array( $limit )
				)
			)
		);

		return is_array( $items ) ? $items : array();
	}

	/**
	 * Get every reservation for one operational timeline day.
	 *
	 * @param array<string,mixed> $args Query arguments.
	 * @return array<int,object>
	 */
	public function get_reservations_for_timeline_day( array $args = array() ): array {
		self::reconcile_ended_reservations();

		$db     = self::db();
		$tables = self::table_names();

		$date = isset( $args['date'] ) ? $this->normalize_date( (string) $args['date'] ) : '';
		if ( '' === $date ) {
			return array();
		}

		$status          = isset( $args['status'] ) ? sanitize_key( (string) $args['status'] ) : '';
		$floor_id        = absint( $args['floor_id'] ?? 0 );
		$search          = isset( $args['search'] ) ? sanitize_text_field( (string) $args['search'] ) : '';
		$operational_day = ! empty( $args['operational_day'] );

		$parsed_date  = DateTimeImmutable::createFromFormat( '!Y-m-d', $date, wp_timezone() );
		$next_date    = $parsed_date instanceof DateTimeImmutable ? $parsed_date->modify( '+1 day' )->format( 'Y-m-d' ) : $date;
		$where        = array( $operational_day ? 'reservations.reservation_date IN (%s, %s)' : 'reservations.reservation_date = %s' );
		$where_values = $operational_day ? array( $date, $next_date ) : array( $date );
		if ( '' !== $status && in_array( $status, self::get_status_keys(), true ) ) {
			$where[]        = 'reservations.status = %s';
			$where_values[] = $status;
		}
		if ( $floor_id > 0 ) {
			$where[]        = 'reservation_tables.floor_id = %d';
			$where_values[] = $floor_id;
		}
		if ( '' !== $search ) {
			$like           = '%' . $db->esc_like( $search ) . '%';
			$where[]        = '(reservations.reservation_code LIKE %s OR reservations.customer_name LIKE %s OR reservations.customer_email LIKE %s OR reservations.customer_phone LIKE %s)';
			$where_values[] = $like;
			$where_values[] = $like;
			$where_values[] = $like;
			$where_values[] = $like;
		}

		$where_sql = implode( ' AND ', $where );
		$items     = $db->get_results(
			$db->prepare(
				"SELECT reservations.*, reservation_tables.name AS table_name, reservation_tables.floor_id AS table_floor_id, reservation_floors.name AS floor_name
				FROM %i reservations
				LEFT JOIN %i reservation_tables ON reservation_tables.id = reservations.table_id
				LEFT JOIN %i reservation_floors ON reservation_floors.id = reservation_tables.floor_id
				WHERE {$where_sql}
				ORDER BY reservations.reservation_date ASC, reservations.reservation_time ASC, reservations.id ASC",
				array_merge(
					array( $tables['reservations'], $tables['tables'], $tables['floors'] ),
					$where_values
				)
			)
		);

		if ( ! is_array( $items ) ) {
			return array();
		}
		if ( ! $operational_day ) {
			return $items;
		}

		$current_ranges  = $this->get_timeline_day_schedule( $date, true )['ranges'];
		$previous_date   = $parsed_date instanceof DateTimeImmutable ? $parsed_date->modify( '-1 day' )->format( 'Y-m-d' ) : $date;
		$previous_ranges = $this->get_timeline_day_schedule( $previous_date, true )['ranges'];

		return array_values(
			array_filter(
				$items,
				static function ( $item ) use ( $date, $next_date, $current_ranges, $previous_ranges ): bool {
					$item_date = sanitize_text_field( (string) ( $item->reservation_date ?? '' ) );
					$item_time = self::sanitize_slot_time( (string) ( $item->reservation_time ?? '' ) );
					if ( '' === $item_time ) {
						return false;
					}

					if ( $next_date === $item_date ) {
						return self::timeline_time_matches_ranges( $item_time, 1, $current_ranges );
					}

					if ( $date !== $item_date ) {
						return false;
					}

					if ( self::timeline_time_matches_ranges( $item_time, 0, $current_ranges ) ) {
						return true;
					}

					return ! self::timeline_time_matches_ranges( $item_time, 1, $previous_ranges );
				}
			)
		);
	}

	/**
	 * Get the configured booking schedule for a timeline date.
	 *
	 * @param string $date            Reservation date.
	 * @param bool   $operational_day Whether the response should span the complete operational day.
	 * @return array{closed:bool,ranges:array<int,array<string,int|string>>}
	 */
	public function get_timeline_day_schedule( string $date, bool $operational_day = false ): array {
		$date = $this->normalize_date( $date );
		if ( '' === $date ) {
			return array(
				'closed' => true,
				'ranges' => array(),
			);
		}

		$settings = self::get_booking_settings();
		foreach ( $settings['holiday_ranges'] as $range ) {
			if ( $date >= $range['from'] && $date <= $range['to'] ) {
				return array(
					'closed' => true,
					'ranges' => array(),
				);
			}
		}
		if ( ! $operational_day ) {
			$schedule = $this->get_booking_schedule_for_date( $date );

			return array(
				'closed' => empty( $schedule['ranges'] ),
				'ranges' => array_values( $schedule['ranges'] ),
			);
		}

		$parsed_date = DateTimeImmutable::createFromFormat( '!Y-m-d', $date, wp_timezone() );
		if ( ! $parsed_date instanceof DateTimeImmutable ) {
			return array(
				'closed' => true,
				'ranges' => array(),
			);
		}

		$weekday = (int) $parsed_date->format( 'w' );
		$day     = $settings['schedule'][ $weekday ] ?? array();
		$ranges  = array();
		if ( ! empty( $day['enabled'] ) ) {
			$next_date         = $parsed_date->modify( '+1 day' )->format( 'Y-m-d' );
			$next_date_blocked = self::date_is_inside_holiday_ranges( $next_date, $settings['holiday_ranges'] );
			foreach ( $day['ranges'] as $range ) {
				$start_minutes = self::time_to_minutes( (string) $range['from'] );
				$end_minutes   = self::time_to_minutes( (string) $range['to'] );
				$interval      = max( 1, absint( $range['interval'] ) );
				$is_overnight  = self::booking_range_is_overnight( $range );

				if ( $is_overnight && $next_date_blocked ) {
					$last_slot = $start_minutes;
					while ( $last_slot + $interval < 1440 ) {
						$last_slot += $interval;
					}
					$end_minutes  = $last_slot;
					$is_overnight = false;
				}

				$ranges[] = array(
					'from'             => (string) $range['from'],
					'to'               => sprintf( '%02d:%02d', (int) floor( $end_minutes / 60 ), $end_minutes % 60 ),
					'interval'         => $interval,
					'start_day_offset' => 0,
					'end_day_offset'   => $is_overnight ? 1 : 0,
				);
			}
		}

		return array(
			'closed' => empty( $ranges ),
			'ranges' => array_values( $ranges ),
		);
	}

	/**
	 * Check whether a timeline time belongs to one day offset in its ranges.
	 *
	 * @param string $time       Time in H:i format.
	 * @param int    $day_offset Calendar-day offset from the operational date.
	 * @param array  $ranges     Timeline ranges.
	 */
	private static function timeline_time_matches_ranges( string $time, int $day_offset, array $ranges ): bool {
		$time_minutes = self::time_to_minutes( $time );
		foreach ( $ranges as $range ) {
			$start_offset = absint( $range['start_day_offset'] ?? 0 );
			$end_offset   = absint( $range['end_day_offset'] ?? $start_offset );
			if ( $day_offset < $start_offset || $day_offset > $end_offset ) {
				continue;
			}

			$start = self::time_to_minutes( (string) $range['from'] );
			$end   = self::time_to_minutes( (string) $range['to'] );
			if ( $start_offset === $end_offset ) {
				if ( $time_minutes >= $start && $time_minutes <= $end ) {
					return true;
				}
				continue;
			}
			if ( $day_offset === $start_offset && $time_minutes >= $start ) {
				return true;
			}
			if ( $day_offset === $end_offset && $time_minutes <= $end ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Check whether a date is covered by a configured holiday range.
	 *
	 * @param string $date           Date in Y-m-d format.
	 * @param array  $holiday_ranges Holiday ranges.
	 */
	private static function date_is_inside_holiday_ranges( string $date, array $holiday_ranges ): bool {
		foreach ( $holiday_ranges as $range ) {
			if ( $date >= $range['from'] && $date <= $range['to'] ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Get exact daily reservation counts for a calendar date range.
	 *
	 * @param array<string,mixed> $args Query arguments.
	 * @return array<int,object>
	 */
	public function get_reservation_calendar_summary( array $args = array() ): array {
		self::reconcile_ended_reservations();

		$db     = self::db();
		$tables = self::table_names();

		$date_from = isset( $args['date_from'] ) ? $this->normalize_date( (string) $args['date_from'] ) : '';
		$date_to   = isset( $args['date_to'] ) ? $this->normalize_date( (string) $args['date_to'] ) : '';
		if ( '' === $date_from || '' === $date_to ) {
			return array();
		}

		if ( $date_to < $date_from ) {
			$temporary = $date_from;
			$date_from = $date_to;
			$date_to   = $temporary;
		}

		$status   = isset( $args['status'] ) ? sanitize_key( (string) $args['status'] ) : '';
		$search   = isset( $args['search'] ) ? sanitize_text_field( (string) $args['search'] ) : '';
		$floor_id = absint( $args['floor_id'] ?? 0 );

		$where        = array( 'reservations.reservation_date BETWEEN %s AND %s' );
		$where_values = array( $date_from, $date_to );

		if ( '' !== $status && in_array( $status, self::get_status_keys(), true ) ) {
			$where[]        = 'reservations.status = %s';
			$where_values[] = $status;
		}

		if ( $floor_id > 0 ) {
			$where[]        = 'reservation_tables.floor_id = %d';
			$where_values[] = $floor_id;
		}

		if ( '' !== $search ) {
			$like           = '%' . $db->esc_like( $search ) . '%';
			$where[]        = '(reservations.reservation_code LIKE %s OR reservations.customer_name LIKE %s OR reservations.customer_email LIKE %s OR reservations.customer_phone LIKE %s)';
			$where_values[] = $like;
			$where_values[] = $like;
			$where_values[] = $like;
			$where_values[] = $like;
		}

		$where_sql = implode( ' AND ', $where );
		$summary   = $db->get_results(
			$db->prepare(
				"SELECT reservations.reservation_date, reservations.status, COUNT(*) AS reservation_count
				FROM %i reservations
				LEFT JOIN %i reservation_tables ON reservation_tables.id = reservations.table_id
				WHERE {$where_sql}
				GROUP BY reservations.reservation_date, reservations.status
				ORDER BY reservations.reservation_date ASC, reservations.status ASC",
				array_merge(
					array( $tables['reservations'], $tables['tables'] ),
					$where_values
				)
			)
		);

		return is_array( $summary ) ? $summary : array();
	}

	/**
	 * Get one reservation.
	 *
	 * @param int $reservation_id Reservation ID.
	 * @return object|null
	 */
	public function get_reservation( int $reservation_id ) {
		self::reconcile_ended_reservations();

		$db             = self::db();
		$reservation_id = absint( $reservation_id );
		if ( $reservation_id <= 0 ) {
			return null;
		}

		$tables = self::table_names();

		return $db->get_row(
			$db->prepare(
				'SELECT reservations.*, reservation_tables.name AS table_name, reservation_floors.name AS floor_name
				FROM %i reservations
				LEFT JOIN %i reservation_tables ON reservation_tables.id = reservations.table_id
				LEFT JOIN %i reservation_floors ON reservation_floors.id = reservation_tables.floor_id
				WHERE reservations.id = %d',
				$tables['reservations'],
				$tables['tables'],
				$tables['floors'],
				$reservation_id
			)
		);
	}

	/**
	 * Update reservation status.
	 *
	 * @param int    $reservation_id Reservation ID.
	 * @param string $status         New status.
	 * @param string $reason         Optional cancellation reason.
	 * @return bool|WP_Error
	 */
	public function update_reservation_status( int $reservation_id, string $status, string $reason = '' ) {
		$db             = self::db();
		$reservation_id = absint( $reservation_id );
		if ( $reservation_id <= 0 ) {
			return new WP_Error( 'foodord_reservation_invalid_id', __( 'Invalid reservation.', 'foodord' ) );
		}

		$status              = self::sanitize_status( $status );
		$cancellation_reason = 'cancelled' === $status ? self::sanitize_cancellation_reason( $reason ) : '';
		$tables              = self::table_names();
		$reservation         = 'cancelled' === $status ? $this->get_reservation( $reservation_id ) : null;

		$updated = $db->update(
			$tables['reservations'],
			array(
				'status'              => $status,
				'cancellation_reason' => $cancellation_reason,
				'updated_at_gmt'      => current_time( 'mysql', true ),
			),
			array( 'id' => $reservation_id ),
			array( '%s', '%s', '%s' ),
			array( '%d' )
		);

		if ( false === $updated ) {
			return new WP_Error( 'foodord_reservation_status_update_failed', __( 'Unable to update the reservation.', 'foodord' ) );
		}

		if ( $reservation ) {
			$this->release_pending_quota_permit( $reservation );
		}

		return true;
	}

	/**
	 * Update a confirmed reservation duration without changing its assigned table.
	 *
	 * @param int $reservation_id Reservation ID.
	 * @param int $duration       New duration in minutes.
	 * @return object|WP_Error
	 */
	public function update_confirmed_reservation_duration( int $reservation_id, int $duration ) {
		$reservation_id = absint( $reservation_id );
		$duration       = self::sanitize_reservation_duration_minutes( $duration );
		$reservation    = $this->get_reservation( $reservation_id );

		if ( ! $reservation ) {
			return new WP_Error( 'foodord_reservation_invalid_id', __( 'Invalid reservation.', 'foodord' ), array( 'status' => 404 ) );
		}

		$date = $this->normalize_date( (string) $reservation->reservation_date );
		if ( '' === $date ) {
			return new WP_Error( 'foodord_reservation_invalid_slot', __( 'Invalid reservation date or time.', 'foodord' ), array( 'status' => 400 ) );
		}

		return $this->with_reservation_allocation_lock(
			$date,
			function () use ( $reservation_id, $duration ) {
				$reservation = $this->get_reservation( $reservation_id );
				if ( ! $reservation ) {
					return new WP_Error( 'foodord_reservation_invalid_id', __( 'Invalid reservation.', 'foodord' ), array( 'status' => 404 ) );
				}

				if ( 'confirmed' !== (string) $reservation->status ) {
					return new WP_Error(
						'foodord_reservation_duration_status_invalid',
						__( 'Only confirmed reservations can have their duration updated.', 'foodord' ),
						array( 'status' => 409 )
					);
				}

				$table_id = absint( $reservation->table_id ?? 0 );
				if ( $table_id <= 0 ) {
					return new WP_Error( 'foodord_reservation_invalid_table', __( 'Invalid table.', 'foodord' ), array( 'status' => 409 ) );
				}

				$current_duration = self::sanitize_reservation_duration_minutes( $reservation->duration_minutes ?? self::DEFAULT_DURATION_MINUTES );
				if ( $duration === $current_duration ) {
					return $reservation;
				}

				if ( $duration > $current_duration ) {
					$this->cleanup_expired_holds();
					$date = $this->normalize_date( (string) $reservation->reservation_date );
					$time = $this->normalize_time( (string) $reservation->reservation_time );
					if ( ! $this->reservation_duration_fits_booking_hours( $date, $time, $duration ) ) {
						return new WP_Error(
							'foodord_reservation_duration_outside_booking_hours',
							__( 'Reservation duration cannot extend beyond the configured booking hours.', 'foodord' ),
							array( 'status' => 409 )
						);
					}

					if (
						! $this->slot_can_be_reserved( $table_id, $date, $time, $duration, $reservation_id )
						|| $this->slot_has_active_hold_conflict( $table_id, $date, $time, $duration )
					) {
						return new WP_Error(
							'foodord_reservation_duration_conflict',
							__( 'This reservation cannot be extended because the table is required for another booking.', 'foodord' ),
							array( 'status' => 409 )
						);
					}

					$capacity_validation = $this->validate_capacity_limits(
						$date,
						$time,
						$duration,
						absint( $reservation->party_size ?? 0 ),
						$reservation_id
					);
					if ( is_wp_error( $capacity_validation ) ) {
						return $capacity_validation;
					}
				}

				$db      = self::db();
				$tables  = self::table_names();
				$updated = $db->update(
					$tables['reservations'],
					array(
						'duration_minutes' => $duration,
						'updated_at_gmt'   => current_time( 'mysql', true ),
					),
					array( 'id' => $reservation_id ),
					array( '%d', '%s' ),
					array( '%d' )
				);

				if ( false === $updated ) {
					return new WP_Error( 'foodord_reservation_duration_update_failed', __( 'Unable to update the reservation duration.', 'foodord' ), array( 'status' => 500 ) );
				}

				self::increment_sync_revision();

				return $this->get_reservation( $reservation_id );
			}
		);
	}

	/**
	 * Update a reservation guest count without changing its allocation.
	 *
	 * @param int $reservation_id Reservation ID.
	 * @param int $party_size     New guest count.
	 * @return object|WP_Error
	 */
	public function update_reservation_party_size( int $reservation_id, int $party_size ) {
		$reservation_id = absint( $reservation_id );
		$party_size     = absint( $party_size );

		if ( $party_size < 1 || $party_size > 65535 ) {
			return new WP_Error( 'foodord_reservation_invalid_party_size', __( 'Please enter the number of guests.', 'foodord' ), array( 'status' => 400 ) );
		}

		$reservation = $this->get_reservation( $reservation_id );
		if ( ! $reservation ) {
			return new WP_Error( 'foodord_reservation_invalid_id', __( 'Invalid reservation.', 'foodord' ), array( 'status' => 404 ) );
		}

		$status = self::sanitize_status( (string) ( $reservation->status ?? '' ) );
		if ( ! in_array( $status, array( 'pending', 'confirmed' ), true ) ) {
			return new WP_Error( 'foodord_reservation_update_unavailable', __( 'This reservation can no longer be changed.', 'foodord' ), array( 'status' => 409 ) );
		}

		if ( absint( $reservation->party_size ?? 0 ) === $party_size ) {
			return $reservation;
		}

		$db      = self::db();
		$tables  = self::table_names();
		$updated = $db->update(
			$tables['reservations'],
			array(
				'party_size'     => $party_size,
				'updated_at_gmt' => current_time( 'mysql', true ),
			),
			array( 'id' => $reservation_id ),
			array( '%d', '%s' ),
			array( '%d' )
		);

		if ( false === $updated ) {
			return new WP_Error( 'foodord_reservation_update_failed', __( 'Unable to update the reservation.', 'foodord' ), array( 'status' => 500 ) );
		}

		self::increment_sync_revision();

		return $this->get_reservation( $reservation_id );
	}

	/**
	 * Attach a WooCommerce payment order to a deposit reservation.
	 *
	 * @param int   $reservation_id Reservation ID.
	 * @param int   $order_id       WooCommerce order ID.
	 * @param float $amount         Deposit amount.
	 * @return bool|WP_Error
	 */
	public function attach_deposit_order( int $reservation_id, int $order_id, float $amount ) {
		if ( $amount <= 0 ) {
			return new WP_Error( 'foodord_reservation_invalid_deposit_order', __( 'Invalid reservation deposit order.', 'foodord' ) );
		}

		return $this->attach_payment_order( $reservation_id, $order_id, $amount );
	}

	/**
	 * Attach a WooCommerce payment order to a reservation.
	 *
	 * @param int   $reservation_id Reservation ID.
	 * @param int   $order_id       WooCommerce order ID.
	 * @param float $deposit_amount Optional deposit amount included in the order.
	 * @return bool|WP_Error
	 */
	public function attach_payment_order( int $reservation_id, int $order_id, float $deposit_amount = 0.0 ) {
		$db             = self::db();
		$reservation_id = absint( $reservation_id );
		$order_id       = absint( $order_id );
		$deposit_amount = self::sanitize_money_amount( $deposit_amount );

		if ( $reservation_id <= 0 || $order_id <= 0 ) {
			return new WP_Error( 'foodord_reservation_invalid_payment_order', __( 'Invalid reservation payment order.', 'foodord' ) );
		}

		$tables  = self::table_names();
		$updated = $db->update(
			$tables['reservations'],
			array(
				'order_id'       => $order_id,
				'deposit_amount' => $deposit_amount,
				'payment_status' => 'requires_payment',
				'updated_at_gmt' => current_time( 'mysql', true ),
			),
			array( 'id' => $reservation_id ),
			array( '%d', '%f', '%s', '%s' ),
			array( '%d' )
		);

		if ( false === $updated ) {
			return new WP_Error( 'foodord_reservation_payment_order_update_failed', __( 'Unable to attach the payment order.', 'foodord' ) );
		}

		return true;
	}

	/**
	 * Mark a deposit reservation as paid.
	 *
	 * @param int $reservation_id Reservation ID.
	 * @param int $paid_timestamp Payment completion timestamp.
	 * @return string|false|WP_Error New reservation status, or false when already processed.
	 */
	public function mark_deposit_paid( int $reservation_id, int $paid_timestamp = 0 ) {
		$reservation = $this->get_reservation( $reservation_id );
		if ( ! $reservation ) {
			return new WP_Error( 'foodord_reservation_invalid_id', __( 'Invalid reservation.', 'foodord' ) );
		}

		if ( $this->is_deposit_payment_expired( $reservation, $paid_timestamp ) ) {
			$this->expire_unpaid_deposit_reservations();
			return new WP_Error( 'foodord_reservation_deposit_expired', __( 'The reservation payment window has expired.', 'foodord' ) );
		}

		$quota_permit_token  = trim( (string) ( $reservation->quota_permit_token ?? '' ) );
		$quota_permit_status = sanitize_key( (string) ( $reservation->quota_permit_status ?? '' ) );
		if ( '' !== $quota_permit_token && 'pending' === $quota_permit_status ) {
			if ( ! class_exists( 'Foodord_Reservation_Quota' ) ) {
				return new WP_Error( 'foodord_reservation_quota_unavailable', __( 'Unable to finalize this reservation. Please try again.', 'foodord' ) );
			}

			$quota_committed = Foodord_Reservation_Quota::commit( $quota_permit_token, $reservation_id );
			if ( is_wp_error( $quota_committed ) ) {
				return $quota_committed;
			}
		}

		$status = self::is_auto_confirm_enabled() ? 'confirmed' : 'pending';
		$db     = self::db();
		$tables = self::table_names();
		$result = $db->update(
			$tables['reservations'],
			array(
				'status'              => $status,
				'payment_status'      => 'paid',
				'quota_permit_status' => '' !== $quota_permit_token ? 'committed' : $quota_permit_status,
				'updated_at_gmt'      => current_time( 'mysql', true ),
			),
			array(
				'id'             => absint( $reservation_id ),
				'status'         => 'paid_pending',
				'payment_status' => 'requires_payment',
			),
			array( '%s', '%s', '%s', '%s' ),
			array( '%d', '%s', '%s' )
		);

		if ( false === $result ) {
			return new WP_Error( 'foodord_reservation_deposit_paid_update_failed', __( 'Unable to update the reservation payment status.', 'foodord' ) );
		}

		if ( 0 === $result ) {
			$current = $this->get_reservation( $reservation_id );
			if ( $current && 'paid' === (string) $current->payment_status ) {
				return false;
			}

			return new WP_Error( 'foodord_reservation_payment_state_invalid', __( 'The reservation is no longer awaiting payment.', 'foodord' ) );
		}

		$this->reset_public_hard_hold_abuse( sanitize_text_field( (string) ( $reservation->hold_abuse_identity_hash ?? '' ) ) );

		return $status;
	}

	/**
	 * Mark an unpaid deposit reservation as failed or cancelled.
	 *
	 * @param int    $reservation_id  Reservation ID.
	 * @param string $payment_status Payment status.
	 * @return bool|WP_Error
	 */
	public function mark_deposit_payment_failed( int $reservation_id, string $payment_status = 'failed' ) {
		$reservation_id = absint( $reservation_id );
		$payment_status = sanitize_key( $payment_status );
		if ( $reservation_id <= 0 ) {
			return new WP_Error( 'foodord_reservation_invalid_id', __( 'Invalid reservation.', 'foodord' ) );
		}

		if ( '' === $payment_status ) {
			$payment_status = 'failed';
		}

		$reservation = $this->get_reservation( $reservation_id );
		$db          = self::db();
		$tables      = self::table_names();
		$result      = $db->update(
			$tables['reservations'],
			array(
				'status'         => 'cancelled',
				'payment_status' => $payment_status,
				'updated_at_gmt' => current_time( 'mysql', true ),
			),
			array(
				'id'     => $reservation_id,
				'status' => 'paid_pending',
			),
			array( '%s', '%s', '%s' ),
			array( '%d', '%s' )
		);

		if ( false === $result ) {
			return new WP_Error( 'foodord_reservation_deposit_failed_update_failed', __( 'Unable to update the reservation payment status.', 'foodord' ) );
		}

		if ( 1 === $result && $reservation ) {
			$this->release_pending_quota_permit( $reservation );
		}

		return true;
	}

	/**
	 * Expire unpaid deposit reservations after the hold window.
	 */
	public function expire_unpaid_deposit_reservations(): int {
		if ( ! $this->acquire_public_hold_cleanup_lock() ) {
			return 0;
		}

		$db      = self::db();
		$tables  = self::table_names();
		$cutoff  = gmdate( 'Y-m-d H:i:s', time() - self::HOLD_TTL_SECONDS );
		$expired = 0;

		try {
			$reservations = $db->get_results(
				$db->prepare(
					'SELECT id, hold_abuse_identity_hash, quota_permit_token, quota_permit_status FROM %i WHERE status = %s AND payment_status = %s AND created_at_gmt <= %s',
					$tables['reservations'],
					'paid_pending',
					'requires_payment',
					$cutoff
				)
			);

			foreach ( is_array( $reservations ) ? $reservations : array() as $reservation ) {
				$updated = $db->update(
					$tables['reservations'],
					array(
						'status'         => 'cancelled',
						'payment_status' => 'expired',
						'updated_at_gmt' => current_time( 'mysql', true ),
					),
					array(
						'id'             => absint( $reservation->id ),
						'status'         => 'paid_pending',
						'payment_status' => 'requires_payment',
					),
					array( '%s', '%s', '%s' ),
					array( '%d', '%s', '%s' )
				);

				if ( 1 !== $updated ) {
					continue;
				}

				++$expired;
				$this->release_pending_quota_permit( $reservation );
				$this->record_failed_public_hard_hold( sanitize_text_field( (string) $reservation->hold_abuse_identity_hash ) );
			}
			$this->cleanup_stale_public_hold_abuse();
		} finally {
			$this->release_public_hold_cleanup_lock();
		}

		return $expired;
	}

	/**
	 * Check whether an unpaid deposit reservation has expired.
	 *
	 * @param object $reservation    Reservation row.
	 * @param int    $reference_time Optional timestamp used for payment reconciliation.
	 */
	public function is_deposit_payment_expired( $reservation, int $reference_time = 0 ): bool {
		$status         = isset( $reservation->status ) ? (string) $reservation->status : '';
		$payment_status = isset( $reservation->payment_status ) ? (string) $reservation->payment_status : '';
		$created_at     = isset( $reservation->created_at_gmt ) ? (string) $reservation->created_at_gmt : '';

		if ( 'paid_pending' !== $status || 'requires_payment' !== $payment_status || '' === $created_at ) {
			return false;
		}

		$created_timestamp = strtotime( $created_at . ' UTC' );
		if ( false === $created_timestamp ) {
			return false;
		}

		$reference_time = $reference_time > 0 ? $reference_time : time();

		return $reference_time > $created_timestamp + self::HOLD_TTL_SECONDS;
	}

	/**
	 * Delete a reservation.
	 *
	 * @param int $reservation_id Reservation ID.
	 * @return bool|WP_Error
	 */
	public function delete_reservation( int $reservation_id ) {
		$db             = self::db();
		$reservation_id = absint( $reservation_id );
		if ( $reservation_id <= 0 ) {
			return new WP_Error( 'foodord_reservation_invalid_id', __( 'Invalid reservation.', 'foodord' ) );
		}

		$reservation = $this->get_reservation( $reservation_id );
		$tables      = self::table_names();
		$deleted     = $db->delete( $tables['reservations'], array( 'id' => $reservation_id ), array( '%d' ) );

		if ( false === $deleted ) {
			return new WP_Error( 'foodord_reservation_delete_failed', __( 'Unable to delete the reservation.', 'foodord' ) );
		}

		if ( $reservation ) {
			$this->release_pending_quota_permit( $reservation );
		}

		return true;
	}

	/**
	 * Release a central permit that was never committed.
	 *
	 * Committed permits represent consumed monthly quota and are never released
	 * when a reservation is later cancelled or deleted.
	 *
	 * @param object $reservation Reservation row containing quota permit data.
	 * @return bool
	 */
	private function release_pending_quota_permit( $reservation ): bool {
		$permit_token  = trim( (string) ( $reservation->quota_permit_token ?? '' ) );
		$permit_status = sanitize_key( (string) ( $reservation->quota_permit_status ?? '' ) );
		if ( '' === $permit_token || 'pending' !== $permit_status || ! class_exists( 'Foodord_Reservation_Quota' ) ) {
			return false;
		}

		if ( ! Foodord_Reservation_Quota::release( $permit_token ) ) {
			return false;
		}

		$reservation_id = absint( $reservation->id ?? 0 );
		if ( $reservation_id > 0 ) {
			$db     = self::db();
			$tables = self::table_names();
			$db->update(
				$tables['reservations'],
				array( 'quota_permit_status' => 'released' ),
				array(
					'id'                  => $reservation_id,
					'quota_permit_status' => 'pending',
				),
				array( '%s' ),
				array( '%d', '%s' )
			);
		}

		return true;
	}

	/**
	 * Create a new customer management token for a reservation.
	 *
	 * @param int $reservation_id Reservation ID.
	 */
	public function prepare_manage_token( int $reservation_id ): string {
		if ( ! self::is_customer_management_enabled() ) {
			return '';
		}

		$db             = self::db();
		$reservation_id = absint( $reservation_id );
		if ( $reservation_id <= 0 ) {
			return '';
		}

		$token  = self::generate_manage_token();
		$tables = self::table_names();
		$result = $db->update(
			$tables['reservations'],
			array(
				'manage_token_hash'           => self::hash_manage_token( $token ),
				'manage_token_created_at_gmt' => current_time( 'mysql', true ),
				'updated_at_gmt'              => current_time( 'mysql', true ),
			),
			array( 'id' => $reservation_id ),
			array( '%s', '%s', '%s' ),
			array( '%d' )
		);

		return false === $result ? '' : $token;
	}

	/**
	 * Build the public management URL for a reservation.
	 *
	 * @param object $reservation Reservation row.
	 * @param string $token       Raw management token.
	 */
	public static function get_manage_reservation_url( $reservation, string $token ): string {
		$reservation_id = isset( $reservation->id ) ? absint( $reservation->id ) : 0;
		$token          = sanitize_text_field( $token );

		if ( $reservation_id <= 0 || '' === $token ) {
			return '';
		}

		return add_query_arg(
			array(
				'foodord_reservation_manage' => $reservation_id,
				'foodord_reservation_token'  => rawurlencode( $token ),
			),
			self::get_reservations_page_url()
		);
	}

	/**
	 * Get the public reservations page URL.
	 */
	public static function get_public_reservations_page_url(): string {
		return self::get_reservations_page_url();
	}

	/**
	 * Get the public reservations page URL.
	 */
	private static function get_reservations_page_url(): string {
		$page_id = absint( get_option( 'foodord_onboarding_reservation_page_id', 0 ) );
		if ( $page_id > 0 ) {
			$page = get_post( $page_id );
			if ( $page instanceof WP_Post && 'publish' === $page->post_status ) {
				$permalink = get_permalink( $page_id );
				if ( is_string( $permalink ) && '' !== $permalink ) {
					return $permalink;
				}
			}
		}

		$db       = self::db();
		$found_id = (int) $db->get_var(
			$db->prepare(
				'SELECT ID FROM %i WHERE post_type = %s AND post_status = %s AND (post_content LIKE %s OR post_content LIKE %s) ORDER BY ID DESC LIMIT 1',
				$db->posts,
				'page',
				'publish',
				'%' . $db->esc_like( '[foodord_reservations' ) . '%',
				'%' . $db->esc_like( 'wp:foodord/reservations' ) . '%'
			)
		);

		if ( $found_id > 0 ) {
			$permalink = get_permalink( $found_id );
			if ( is_string( $permalink ) && '' !== $permalink ) {
				return $permalink;
			}
		}

		return home_url( '/' );
	}

	/**
	 * Get a reservation after validating its management token.
	 *
	 * @param int    $reservation_id Reservation ID.
	 * @param string $token          Raw management token.
	 * @return object|WP_Error
	 */
	public function get_reservation_by_manage_token( int $reservation_id, string $token ) {
		if ( ! self::is_customer_management_enabled() ) {
			return new WP_Error( 'foodord_reservation_management_disabled', __( 'Reservation management is not available.', 'foodord' ) );
		}

		$reservation_id = absint( $reservation_id );
		$token          = sanitize_text_field( $token );
		if ( $reservation_id <= 0 || '' === $token ) {
			return new WP_Error( 'foodord_reservation_invalid_manage_link', __( 'This reservation link is invalid.', 'foodord' ) );
		}

		$reservation = $this->get_reservation( $reservation_id );
		if ( ! $reservation || empty( $reservation->manage_token_hash ) ) {
			return new WP_Error( 'foodord_reservation_invalid_manage_link', __( 'This reservation link is invalid.', 'foodord' ) );
		}

		$expected_hash = (string) $reservation->manage_token_hash;
		if ( ! hash_equals( $expected_hash, self::hash_manage_token( $token ) ) ) {
			return new WP_Error( 'foodord_reservation_invalid_manage_link', __( 'This reservation link is invalid.', 'foodord' ) );
		}

		return $reservation;
	}

	/**
	 * Check whether a reservation can still be changed by the customer.
	 *
	 * @param object $reservation Reservation row.
	 */
	public function can_customer_manage_reservation( $reservation ): bool {
		if ( ! self::is_customer_management_enabled() || ! $reservation ) {
			return false;
		}

		$status = isset( $reservation->status ) ? self::sanitize_status( (string) $reservation->status ) : '';
		if ( ! in_array( $status, array( 'pending', 'confirmed' ), true ) ) {
			return false;
		}

		return ! $this->reservation_has_started( $reservation );
	}

	/**
	 * Check whether a reservation can still be cancelled by the customer.
	 *
	 * @param object $reservation Reservation row.
	 */
	public function can_customer_cancel_reservation( $reservation ): bool {
		if ( ! self::is_customer_management_enabled() || ! $reservation ) {
			return false;
		}

		if ( $this->is_paid_preorder_reservation( $reservation ) ) {
			return false;
		}

		$status = isset( $reservation->status ) ? self::sanitize_status( (string) $reservation->status ) : '';
		if ( ! in_array( $status, array( 'pending', 'confirmed' ), true ) ) {
			return false;
		}

		$starts_at = $this->get_reservation_start_datetime( $reservation );
		if ( ! $starts_at instanceof DateTimeImmutable ) {
			return false;
		}

		$cutoff_minutes = self::get_customer_cancellation_cutoff_minutes();
		$deadline       = $cutoff_minutes > 0 ? $starts_at->modify( '-' . $cutoff_minutes . ' minutes' ) : $starts_at;

		return new DateTimeImmutable( 'now', wp_timezone() ) < $deadline;
	}

	/**
	 * Check whether a reservation contains a paid food pre-order.
	 *
	 * @param object $reservation Reservation row.
	 */
	private function is_paid_preorder_reservation( $reservation ): bool {
		$payment_status = isset( $reservation->payment_status ) ? sanitize_key( (string) $reservation->payment_status ) : '';

		return 'paid' === $payment_status
			&& class_exists( 'Foodord_Table_Reservation_Deposits' )
			&& Foodord_Table_Reservation_Deposits::reservation_has_preorder( $reservation );
	}

	/**
	 * Update a customer-managed reservation.
	 *
	 * @param int                 $reservation_id Reservation ID.
	 * @param string              $token          Raw management token.
	 * @param array<string,mixed> $data           Reservation data.
	 * @return array{slot_changed:bool,email_changed:bool,old_email:string,manage_token:string,changed_fields:array<int,string>}|false|WP_Error Update result, false when unchanged.
	 */
	public function update_customer_managed_reservation( int $reservation_id, string $token, array $data ) {
		$reservation = $this->get_reservation_by_manage_token( $reservation_id, $token );
		if ( is_wp_error( $reservation ) ) {
			return $reservation;
		}

		if ( ! $this->can_customer_manage_reservation( $reservation ) ) {
			return new WP_Error( 'foodord_reservation_management_expired', __( 'This reservation cannot be changed right now. Please contact the restaurant.', 'foodord' ) );
		}

		$date       = $this->normalize_date( (string) ( $data['date'] ?? '' ) );
		$time       = $this->normalize_time( (string) ( $data['time'] ?? '' ) );
		$party_size = max( 1, absint( $data['party_size'] ?? 0 ) );
		$customer   = $this->sanitize_customer_payload( $data );
		if ( is_wp_error( $customer ) ) {
			return $customer;
		}

		$current_time   = isset( $reservation->reservation_time ) ? $this->normalize_time( (string) $reservation->reservation_time ) : '';
		$current_email  = sanitize_email( (string) ( $reservation->customer_email ?? '' ) );
		$field_changes  = array(
			'date'       => $date !== (string) $reservation->reservation_date,
			'time'       => $time !== $current_time,
			'party_size' => absint( $reservation->party_size ) !== $party_size,
			'name'       => $customer['name'] !== (string) $reservation->customer_name,
			'email'      => strtolower( $customer['email'] ) !== strtolower( $current_email ),
			'phone'      => $customer['phone'] !== (string) $reservation->customer_phone,
			'note'       => $customer['note'] !== (string) $reservation->customer_note,
		);
		$changed_fields = array_keys( array_filter( $field_changes ) );
		if ( empty( $changed_fields ) ) {
			return false;
		}

		$slot_changed         = $field_changes['date'] || $field_changes['time'] || $field_changes['party_size'];
		$party_size_increased = $field_changes['party_size'] && $party_size > absint( $reservation->party_size );
		$email_changed        = $field_changes['email'];

		if ( '' === $date || '' === $time ) {
			return new WP_Error( 'foodord_reservation_invalid_slot', __( 'Invalid reservation date or time.', 'foodord' ) );
		}

		$table_id = absint( $reservation->table_id ?? 0 );
		$duration = self::sanitize_reservation_duration_minutes( $reservation->duration_minutes ?? self::DEFAULT_DURATION_MINUTES );

		if ( $slot_changed ) {
			$slot_validation = $this->validate_booking_slot( $date, $time );
			if ( is_wp_error( $slot_validation ) ) {
				return $slot_validation;
			}

			$max_capacity = $this->get_max_active_table_capacity();
			if ( $max_capacity > 0 && $party_size > $max_capacity ) {
				return $party_size_increased
					? new WP_Error( 'foodord_reservation_management_unavailable', __( 'This reservation cannot be changed right now. Please contact the restaurant.', 'foodord' ) )
					: $this->get_party_size_capacity_error();
			}

			$available_tables = $this->get_available_tables_for_slot( $date, $time, $party_size, (int) $reservation->id );
			$table            = null;
			foreach ( $available_tables as $available_table ) {
				if ( absint( $available_table->id ?? 0 ) === $table_id ) {
					$table = $available_table;
					break;
				}
			}

			if ( ! $table && $party_size_increased ) {
				return new WP_Error( 'foodord_reservation_management_unavailable', __( 'This reservation cannot be changed right now. Please contact the restaurant.', 'foodord' ) );
			}

			if ( ! $table ) {
				$table = $available_tables[0] ?? null;
			}

			if ( ! $table ) {
				return new WP_Error( 'foodord_reservation_no_table_available', __( 'No table is available for the selected time. Please select a different time slot or contact us.', 'foodord' ) );
			}

			$table_id = absint( $table->id );
			$duration = self::sanitize_reservation_duration_minutes( $table->reservation_duration_minutes ?? self::DEFAULT_DURATION_MINUTES );
			if ( ! $this->slot_can_be_reserved( $table_id, $date, $time, $duration, (int) $reservation->id ) ) {
				return new WP_Error( 'foodord_reservation_slot_taken', __( 'This table is no longer available. Please select another time.', 'foodord' ) );
			}

			$capacity_validation = $this->validate_capacity_limits( $date, $time, $duration, $party_size, (int) $reservation->id );
			if ( is_wp_error( $capacity_validation ) ) {
				return $capacity_validation;
			}
		}

		$db            = self::db();
		$tables        = self::table_names();
		$status        = $slot_changed
			? ( self::is_auto_confirm_enabled() ? 'confirmed' : 'pending' )
			: self::sanitize_status( (string) $reservation->status );
		$manage_token  = $token;
		$update_data   = array(
			'table_id'         => $table_id,
			'status'           => $status,
			'customer_name'    => $customer['name'],
			'customer_email'   => $customer['email'],
			'customer_phone'   => $customer['phone'],
			'party_size'       => $party_size,
			'duration_minutes' => $duration,
			'reservation_date' => $date,
			'reservation_time' => $time,
			'customer_note'    => $customer['note'],
			'updated_at_gmt'   => current_time( 'mysql', true ),
		);
		$update_format = array( '%d', '%s', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%s' );

		if ( $email_changed ) {
			$manage_token                               = self::generate_manage_token();
			$update_data['manage_token_hash']           = self::hash_manage_token( $manage_token );
			$update_data['manage_token_created_at_gmt'] = current_time( 'mysql', true );
			$update_format[]                            = '%s';
			$update_format[]                            = '%s';
		}

		$result = $db->update(
			$tables['reservations'],
			$update_data,
			array( 'id' => (int) $reservation->id ),
			$update_format,
			array( '%d' )
		);

		if ( false === $result ) {
			return new WP_Error( 'foodord_reservation_update_failed', __( 'Unable to update the reservation.', 'foodord' ) );
		}

		return array(
			'slot_changed'   => $slot_changed,
			'email_changed'  => $email_changed,
			'old_email'      => $current_email,
			'manage_token'   => $manage_token,
			'changed_fields' => $changed_fields,
		);
	}

	/**
	 * Cancel a customer-managed reservation.
	 *
	 * @param int    $reservation_id Reservation ID.
	 * @param string $token          Raw management token.
	 * @return bool|WP_Error
	 */
	public function cancel_customer_managed_reservation( int $reservation_id, string $token ) {
		$reservation = $this->get_reservation_by_manage_token( $reservation_id, $token );
		if ( is_wp_error( $reservation ) ) {
			return $reservation;
		}

		if ( ! $this->can_customer_cancel_reservation( $reservation ) ) {
			return new WP_Error( 'foodord_reservation_cancellation_closed', __( 'This reservation can no longer be cancelled online. Please contact the restaurant.', 'foodord' ) );
		}

		return $this->update_reservation_status( (int) $reservation->id, 'cancelled' );
	}

	/**
	 * Check whether a customer reservation can currently be allocated.
	 *
	 * This method never creates a hold. Final allocation must still be repeated
	 * while the reservation allocation lock is held.
	 *
	 * @param string $date       Reservation date in Y-m-d format.
	 * @param string $time       Reservation time in H:i or H:i:s format.
	 * @param int    $party_size Party size.
	 * @param int    $table_id   Optional customer-selected table ID.
	 * @return array<string,mixed>|WP_Error
	 */
	public function check_customer_availability( string $date, string $time, int $party_size, int $table_id = 0 ) {
		$date       = $this->normalize_date( $date );
		$time       = $this->normalize_time( $time );
		$party_size = max( 1, absint( $party_size ) );
		$table_id   = absint( $table_id );

		if ( '' === $date || '' === $time ) {
			return new WP_Error( 'foodord_reservation_invalid_slot', __( 'Invalid reservation date or time.', 'foodord' ) );
		}

		$slot_validation = $this->validate_booking_slot( $date, $time );
		if ( is_wp_error( $slot_validation ) ) {
			return $slot_validation;
		}

		return $this->with_reservation_allocation_lock(
			$date,
			function () use ( $date, $time, $party_size, $table_id ) {
				$this->cleanup_expired_holds();
				$this->expire_unpaid_deposit_reservations();

				$table = $this->resolve_customer_table_for_slot( $date, $time, $party_size, $table_id );
				if ( is_wp_error( $table ) ) {
					return $table;
				}

				$duration = self::sanitize_reservation_duration_minutes( $table->reservation_duration_minutes ?? self::DEFAULT_DURATION_MINUTES );

				return array(
					'hold_token'     => '',
					'table_id'       => (int) $table->id,
					'table_name'     => (string) $table->name,
					'floor_name'     => (string) ( $table->floor_name ?? '' ),
					'capacity'       => (int) $table->capacity,
					'duration'       => $duration,
					'expires_at_gmt' => '',
					'ttl_seconds'    => 0,
				);
			}
		);
	}

	/**
	 * Resolve one available customer table for a slot.
	 *
	 * @param string $date       Reservation date.
	 * @param string $time       Reservation time.
	 * @param int    $party_size Party size.
	 * @param int    $table_id   Optional exact table ID.
	 * @return object|WP_Error
	 */
	private function resolve_customer_table_for_slot( string $date, string $time, int $party_size, int $table_id = 0 ) {
		$max_capacity = $this->get_max_active_table_capacity();
		if ( $max_capacity > 0 && $party_size > $max_capacity ) {
			return $this->get_party_size_capacity_error();
		}

		$table = null;
		if ( $table_id > 0 ) {
			foreach ( $this->get_available_tables_for_slot( $date, $time, $party_size ) as $available_table ) {
				if ( $table_id === (int) $available_table->id ) {
					$table = $available_table;
					break;
				}
			}

			if ( ! $table ) {
				return new WP_Error(
					'foodord_reservation_selected_table_unavailable',
					__( 'This table is no longer available. Please select another table.', 'foodord' ),
					array( 'status' => 409 )
				);
			}
		} else {
			$table = $this->find_available_table( $date, $time, $party_size );
			if ( ! $table ) {
				return new WP_Error(
					'foodord_reservation_no_table_available',
					__( 'No table is available for the selected time. Please select a different time slot or contact us.', 'foodord' ),
					array( 'status' => 409 )
				);
			}
		}

		$duration            = self::sanitize_reservation_duration_minutes( $table->reservation_duration_minutes ?? self::DEFAULT_DURATION_MINUTES );
		$capacity_validation = $this->validate_capacity_limits( $date, $time, $duration, $party_size );
		if ( is_wp_error( $capacity_validation ) ) {
			return $capacity_validation;
		}

		return $table;
	}

	/**
	 * Prepare abuse-protection context for a public hard hold.
	 *
	 * @param string $customer_session Customer session key.
	 * @param string $date             Reservation date.
	 * @param string $time             Reservation time.
	 * @param int    $party_size       Party size.
	 * @param int    $table_id         Optional exact table ID.
	 * @param bool   $enabled          Whether protection applies.
	 * @return array<string,mixed>|WP_Error
	 */
	private function prepare_public_hard_hold_context( string $customer_session, string $date, string $time, int $party_size, int $table_id, bool $enabled ) {
		if ( ! $enabled ) {
			return array( 'identity_hash' => '' );
		}

		$customer_session = substr( sanitize_text_field( $customer_session ), 0, 64 );
		if ( '' === $customer_session ) {
			return $this->get_public_hold_rate_limit_error();
		}

		$identity_hash = $this->get_public_hold_identity_hash( $customer_session );
		if ( '' === $identity_hash ) {
			return $this->get_public_hold_rate_limit_error();
		}

		$session_hold = $this->get_active_public_hard_hold( 'customer_session', $customer_session );
		if ( $session_hold && $this->public_hold_matches_request( $session_hold, $date, $time, $party_size, $table_id ) ) {
			$existing = $this->format_existing_hold_result( $session_hold );
			if ( ! is_wp_error( $existing ) ) {
				return array(
					'identity_hash' => $identity_hash,
					'existing'      => $existing,
				);
			}
		}

		if ( $this->is_public_hold_identity_blocked( $identity_hash ) ) {
			return $this->get_public_hold_rate_limit_error();
		}

		if ( $session_hold ) {
			$this->release_hold( (string) $session_hold->hold_token );
		}

		$identity_hold = $this->get_active_public_hard_hold( 'abuse_identity_hash', $identity_hash );
		if ( $identity_hold ) {
			return $this->get_public_hold_rate_limit_error();
		}

		return array( 'identity_hash' => $identity_hash );
	}

	/**
	 * Create a non-reversible hash for public hold abuse tracking.
	 *
	 * @param string $customer_session Customer session key.
	 */
	private function get_public_hold_identity_hash( string $customer_session ): string {
		$ip_address = self::get_request_ip_address();
		if ( '' === $ip_address && '' === $customer_session ) {
			return '';
		}

		$identity = '' !== $ip_address ? 'ip|' . $ip_address : 'session|' . $customer_session;

		return hash_hmac( 'sha256', $identity, wp_salt( 'auth' ) );
	}

	/**
	 * Get one active public hard hold by an allow-listed identity field.
	 *
	 * @param string $field Field name.
	 * @param string $value Field value.
	 * @return object|null
	 */
	private function get_active_public_hard_hold( string $field, string $value ) {
		if ( ! in_array( $field, array( 'customer_session', 'abuse_identity_hash' ), true ) || '' === $value ) {
			return null;
		}

		$db     = self::db();
		$tables = self::table_names();

		return $db->get_row(
			$db->prepare(
				'SELECT * FROM %i WHERE %i = %s AND is_public_hard_hold = 1 AND expires_at_gmt > %s ORDER BY id DESC LIMIT 1',
				$tables['holds'],
				$field,
				$value,
				current_time( 'mysql', true )
			)
		);
	}

	/**
	 * Check whether an active hold represents the same idempotent request.
	 *
	 * @param object $hold       Hold row.
	 * @param string $date       Reservation date.
	 * @param string $time       Reservation time.
	 * @param int    $party_size Party size.
	 * @param int    $table_id   Optional exact table ID.
	 */
	private function public_hold_matches_request( $hold, string $date, string $time, int $party_size, int $table_id ): bool {
		return $date === (string) $hold->reservation_date
			&& $this->normalize_time( $time ) === $this->normalize_time( (string) $hold->reservation_time )
			&& $party_size === (int) $hold->party_size
			&& ( $table_id <= 0 || $table_id === (int) $hold->table_id );
	}

	/**
	 * Format an existing hold like a newly created hold.
	 *
	 * @param object $hold Hold row.
	 * @return array<string,mixed>|WP_Error
	 */
	private function format_existing_hold_result( $hold ) {
		$table = $this->get_table( (int) $hold->table_id );
		if ( ! $table ) {
			return new WP_Error( 'foodord_reservation_invalid_table', __( 'Invalid table.', 'foodord' ) );
		}

		$expires_at = (string) $hold->expires_at_gmt;
		$expires    = strtotime( $expires_at . ' UTC' );
		$ttl        = false === $expires ? 0 : max( 1, $expires - time() );

		return array(
			'hold_id'        => (int) $hold->id,
			'hold_token'     => (string) $hold->hold_token,
			'table_id'       => (int) $hold->table_id,
			'table_name'     => (string) $table->name,
			'floor_name'     => (string) ( $table->floor_name ?? '' ),
			'capacity'       => (int) $table->capacity,
			'duration'       => (int) $hold->duration_minutes,
			'expires_at_gmt' => $expires_at,
			'ttl_seconds'    => $ttl,
		);
	}

	/**
	 * Check active cooldown and rapid-start limits for an identity.
	 *
	 * @param string $identity_hash Hashed identity.
	 */
	private function is_public_hold_identity_blocked( string $identity_hash ): bool {
		$db     = self::db();
		$tables = self::table_names();
		$row    = $db->get_row(
			$db->prepare(
				'SELECT * FROM %i WHERE identity_hash = %s',
				$tables['hold_abuse'],
				$identity_hash
			)
		);

		if ( ! $row ) {
			return false;
		}

		$now = time();
		if ( ! empty( $row->blocked_until_gmt ) ) {
			$blocked_until = strtotime( (string) $row->blocked_until_gmt . ' UTC' );
			if ( false !== $blocked_until && $blocked_until > $now ) {
				return true;
			}
		}

		if ( (int) $row->hold_start_count < self::HOLD_RATE_MAX_STARTS || empty( $row->window_started_at_gmt ) ) {
			return false;
		}

		$window_started = strtotime( (string) $row->window_started_at_gmt . ' UTC' );

		return false !== $window_started && ( $window_started + self::HOLD_RATE_WINDOW_SECONDS ) > $now;
	}

	/**
	 * Record one newly created public hard hold.
	 *
	 * @param string $identity_hash Hashed identity.
	 */
	private function record_public_hard_hold_started( string $identity_hash ): void {
		$db     = self::db();
		$tables = self::table_names();
		$now    = current_time( 'mysql', true );
		$row    = $db->get_row(
			$db->prepare(
				'SELECT * FROM %i WHERE identity_hash = %s',
				$tables['hold_abuse'],
				$identity_hash
			)
		);

		$window_started = $row && ! empty( $row->window_started_at_gmt ) ? strtotime( (string) $row->window_started_at_gmt . ' UTC' ) : false;
		$within_window  = false !== $window_started && ( $window_started + self::HOLD_RATE_WINDOW_SECONDS ) > time();
		$values         = array(
			'hold_start_count'      => $within_window ? (int) $row->hold_start_count + 1 : 1,
			'window_started_at_gmt' => $within_window ? (string) $row->window_started_at_gmt : $now,
			'updated_at_gmt'        => $now,
		);

		if ( $row ) {
			$db->update( $tables['hold_abuse'], $values, array( 'identity_hash' => $identity_hash ), array( '%d', '%s', '%s' ), array( '%s' ) );
			return;
		}

		$db->insert(
			$tables['hold_abuse'],
			array_merge(
				array(
					'identity_hash' => $identity_hash,
					'strike_count'  => 0,
				),
				$values
			),
			array( '%s', '%d', '%d', '%s', '%s' )
		);
	}

	/**
	 * Return a generic public hold throttling error.
	 */
	private function get_public_hold_rate_limit_error(): WP_Error {
		return new WP_Error(
			'foodord_reservation_hold_rate_limited',
			__( 'A new reservation attempt cannot be started right now. Please try again later.', 'foodord' ),
			array( 'status' => 429 )
		);
	}

	/**
	 * Record one public hard hold that expired without conversion.
	 *
	 * @param string $identity_hash Hashed identity.
	 */
	private function record_failed_public_hard_hold( string $identity_hash ): void {
		if ( '' === $identity_hash ) {
			return;
		}

		$db     = self::db();
		$tables = self::table_names();
		$now    = current_time( 'mysql', true );
		$row    = $db->get_row(
			$db->prepare(
				'SELECT * FROM %i WHERE identity_hash = %s',
				$tables['hold_abuse'],
				$identity_hash
			)
		);

		$last_failure = $row && ! empty( $row->last_failure_at_gmt ) ? strtotime( (string) $row->last_failure_at_gmt . ' UTC' ) : false;
		$strike_count = false !== $last_failure && ( $last_failure + self::HOLD_STRIKE_RESET_SECONDS ) > time()
			? min( 4, (int) $row->strike_count + 1 )
			: 1;
		$cooldown     = self::HOLD_COOLDOWN_SECONDS[ $strike_count ] ?? 0;
		$values       = array(
			'strike_count'        => $strike_count,
			'blocked_until_gmt'   => $cooldown > 0 ? gmdate( 'Y-m-d H:i:s', time() + $cooldown ) : null,
			'last_failure_at_gmt' => $now,
			'updated_at_gmt'      => $now,
		);

		if ( $row ) {
			$db->update( $tables['hold_abuse'], $values, array( 'identity_hash' => $identity_hash ), array( '%d', '%s', '%s', '%s' ), array( '%s' ) );
			return;
		}

		$db->insert(
			$tables['hold_abuse'],
			array_merge(
				array(
					'identity_hash'         => $identity_hash,
					'hold_start_count'      => 0,
					'window_started_at_gmt' => null,
				),
				$values
			),
			array( '%s', '%d', '%s', '%d', '%s', '%s', '%s' )
		);
	}

	/**
	 * Reset hard-hold abuse state after a completed reservation payment.
	 *
	 * @param string $identity_hash Hashed identity.
	 */
	private function reset_public_hard_hold_abuse( string $identity_hash ): void {
		if ( '' === $identity_hash ) {
			return;
		}

		$db     = self::db();
		$tables = self::table_names();
		$db->delete( $tables['hold_abuse'], array( 'identity_hash' => $identity_hash ), array( '%s' ) );
	}

	/**
	 * Acquire the global hard-hold cleanup lock.
	 */
	private function acquire_public_hold_cleanup_lock(): bool {
		$db        = self::db();
		$lock_name = 'foodord_hold_cleanup_' . get_current_blog_id();

		return 1 === (int) $db->get_var( $db->prepare( 'SELECT GET_LOCK(%s, %d)', $lock_name, 0 ) );
	}

	/**
	 * Release the global hard-hold cleanup lock.
	 */
	private function release_public_hold_cleanup_lock(): void {
		$db        = self::db();
		$lock_name = 'foodord_hold_cleanup_' . get_current_blog_id();
		$db->get_var( $db->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
	}

	/**
	 * Remove stale hold abuse counters after their enforcement window.
	 */
	private function cleanup_stale_public_hold_abuse(): void {
		$db     = self::db();
		$tables = self::table_names();
		$cutoff = gmdate( 'Y-m-d H:i:s', time() - ( 2 * DAY_IN_SECONDS ) );

		$db->query(
			$db->prepare(
				'DELETE FROM %i WHERE updated_at_gmt < %s AND (blocked_until_gmt IS NULL OR blocked_until_gmt <= %s)',
				$tables['hold_abuse'],
				$cutoff,
				current_time( 'mysql', true )
			)
		);
	}

	/**
	 * Create a temporary hold for the best available table.
	 *
	 * @param string $date             Reservation date in Y-m-d format.
	 * @param string $time             Reservation time in H:i or H:i:s format.
	 * @param int    $party_size       Party size.
	 * @param string $customer_session       Optional customer/session key.
	 * @param bool   $ignore_capacity_limits Whether concurrent capacity limits should be bypassed.
	 * @param bool   $allow_over_capacity     Whether staff may exceed individual table capacity.
	 * @param int    $duration_minutes        Optional staff-selected duration in minutes.
	 * @param bool   $public_hard_hold        Whether public hard-hold abuse protection applies.
	 * @param bool   $ignore_booking_limits   Whether maximum advance and late booking limits should be bypassed.
	 * @return array<string,mixed>|WP_Error
	 */
	public function create_hold( string $date, string $time, int $party_size, string $customer_session = '', bool $ignore_capacity_limits = false, bool $allow_over_capacity = false, int $duration_minutes = 0, bool $public_hard_hold = false, bool $ignore_booking_limits = false ) {
		$public_hard_hold = $public_hard_hold && ! current_user_can( 'manage_options' );
		if ( ! $public_hard_hold ) {
			return $this->create_hold_internal( $date, $time, $party_size, $customer_session, $ignore_capacity_limits, $allow_over_capacity, $duration_minutes, false, $ignore_booking_limits );
		}

		$identity_hash = $this->get_public_hold_identity_hash( substr( sanitize_text_field( $customer_session ), 0, 64 ) );
		if ( '' === $identity_hash ) {
			return $this->get_public_hold_rate_limit_error();
		}

		return $this->with_public_hold_identity_lock(
			$identity_hash,
			function () use ( $date, $time, $party_size, $customer_session, $ignore_capacity_limits, $allow_over_capacity, $duration_minutes, $ignore_booking_limits ) {
				return $this->create_hold_internal( $date, $time, $party_size, $customer_session, $ignore_capacity_limits, $allow_over_capacity, $duration_minutes, true, $ignore_booking_limits );
			}
		);
	}

	/**
	 * Create the best-table hold after any public identity lock is acquired.
	 *
	 * @param string $date                   Reservation date.
	 * @param string $time                   Reservation time.
	 * @param int    $party_size             Party size.
	 * @param string $customer_session       Customer/session key.
	 * @param bool   $ignore_capacity_limits Whether capacity limits are ignored.
	 * @param bool   $allow_over_capacity     Whether table capacity may be exceeded.
	 * @param int    $duration_minutes        Optional duration.
	 * @param bool   $public_hard_hold        Whether public hard-hold protection applies.
	 * @param bool   $ignore_booking_limits   Whether maximum advance and late booking limits should be bypassed.
	 * @return array<string,mixed>|WP_Error
	 */
	private function create_hold_internal( string $date, string $time, int $party_size, string $customer_session, bool $ignore_capacity_limits, bool $allow_over_capacity, int $duration_minutes, bool $public_hard_hold, bool $ignore_booking_limits ) {
		$date             = $this->normalize_date( $date );
		$time             = $this->normalize_time( $time );
		$party_size       = max( 1, absint( $party_size ) );
		$duration_minutes = self::sanitize_optional_reservation_duration_minutes( $duration_minutes );

		if ( '' === $date || '' === $time ) {
			return new WP_Error( 'foodord_reservation_invalid_slot', __( 'Invalid reservation date or time.', 'foodord' ) );
		}

		$slot_validation = $this->validate_booking_slot( $date, $time, $ignore_booking_limits );
		if ( is_wp_error( $slot_validation ) ) {
			return $slot_validation;
		}

		return $this->with_reservation_allocation_lock(
			$date,
			function () use ( $date, $time, $party_size, $customer_session, $ignore_capacity_limits, $allow_over_capacity, $duration_minutes, $public_hard_hold ) {
				$this->cleanup_expired_holds();
				$this->expire_unpaid_deposit_reservations();

				$hold_context = $this->prepare_public_hard_hold_context( $customer_session, $date, $time, $party_size, 0, $public_hard_hold );
				if ( is_wp_error( $hold_context ) ) {
					return $hold_context;
				}
				if ( isset( $hold_context['existing'] ) && is_array( $hold_context['existing'] ) ) {
					return $hold_context['existing'];
				}

				if ( ! $allow_over_capacity ) {
					$max_capacity = $this->get_max_active_table_capacity();
					if ( $max_capacity > 0 && $party_size > $max_capacity ) {
						return $this->get_party_size_capacity_error();
					}
				}

				$table = $allow_over_capacity
					? $this->find_available_table_for_staff( $date, $time, $party_size, 0, $duration_minutes )
					: $this->find_available_table( $date, $time, $party_size, 0, $duration_minutes );
				if ( ! $table ) {
					return new WP_Error( 'foodord_reservation_no_table_available', __( 'No table is available for the selected time. Please select a different time slot or contact us.', 'foodord' ) );
				}

				$duration = $duration_minutes > 0
					? $duration_minutes
					: self::sanitize_reservation_duration_minutes( $hold->duration_minutes ?? self::DEFAULT_DURATION_MINUTES );
				if ( $duration_minutes > 0 && ! $this->reservation_duration_fits_booking_hours( $date, $time, $duration ) ) {
					return new WP_Error(
						'foodord_reservation_duration_outside_booking_hours',
						__( 'Reservation duration cannot extend beyond the configured booking hours.', 'foodord' ),
						array( 'status' => 409 )
					);
				}
				if ( ! $ignore_capacity_limits ) {
					$capacity_validation = $this->validate_capacity_limits( $date, $time, $duration, $party_size );
					if ( is_wp_error( $capacity_validation ) ) {
						return $capacity_validation;
					}
				}

				return $this->insert_hold_for_table(
					$table,
					$date,
					$time,
					$party_size,
					$duration,
					$customer_session,
					self::HOLD_TTL_SECONDS,
					$public_hard_hold,
					(string) ( $hold_context['identity_hash'] ?? '' )
				);
			}
		);
	}

	/**
	 * Create a temporary hold for a customer-selected table.
	 *
	 * @param string $date             Reservation date in Y-m-d format.
	 * @param string $time             Reservation time in H:i or H:i:s format.
	 * @param int    $party_size       Party size.
	 * @param int    $table_id         Selected table ID.
	 * @param string $customer_session Optional customer/session key.
	 * @param bool   $public_hard_hold Whether public hard-hold abuse protection applies.
	 * @return array<string,mixed>|WP_Error
	 */
	public function create_hold_for_table( string $date, string $time, int $party_size, int $table_id, string $customer_session = '', bool $public_hard_hold = false ) {
		$public_hard_hold = $public_hard_hold && ! current_user_can( 'manage_options' );
		if ( ! $public_hard_hold ) {
			return $this->create_hold_for_table_with_ttl( $date, $time, $party_size, $table_id, $customer_session, self::HOLD_TTL_SECONDS, false, false, false, false );
		}

		$identity_hash = $this->get_public_hold_identity_hash( substr( sanitize_text_field( $customer_session ), 0, 64 ) );
		if ( '' === $identity_hash ) {
			return $this->get_public_hold_rate_limit_error();
		}

		return $this->with_public_hold_identity_lock(
			$identity_hash,
			function () use ( $date, $time, $party_size, $table_id, $customer_session ) {
				return $this->create_hold_for_table_with_ttl( $date, $time, $party_size, $table_id, $customer_session, self::HOLD_TTL_SECONDS, false, false, true, false );
			}
		);
	}

	/**
	 * Create a short staff lease for one exact table and time.
	 *
	 * @param string $date       Reservation date in Y-m-d format.
	 * @param string $time       Reservation time in H:i or H:i:s format.
	 * @param int    $party_size Initial party size.
	 * @param int    $table_id   Selected table ID.
	 * @param bool   $ignore_capacity_limits Whether concurrent limits may be bypassed.
	 * @param bool   $ignore_booking_limits  Whether maximum advance and late booking limits should be bypassed.
	 * @param bool   $allow_over_capacity    Whether the selected table capacity may be exceeded.
	 * @return array<string,mixed>|WP_Error
	 */
	public function create_staff_hold_for_table( string $date, string $time, int $party_size, int $table_id, bool $ignore_capacity_limits = false, bool $ignore_booking_limits = false, bool $allow_over_capacity = true ) {
		$customer_session = sprintf( 'staff-%d-%s', get_current_user_id(), wp_generate_uuid4() );

		return $this->create_hold_for_table_with_ttl( $date, $time, $party_size, $table_id, $customer_session, self::STAFF_HOLD_TTL_SECONDS, $ignore_capacity_limits, $allow_over_capacity, false, $ignore_booking_limits, true );
	}

	/**
	 * Create a temporary hold for a specific table with a controlled lifetime.
	 *
	 * @param string $date             Reservation date in Y-m-d format.
	 * @param string $time             Reservation time in H:i or H:i:s format.
	 * @param int    $party_size       Party size.
	 * @param int    $table_id         Selected table ID.
	 * @param string $customer_session Customer/session key.
	 * @param int    $ttl_seconds      Hold lifetime in seconds.
	 * @param bool   $ignore_capacity_limits Whether concurrent limits may be bypassed.
	 * @param bool   $allow_over_capacity     Whether staff may exceed individual table capacity.
	 * @param bool   $public_hard_hold        Whether public hard-hold abuse protection applies.
	 * @param bool   $ignore_booking_limits   Whether maximum advance and late booking limits should be bypassed.
	 * @param bool   $flexible_staff_duration Whether the hold may use a shorter staff-selected duration.
	 * @return array<string,mixed>|WP_Error
	 */
	private function create_hold_for_table_with_ttl( string $date, string $time, int $party_size, int $table_id, string $customer_session, int $ttl_seconds, bool $ignore_capacity_limits, bool $allow_over_capacity, bool $public_hard_hold, bool $ignore_booking_limits, bool $flexible_staff_duration = false ) {
		$date        = $this->normalize_date( $date );
		$time        = $this->normalize_time( $time );
		$party_size  = max( 1, absint( $party_size ) );
		$table_id    = absint( $table_id );
		$ttl_seconds = max( self::STAFF_HOLD_TTL_SECONDS, min( self::HOLD_TTL_SECONDS, absint( $ttl_seconds ) ) );

		if ( '' === $date || '' === $time || $table_id <= 0 ) {
			return new WP_Error( 'foodord_reservation_invalid_slot', __( 'Invalid reservation date or time.', 'foodord' ) );
		}

		$slot_validation = $this->validate_booking_slot( $date, $time, $ignore_booking_limits );
		if ( is_wp_error( $slot_validation ) ) {
			return $slot_validation;
		}

		return $this->with_reservation_allocation_lock(
			$date,
			function () use ( $date, $time, $party_size, $table_id, $customer_session, $ttl_seconds, $ignore_capacity_limits, $allow_over_capacity, $public_hard_hold, $flexible_staff_duration ) {
				$this->cleanup_expired_holds();
				$this->expire_unpaid_deposit_reservations();

				$hold_context = $this->prepare_public_hard_hold_context( $customer_session, $date, $time, $party_size, $table_id, $public_hard_hold );
				if ( is_wp_error( $hold_context ) ) {
					return $hold_context;
				}
				if ( isset( $hold_context['existing'] ) && is_array( $hold_context['existing'] ) ) {
					return $hold_context['existing'];
				}

				if ( ! $allow_over_capacity ) {
					$max_capacity = $this->get_max_active_table_capacity();
					if ( $max_capacity > 0 && $party_size > $max_capacity ) {
						return $this->get_party_size_capacity_error();
					}
				}

				$availability_party_size = $allow_over_capacity ? 1 : $party_size;
				$availability_duration   = $flexible_staff_duration ? self::MIN_DURATION_MINUTES : 0;
				$available_tables        = $this->get_available_tables_for_slot( $date, $time, $availability_party_size, 0, $availability_duration );
				$table                   = null;
				foreach ( $available_tables as $available_table ) {
					if ( $table_id === (int) $available_table->id ) {
						$table = $available_table;
						break;
					}
				}

				if ( ! $table ) {
					return new WP_Error(
						'foodord_reservation_selected_table_unavailable',
						__( 'This table is no longer available. Please select another table.', 'foodord' ),
						array( 'status' => 409 )
					);
				}

				$configured_duration = self::sanitize_reservation_duration_minutes( $table->reservation_duration_minutes ?? self::DEFAULT_DURATION_MINUTES );
				$max_duration        = $configured_duration;
				if ( $flexible_staff_duration ) {
					$max_duration = $this->get_max_reservable_staff_duration( $table_id, $date, $time );
					if ( $max_duration < self::MIN_DURATION_MINUTES ) {
						return new WP_Error(
							'foodord_reservation_selected_table_unavailable',
							__( 'This table is no longer available. Please select another table.', 'foodord' ),
							array( 'status' => 409 )
						);
					}
				}
				$duration = min( $configured_duration, $max_duration );
				if ( ! $ignore_capacity_limits ) {
					$capacity_validation = $this->validate_capacity_limits( $date, $time, $duration, $party_size );
					if ( is_wp_error( $capacity_validation ) ) {
						return $capacity_validation;
					}
				}

				$result = $this->insert_hold_for_table(
					$table,
					$date,
					$time,
					$party_size,
					$duration,
					$customer_session,
					$ttl_seconds,
					$public_hard_hold,
					(string) ( $hold_context['identity_hash'] ?? '' )
				);
				if ( is_array( $result ) && $flexible_staff_duration ) {
					$result['max_duration'] = $max_duration;
				}

				return $result;
			}
		);
	}

	/**
	 * Insert a temporary hold for a validated reservation table.
	 *
	 * @param object $table            Available table.
	 * @param string $date             Reservation date.
	 * @param string $time             Reservation time.
	 * @param int    $party_size       Party size.
	 * @param int    $duration         Reservation duration in minutes.
	 * @param string $customer_session Optional customer/session key.
	 * @param int    $ttl_seconds      Hold lifetime in seconds.
	 * @param bool   $public_hard_hold Whether public hard-hold abuse protection applies.
	 * @param string $identity_hash    Hashed public hold identity.
	 * @return array<string,mixed>|WP_Error
	 */
	private function insert_hold_for_table( $table, string $date, string $time, int $party_size, int $duration, string $customer_session, int $ttl_seconds, bool $public_hard_hold = false, string $identity_hash = '' ) {
		$db         = self::db();
		$tables     = self::table_names();
		$now        = current_time( 'mysql', true );
		$expires_at = gmdate( 'Y-m-d H:i:s', time() + $ttl_seconds );
		$token      = wp_generate_password( 64, false, false );

		$inserted = $db->insert(
			$tables['holds'],
			array(
				'hold_token'          => $token,
				'table_id'            => (int) $table->id,
				'reservation_date'    => $date,
				'reservation_time'    => $time,
				'duration_minutes'    => $duration,
				'party_size'          => $party_size,
				'customer_session'    => sanitize_text_field( $customer_session ),
				'abuse_identity_hash' => $public_hard_hold ? sanitize_text_field( $identity_hash ) : '',
				'is_public_hard_hold' => $public_hard_hold ? 1 : 0,
				'expires_at_gmt'      => $expires_at,
				'created_at_gmt'      => $now,
			),
			array( '%s', '%d', '%s', '%s', '%d', '%d', '%s', '%s', '%d', '%s', '%s' )
		);

		if ( false === $inserted ) {
			return new WP_Error( 'foodord_reservation_hold_failed', __( 'Unable to hold this table.', 'foodord' ) );
		}

		if ( $public_hard_hold && '' !== $identity_hash ) {
			$this->record_public_hard_hold_started( $identity_hash );
		}

		return array(
			'hold_id'        => (int) $db->insert_id,
			'hold_token'     => $token,
			'table_id'       => (int) $table->id,
			'table_name'     => (string) $table->name,
			'floor_name'     => (string) ( $table->floor_name ?? '' ),
			'capacity'       => (int) $table->capacity,
			'duration'       => $duration,
			'expires_at_gmt' => $expires_at,
			'ttl_seconds'    => $ttl_seconds,
		);
	}

	/**
	 * Refresh an active staff lease.
	 *
	 * @param string $hold_token Hold token.
	 * @return object|WP_Error
	 */
	public function refresh_staff_hold( string $hold_token ) {
		$hold_token = sanitize_text_field( $hold_token );
		$hold       = $this->get_active_hold( $hold_token );
		if ( ! $this->is_staff_hold( $hold ) ) {
			return new WP_Error( 'foodord_reservation_hold_expired', __( 'Your table hold has expired. Please select a time again.', 'foodord' ), array( 'status' => 409 ) );
		}

		$db         = self::db();
		$tables     = self::table_names();
		$now        = current_time( 'mysql', true );
		$expires_at = gmdate( 'Y-m-d H:i:s', time() + self::STAFF_HOLD_TTL_SECONDS );
		$updated    = $db->query(
			$db->prepare(
				'UPDATE %i SET expires_at_gmt = %s WHERE hold_token = %s AND customer_session = %s AND expires_at_gmt > %s',
				$tables['holds'],
				$expires_at,
				$hold_token,
				(string) $hold->customer_session,
				$now
			)
		);

		if ( false === $updated ) {
			return new WP_Error( 'foodord_reservation_hold_expired', __( 'Your table hold has expired. Please select a time again.', 'foodord' ), array( 'status' => 409 ) );
		}

		$hold = $this->get_active_hold( $hold_token );
		if ( ! $this->is_staff_hold( $hold ) ) {
			return new WP_Error( 'foodord_reservation_hold_expired', __( 'Your table hold has expired. Please select a time again.', 'foodord' ), array( 'status' => 409 ) );
		}

		$hold->ttl_seconds = self::STAFF_HOLD_TTL_SECONDS;

		return $hold;
	}

	/**
	 * Release a staff lease without touching customer holds.
	 *
	 * @param string $hold_token Hold token.
	 * @return bool
	 */
	public function release_staff_hold( string $hold_token ): bool {
		$db         = self::db();
		$tables     = self::table_names();
		$hold_token = sanitize_text_field( $hold_token );
		if ( '' === $hold_token ) {
			return true;
		}

		$deleted = $db->query(
			$db->prepare(
				'DELETE FROM %i WHERE hold_token = %s AND customer_session LIKE %s',
				$tables['holds'],
				$hold_token,
				'staff-%'
			)
		);

		return false !== $deleted;
	}

	/**
	 * Validate and update a staff lease before it becomes a reservation.
	 *
	 * @param string $hold_token            Hold token.
	 * @param string $date                  Expected reservation date.
	 * @param string $time                  Expected reservation time.
	 * @param int    $party_size            Final party size.
	 * @param bool   $ignore_capacity_limits Whether concurrent limits may be bypassed.
	 * @param int    $duration_minutes       Optional staff-selected duration in minutes.
	 * @return object|WP_Error
	 */
	public function prepare_staff_hold_for_reservation( string $hold_token, string $date, string $time, int $party_size, bool $ignore_capacity_limits = false, int $duration_minutes = 0 ) {
		$hold_token       = sanitize_text_field( $hold_token );
		$date             = $this->normalize_date( $date );
		$time             = $this->normalize_time( $time );
		$party_size       = max( 1, absint( $party_size ) );
		$duration_minutes = self::sanitize_optional_reservation_duration_minutes( $duration_minutes );
		$hold             = $this->get_active_hold( $hold_token );
		if ( ! $this->is_staff_hold( $hold ) ) {
			return new WP_Error( 'foodord_reservation_hold_expired', __( 'Your table hold has expired. Please select a time again.', 'foodord' ), array( 'status' => 409 ) );
		}

		if ( $date !== (string) $hold->reservation_date || $time !== $this->normalize_time( (string) $hold->reservation_time ) ) {
			return new WP_Error( 'foodord_reservation_invalid_slot', __( 'Invalid reservation date or time.', 'foodord' ), array( 'status' => 409 ) );
		}

		return $this->with_reservation_allocation_lock(
			$date,
			function () use ( $hold_token, $party_size, $ignore_capacity_limits, $duration_minutes ) {
				$hold = $this->get_active_hold( $hold_token );
				if ( ! $this->is_staff_hold( $hold ) ) {
					return new WP_Error( 'foodord_reservation_hold_expired', __( 'Your table hold has expired. Please select a time again.', 'foodord' ), array( 'status' => 409 ) );
				}

				$table = $this->get_table( (int) $hold->table_id );
				if ( ! $table || 1 !== (int) $table->is_active ) {
					return $this->get_party_size_capacity_error();
				}

				$duration = $duration_minutes > 0
					? $duration_minutes
					: self::sanitize_reservation_duration_minutes( $table->reservation_duration_minutes ?? self::DEFAULT_DURATION_MINUTES );
				if ( ! $this->slot_can_be_reserved( (int) $hold->table_id, (string) $hold->reservation_date, (string) $hold->reservation_time, $duration ) ) {
					return new WP_Error( 'foodord_reservation_slot_taken', __( 'This table is no longer available. Please select another time.', 'foodord' ), array( 'status' => 409 ) );
				}
				if ( $this->slot_has_active_hold_conflict( (int) $hold->table_id, (string) $hold->reservation_date, (string) $hold->reservation_time, $duration, $hold_token ) ) {
					return new WP_Error( 'foodord_reservation_slot_taken', __( 'This table is no longer available. Please select another time.', 'foodord' ), array( 'status' => 409 ) );
				}

				if ( ! $ignore_capacity_limits ) {
					$capacity_validation = $this->validate_capacity_limits(
						(string) $hold->reservation_date,
						(string) $hold->reservation_time,
						$duration,
						$party_size,
						0,
						$hold_token
					);
					if ( is_wp_error( $capacity_validation ) ) {
						return $capacity_validation;
					}
				}

				$db         = self::db();
				$tables     = self::table_names();
				$now        = current_time( 'mysql', true );
				$expires_at = gmdate( 'Y-m-d H:i:s', time() + self::STAFF_HOLD_TTL_SECONDS );
				$updated    = $db->query(
					$db->prepare(
						'UPDATE %i SET party_size = %d, duration_minutes = %d, expires_at_gmt = %s WHERE hold_token = %s AND expires_at_gmt > %s',
						$tables['holds'],
						$party_size,
						$duration,
						$expires_at,
						$hold_token,
						$now
					)
				);

				if ( false === $updated ) {
					return new WP_Error( 'foodord_reservation_hold_expired', __( 'Your table hold has expired. Please select a time again.', 'foodord' ), array( 'status' => 409 ) );
				}

				$hold = $this->get_active_hold( $hold_token );
				if ( ! $this->is_staff_hold( $hold ) ) {
					return new WP_Error( 'foodord_reservation_hold_expired', __( 'Your table hold has expired. Please select a time again.', 'foodord' ), array( 'status' => 409 ) );
				}

				return $hold;
			}
		);
	}

	/**
	 * Run table allocation under a date-scoped database lock.
	 *
	 * @param string   $date     Reservation date.
	 * @param callable $callback Allocation callback.
	 * @return mixed|WP_Error
	 */
	private function with_reservation_allocation_lock( string $date, callable $callback ) {
		$db        = self::db();
		$lock_name = 'foodord_reservation_' . md5( get_current_blog_id() . '|' . $date );
		$acquired  = (int) $db->get_var(
			$db->prepare(
				'SELECT GET_LOCK(%s, %d)',
				$lock_name,
				self::ALLOCATION_LOCK_TIMEOUT
			)
		);

		if ( 1 !== $acquired ) {
			return new WP_Error( 'foodord_reservation_allocation_busy', __( 'Unable to hold this table.', 'foodord' ), array( 'status' => 409 ) );
		}

		try {
			return $callback();
		} finally {
			$db->get_var( $db->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
		}
	}

	/**
	 * Serialize public hard-hold changes for one hashed identity.
	 *
	 * @param string   $identity_hash Hashed public identity.
	 * @param callable $callback      Hold operation.
	 * @return mixed|WP_Error
	 */
	private function with_public_hold_identity_lock( string $identity_hash, callable $callback ) {
		$db        = self::db();
		$lock_name = 'foodord_hold_identity_' . substr( $identity_hash, 0, 32 );
		$acquired  = (int) $db->get_var(
			$db->prepare(
				'SELECT GET_LOCK(%s, %d)',
				$lock_name,
				self::ALLOCATION_LOCK_TIMEOUT
			)
		);

		if ( 1 !== $acquired ) {
			return $this->get_public_hold_rate_limit_error();
		}

		try {
			return $callback();
		} finally {
			$db->get_var( $db->prepare( 'SELECT RELEASE_LOCK(%s)', $lock_name ) );
		}
	}

	/**
	 * Check whether a hold belongs to a staff workflow.
	 *
	 * @param object|null $hold Hold row.
	 */
	private function is_staff_hold( $hold ): bool {
		return is_object( $hold ) && 0 === strpos( (string) ( $hold->customer_session ?? '' ), 'staff-' );
	}

	/**
	 * Release a hold.
	 *
	 * @param string $hold_token Hold token.
	 * @return bool
	 */
	public function release_hold( string $hold_token ): bool {
		$hold_token = sanitize_text_field( $hold_token );
		if ( '' === $hold_token ) {
			return false;
		}

		$db      = self::db();
		$tables  = self::table_names();
		$deleted = $db->delete( $tables['holds'], array( 'hold_token' => $hold_token ), array( '%s' ) );

		if ( false !== $deleted && class_exists( 'Foodord_Table_Reservation_Preorders' ) ) {
			Foodord_Table_Reservation_Preorders::delete_draft_by_hold_token( $hold_token );
		}

		return false !== $deleted;
	}

	/**
	 * Set a new expiry for an active hold owned by a customer session.
	 *
	 * @param string $hold_token       Hold token.
	 * @param string $customer_session Customer session key.
	 * @param int    $ttl_seconds      New lifetime measured from now.
	 * @return object|WP_Error
	 */
	public function extend_hold_expiration( string $hold_token, string $customer_session, int $ttl_seconds ) {
		$hold_token       = sanitize_text_field( $hold_token );
		$customer_session = substr( sanitize_text_field( $customer_session ), 0, 64 );
		$ttl_seconds      = min( HOUR_IN_SECONDS, max( self::HOLD_TTL_SECONDS, absint( $ttl_seconds ) ) );
		$hold             = $this->get_active_hold( $hold_token );

		if (
			! is_object( $hold )
			|| '' === $customer_session
			|| '' === (string) ( $hold->customer_session ?? '' )
			|| ! hash_equals( (string) $hold->customer_session, $customer_session )
		) {
			return new WP_Error( 'foodord_reservation_hold_expired', __( 'Your table hold has expired. Please select a time again.', 'foodord' ) );
		}

		$db         = self::db();
		$tables     = self::table_names();
		$now        = current_time( 'mysql', true );
		$expires_at = gmdate( 'Y-m-d H:i:s', time() + $ttl_seconds );
		$updated    = $db->query(
			$db->prepare(
				'UPDATE %i SET expires_at_gmt = %s WHERE hold_token = %s AND customer_session = %s AND expires_at_gmt > %s',
				$tables['holds'],
				$expires_at,
				$hold_token,
				$customer_session,
				$now
			)
		);

		if ( 1 !== $updated ) {
			return new WP_Error( 'foodord_reservation_hold_expired', __( 'Your table hold has expired. Please select a time again.', 'foodord' ) );
		}

		$hold->expires_at_gmt = $expires_at;

		return $hold;
	}

	/**
	 * Create a standard reservation without a temporary customer hold.
	 *
	 * Availability is resolved again while the allocation lock is held, so two
	 * concurrent submissions cannot claim the same table.
	 *
	 * @param string              $date       Reservation date.
	 * @param string              $time       Reservation time.
	 * @param int                 $party_size Party size.
	 * @param int                 $table_id   Optional customer-selected table ID.
	 * @param array<string,mixed> $data       Customer data.
	 * @return array<string,mixed>|WP_Error
	 */
	public function create_reservation_without_hold( string $date, string $time, int $party_size, int $table_id, array $data ) {
		$date       = $this->normalize_date( $date );
		$time       = $this->normalize_time( $time );
		$party_size = max( 1, absint( $party_size ) );
		$table_id   = absint( $table_id );

		if ( '' === $date || '' === $time ) {
			return new WP_Error( 'foodord_reservation_invalid_slot', __( 'Invalid reservation date or time.', 'foodord' ) );
		}

		$slot_validation = $this->validate_booking_slot( $date, $time );
		if ( is_wp_error( $slot_validation ) ) {
			return $slot_validation;
		}

		$customer = $this->sanitize_customer_payload( $data );
		if ( is_wp_error( $customer ) ) {
			return $customer;
		}

		$result = $this->with_reservation_allocation_lock(
			$date,
			function () use ( $date, $time, $party_size, $table_id, $data, $customer ) {
				$this->cleanup_expired_holds();
				$this->expire_unpaid_deposit_reservations();
				if ( self::calculate_deposit_amount( $date, $time, $party_size ) > 0 || ! empty( $data['requires_payment'] ) ) {
					return new WP_Error(
						'foodord_reservation_hard_hold_required',
						__( 'Please check availability again.', 'foodord' ),
						array( 'status' => 409 )
					);
				}

				$table = $this->resolve_customer_table_for_slot( $date, $time, $party_size, $table_id );
				if ( is_wp_error( $table ) ) {
					return $table;
				}

				$allocation                      = clone $table;
				$allocation->reservation_date    = $date;
				$allocation->reservation_time    = $time;
				$allocation->party_size          = $party_size;
				$allocation->duration_minutes    = self::sanitize_reservation_duration_minutes( $table->reservation_duration_minutes ?? self::DEFAULT_DURATION_MINUTES );
				$allocation->customer_session    = '';
				$allocation->abuse_identity_hash = '';
				$allocation->is_public_hard_hold = 0;

				return $this->insert_reservation_for_allocation_locked( $allocation, $data, '', $customer );
			}
		);

		if ( ! is_wp_error( $result ) ) {
			$this->reset_public_hard_hold_abuse( $this->get_public_hold_identity_hash( '' ) );
		}

		return $result;
	}

	/**
	 * Convert a valid hold into a pending reservation.
	 *
	 * @param string              $hold_token Hold token.
	 * @param array<string,mixed> $data       Customer data.
	 * @return array<string,mixed>|WP_Error
	 */
	public function create_reservation_from_hold( string $hold_token, array $data ) {
		$hold_token = sanitize_text_field( $hold_token );
		$hold       = $this->get_active_hold( $hold_token );
		if ( ! $hold ) {
			return new WP_Error( 'foodord_reservation_hold_expired', __( 'Your table hold has expired. Please select a time again.', 'foodord' ) );
		}

		return $this->with_reservation_allocation_lock(
			(string) $hold->reservation_date,
			function () use ( $hold_token, $data ) {
				return $this->create_reservation_from_hold_locked( $hold_token, $data );
			}
		);
	}

	/**
	 * Convert a hold while its reservation date allocation lock is active.
	 *
	 * @param string              $hold_token Hold token.
	 * @param array<string,mixed> $data       Customer data.
	 * @return array<string,mixed>|WP_Error
	 */
	private function create_reservation_from_hold_locked( string $hold_token, array $data ) {
		$db         = self::db();
		$hold_token = sanitize_text_field( $hold_token );
		if ( '' === $hold_token ) {
			return new WP_Error( 'foodord_reservation_hold_missing', __( 'Your table hold has expired. Please select a time again.', 'foodord' ) );
		}

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

		$slot_validation = $this->validate_booking_slot( (string) $hold->reservation_date, (string) $hold->reservation_time );
		if ( is_wp_error( $slot_validation ) ) {
			$this->release_hold( $hold_token );
			return $slot_validation;
		}

		$customer = $this->sanitize_customer_payload( $data );
		if ( is_wp_error( $customer ) ) {
			return $customer;
		}

		$this->cleanup_expired_holds();

		$duration = self::sanitize_reservation_duration_minutes( $hold->duration_minutes ?? self::DEFAULT_DURATION_MINUTES );

		if ( ! $this->slot_can_be_reserved( (int) $hold->table_id, (string) $hold->reservation_date, (string) $hold->reservation_time, $duration ) ) {
			$this->release_hold( $hold_token );
			return new WP_Error( 'foodord_reservation_slot_taken', __( 'This table is no longer available. Please select another time.', 'foodord' ) );
		}

		$ignore_capacity_limits = ! empty( $data['ignore_capacity_limits'] )
			&& ( $this->is_staff_hold( $hold ) || self::can_current_user_override_capacity_limits() );
		if ( ! $ignore_capacity_limits ) {
			$capacity_validation = $this->validate_capacity_limits(
				(string) $hold->reservation_date,
				(string) $hold->reservation_time,
				$duration,
				(int) $hold->party_size,
				0,
				$hold_token
			);
			if ( is_wp_error( $capacity_validation ) ) {
				$this->release_hold( $hold_token );
				return $capacity_validation;
			}
		}

		$tables       = self::table_names();
		$claimed_hold = $db->delete(
			$tables['holds'],
			array(
				'id'         => absint( $hold->id ),
				'hold_token' => $hold_token,
			),
			array( '%d', '%s' )
		);

		if ( 1 !== $claimed_hold ) {
			return new WP_Error( 'foodord_reservation_hold_expired', __( 'Your table hold has expired. Please select a time again.', 'foodord' ) );
		}

		return $this->insert_reservation_for_allocation_locked( $hold, $data, $hold_token, $customer );
	}

	/**
	 * Insert a reservation for an allocation that was resolved under the lock.
	 *
	 * @param object                    $allocation Allocation data.
	 * @param array<string,mixed>       $data       Customer data.
	 * @param string                    $hold_token Optional consumed hold token.
	 * @param array<string,string>|null $customer Sanitized customer data.
	 * @return array<string,mixed>|WP_Error
	 */
	private function insert_reservation_for_allocation_locked( $allocation, array $data, string $hold_token = '', $customer = null ) {
		if ( ! is_array( $customer ) ) {
			$customer = $this->sanitize_customer_payload( $data );
			if ( is_wp_error( $customer ) ) {
				return $customer;
			}
		}

		if ( ! class_exists( 'Foodord_Reservation_Quota' ) ) {
			return new WP_Error( 'foodord_reservation_quota_unavailable', __( 'Online reservations are temporarily unavailable. Please try again later.', 'foodord' ) );
		}

		$quota_operation_id = hash( 'sha256', wp_generate_uuid4() . '|' . microtime( true ) . '|' . wp_rand() );
		$quota_permit       = Foodord_Reservation_Quota::authorize( $quota_operation_id );
		if ( is_wp_error( $quota_permit ) ) {
			return $quota_permit;
		}
		$quota_permit_token = sanitize_text_field( (string) ( $quota_permit['permit_token'] ?? '' ) );
		if ( '' === $quota_permit_token ) {
			return new WP_Error( 'foodord_reservation_quota_invalid', __( 'Online reservations are temporarily unavailable. Please try again later.', 'foodord' ) );
		}

		$db               = self::db();
		$tables           = self::table_names();
		$now              = current_time( 'mysql', true );
		$table_id         = isset( $allocation->table_id ) ? absint( $allocation->table_id ) : absint( $allocation->id ?? 0 );
		$identity_hash    = sanitize_text_field( (string) ( $allocation->abuse_identity_hash ?? '' ) );
		$duration         = self::sanitize_reservation_duration_minutes( $allocation->duration_minutes ?? self::DEFAULT_DURATION_MINUTES );
		$reservation_code = $this->generate_reservation_code();
		$apply_deposit    = ! array_key_exists( 'apply_deposit', $data ) || ! empty( $data['apply_deposit'] );
		$deposit_amount   = $apply_deposit ? self::calculate_deposit_amount( (string) $allocation->reservation_date, (string) $allocation->reservation_time, (int) $allocation->party_size ) : 0.0;
		$requires_deposit = $deposit_amount > 0;
		$requires_payment = $requires_deposit || ! empty( $data['requires_payment'] );
		$status           = $requires_payment ? 'paid_pending' : ( self::is_auto_confirm_enabled() ? 'confirmed' : 'pending' );
		$payment_status   = $requires_payment ? 'requires_payment' : 'none';
		$manage_token     = self::is_customer_management_enabled() ? self::generate_manage_token() : '';
		$privacy_settings = self::get_privacy_settings();
		$privacy_consent  = $privacy_settings['enabled'] && ! empty( $data['privacy_consent'] );
		$consent_ip       = $privacy_consent && $privacy_settings['store_ip'] ? self::get_request_ip_address() : '';

		$inserted = $db->insert(
			$tables['reservations'],
			array(
				'reservation_code'            => $reservation_code,
				'table_id'                    => $table_id,
				'hold_token'                  => $hold_token,
				'hold_abuse_identity_hash'    => $identity_hash,
				'order_id'                    => 0,
				'quota_permit_token'          => $quota_permit_token,
				'quota_permit_status'         => 'pending',
				'status'                      => $status,
				'payment_status'              => $payment_status,
				'deposit_amount'              => $deposit_amount,
				'customer_name'               => $customer['name'],
				'customer_email'              => $customer['email'],
				'customer_phone'              => $customer['phone'],
				'party_size'                  => (int) $allocation->party_size,
				'duration_minutes'            => $duration,
				'reservation_date'            => (string) $allocation->reservation_date,
				'reservation_time'            => (string) $allocation->reservation_time,
				'customer_note'               => $customer['note'],
				'privacy_consent'             => $privacy_consent ? 1 : 0,
				'privacy_consent_at_gmt'      => $privacy_consent ? $now : null,
				'privacy_consent_ip'          => $consent_ip,
				'manage_token_hash'           => '' !== $manage_token ? self::hash_manage_token( $manage_token ) : '',
				'manage_token_created_at_gmt' => '' !== $manage_token ? $now : null,
				'created_at_gmt'              => $now,
				'updated_at_gmt'              => $now,
			),
			array( '%s', '%d', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%f', '%s', '%s', '%s', '%d', '%d', '%s', '%s', '%s', '%d', '%s', '%s', '%s', '%s', '%s', '%s' )
		);

		if ( false === $inserted ) {
			Foodord_Reservation_Quota::release( $quota_permit_token );
			if ( '' !== $hold_token ) {
				$this->release_hold( $hold_token );
			}
			return new WP_Error( 'foodord_reservation_insert_failed', __( 'Unable to save your reservation. Please try again.', 'foodord' ) );
		}

		$reservation_id = (int) $db->insert_id;
		if ( ! $requires_payment ) {
			$quota_committed = Foodord_Reservation_Quota::commit( $quota_permit_token, $reservation_id );
			if ( is_wp_error( $quota_committed ) ) {
				$db->delete( $tables['reservations'], array( 'id' => $reservation_id ), array( '%d' ) );
				Foodord_Reservation_Quota::release( $quota_permit_token );
				if ( '' !== $hold_token ) {
					$this->release_hold( $hold_token );
				}
				return $quota_committed;
			}

			$db->update(
				$tables['reservations'],
				array( 'quota_permit_status' => 'committed' ),
				array( 'id' => $reservation_id ),
				array( '%s' ),
				array( '%d' )
			);
		}

		if ( '' !== $hold_token && empty( $data['preserve_preorder_draft'] ) ) {
			$this->release_hold( $hold_token );
		}
		if ( ! $requires_payment ) {
			$this->reset_public_hard_hold_abuse( $identity_hash );
		}

		return array(
			'reservation_id'   => $reservation_id,
			'reservation_code' => $reservation_code,
			'status'           => $status,
			'table_id'         => $table_id,
			'party_size'       => (int) $allocation->party_size,
			'duration'         => $duration,
			'date'             => (string) $allocation->reservation_date,
			'time'             => (string) $allocation->reservation_time,
			'deposit_required' => $requires_deposit,
			'deposit_amount'   => $deposit_amount,
			'payment_required' => $requires_payment,
			'manage_token'     => $manage_token,
		);
	}

	/**
	 * Remove expired holds.
	 *
	 * @return int
	 */
	public function cleanup_expired_holds(): int {
		if ( ! $this->acquire_public_hold_cleanup_lock() ) {
			return 0;
		}

		$db      = self::db();
		$tables  = self::table_names();
		$now     = current_time( 'mysql', true );
		$deleted = 0;

		try {
			$expired_hard_holds = $db->get_results(
				$db->prepare(
					'SELECT abuse_identity_hash FROM %i WHERE is_public_hard_hold = 1 AND abuse_identity_hash <> %s AND expires_at_gmt <= %s',
					$tables['holds'],
					'',
					$now
				)
			);

			foreach ( is_array( $expired_hard_holds ) ? $expired_hard_holds : array() as $expired_hold ) {
				$this->record_failed_public_hard_hold( (string) $expired_hold->abuse_identity_hash );
			}

			$deleted = (int) $db->query(
				$db->prepare(
					'DELETE FROM %i WHERE expires_at_gmt <= %s',
					$tables['holds'],
					$now
				)
			);
			$this->cleanup_stale_public_hold_abuse();
		} finally {
			$this->release_public_hold_cleanup_lock();
		}

		if ( class_exists( 'Foodord_Table_Reservation_Preorders' ) ) {
			$preorders = new Foodord_Table_Reservation_Preorders();
			$preorders->cleanup_expired_drafts();
		}

		return $deleted;
	}

	/**
	 * Mark confirmed reservations as completed after their reserved duration.
	 *
	 * @return int Number of updated reservations.
	 */
	public static function complete_ended_reservations(): int {
		if ( ! self::acquire_completion_lock() ) {
			return 0;
		}

		$db        = self::db();
		$tables    = self::table_names();
		$now       = current_datetime();
		$local_now = $now->format( 'Y-m-d H:i:s' );
		$today     = $now->format( 'Y-m-d' );

		$updated = $db->query(
			$db->prepare(
				'UPDATE %i
				SET status = %s, cancellation_reason = %s, updated_at_gmt = %s
				WHERE status = %s
					AND reservation_date <= %s
					AND DATE_ADD(TIMESTAMP(reservation_date, reservation_time), INTERVAL duration_minutes MINUTE) <= %s',
				$tables['reservations'],
				'completed',
				'',
				current_time( 'mysql', true ),
				'confirmed',
				$today,
				$local_now
			)
		);

		$updated = false === $updated ? 0 : max( 0, (int) $updated );
		if ( $updated > 0 ) {
			self::increment_sync_revision();
		}

		return $updated;
	}

	/**
	 * Get the revision used by desktop background synchronization.
	 */
	public static function get_sync_revision(): int {
		return absint( get_option( self::OPTION_SYNC_REVISION, 0 ) );
	}

	/**
	 * Get the revision used to invalidate desktop floor-plan configuration.
	 */
	public static function get_floor_plan_revision(): int {
		return absint( get_option( self::OPTION_FLOOR_PLAN_REV, 0 ) );
	}

	/**
	 * Reconcile ended reservations once before reservation data is read.
	 */
	private static function reconcile_ended_reservations(): void {
		if ( self::$ended_reservations_reconciled ) {
			return;
		}

		self::$ended_reservations_reconciled = true;
		self::complete_ended_reservations();
	}

	/**
	 * Advance the desktop synchronization revision.
	 */
	private static function increment_sync_revision(): void {
		update_option( self::OPTION_SYNC_REVISION, self::get_sync_revision() + 1, false );
	}

	/**
	 * Advance the desktop floor-plan configuration revision.
	 */
	private static function increment_floor_plan_revision(): void {
		update_option( self::OPTION_FLOOR_PLAN_REV, self::get_floor_plan_revision() + 1, false );
	}

	/**
	 * Acquire a shared lock that limits completion queries to once per minute.
	 */
	private static function acquire_completion_lock(): bool {
		$db         = self::db();
		$now        = time();
		$expires_at = $now + self::COMPLETION_LOCK_SECONDS;
		$current    = absint( get_option( self::OPTION_COMPLETION_LOCK, 0 ) );

		if ( $current > $now ) {
			return false;
		}

		if ( 0 === $current && add_option( self::OPTION_COMPLETION_LOCK, $expires_at, '', false ) ) {
			return true;
		}

		$updated = $db->query(
			$db->prepare(
				'UPDATE %i
				SET option_value = %s
				WHERE option_name = %s
					AND CAST(option_value AS UNSIGNED) <= %d',
				$db->options,
				(string) $expires_at,
				self::OPTION_COMPLETION_LOCK,
				$now
			)
		);

		if ( 1 === $updated ) {
			wp_cache_delete( self::OPTION_COMPLETION_LOCK, 'options' );
			return true;
		}

		return false;
	}

	/**
	 * Get the largest active table capacity.
	 *
	 * @return int
	 */
	private function get_max_active_table_capacity(): int {
		$db     = self::db();
		$tables = self::table_names();

		return max(
			0,
			(int) $db->get_var(
				$db->prepare(
					'SELECT MAX(capacity) FROM %i WHERE is_active = 1',
					$tables['tables']
				)
			)
		);
	}

	/**
	 * Validate whether any active table can accommodate a party size.
	 *
	 * @param int $party_size Requested party size.
	 * @return true|WP_Error
	 */
	public function validate_party_size_capacity( int $party_size ) {
		$party_size   = max( 1, absint( $party_size ) );
		$max_capacity = $this->get_max_active_table_capacity();

		if ( $max_capacity > 0 && $party_size > $max_capacity ) {
			return $this->get_party_size_capacity_error();
		}

		return true;
	}

	/**
	 * Build the unavailable table error for a party larger than every table.
	 */
	private function get_party_size_capacity_error(): WP_Error {
		return new WP_Error(
			'foodord_reservation_party_size_too_large',
			__( 'No suitable table is available for your party size. Please contact us directly so we can help with your reservation.', 'foodord' )
		);
	}

	/**
	 * Find the smallest active table that can serve the requested slot.
	 *
	 * @param string $date       Date in Y-m-d format.
	 * @param string $time       Time in H:i:s format.
	 * @param int    $party_size             Party size.
	 * @param int    $exclude_reservation_id Optional reservation ID to ignore.
	 * @param int    $duration_minutes       Optional staff-selected duration in minutes.
	 * @return object|null
	 */
	public function find_available_table( string $date, string $time, int $party_size, int $exclude_reservation_id = 0, int $duration_minutes = 0 ) {
		$available_tables = $this->get_available_tables_for_slot( $date, $time, $party_size, $exclude_reservation_id, $duration_minutes );

		return $available_tables[0] ?? null;
	}

	/**
	 * Find the best available table for a staff-created reservation.
	 *
	 * Staff may record a party larger than every configured table. A fitting
	 * table remains preferred; otherwise the largest available table is used.
	 *
	 * @param string $date                   Date in Y-m-d format.
	 * @param string $time                   Time in H:i:s format.
	 * @param int    $party_size             Party size.
	 * @param int    $exclude_reservation_id Optional reservation ID to ignore.
	 * @param int    $duration_minutes       Optional staff-selected duration in minutes.
	 * @return object|null
	 */
	private function find_available_table_for_staff( string $date, string $time, int $party_size, int $exclude_reservation_id = 0, int $duration_minutes = 0 ) {
		$table = $this->find_available_table( $date, $time, $party_size, $exclude_reservation_id, $duration_minutes );
		if ( $table ) {
			return $table;
		}

		$available_tables = $this->get_available_tables_for_slot( $date, $time, 1, $exclude_reservation_id, $duration_minutes );
		$largest_table    = null;
		foreach ( $available_tables as $available_table ) {
			if ( ! $largest_table || (int) $available_table->capacity > (int) $largest_table->capacity ) {
				$largest_table = $available_table;
			}
		}

		return $largest_table;
	}

	/**
	 * Get floor-plan tables a customer can select for a requested slot.
	 *
	 * @param string $date       Reservation date in Y-m-d format.
	 * @param string $time       Reservation time in H:i or H:i:s format.
	 * @param int    $party_size Party size.
	 * @return array<int,array{id:int,name:string,image_url:string,tables:array<int,array{id:int,name:string,capacity:int,x:float,y:float}>}>|WP_Error
	 */
	public function get_selectable_tables_for_slot( string $date, string $time, int $party_size ) {
		$date       = $this->normalize_date( $date );
		$time       = $this->normalize_time( $time );
		$party_size = max( 1, absint( $party_size ) );

		if ( ! self::is_frontend_table_selection_enabled() ) {
			return new WP_Error( 'foodord_reservation_table_selection_disabled', __( 'Table selection is not available right now.', 'foodord' ) );
		}

		if ( '' === $date || '' === $time ) {
			return new WP_Error( 'foodord_reservation_invalid_slot', __( 'Invalid reservation date or time.', 'foodord' ) );
		}

		$slot_validation = $this->validate_booking_slot( $date, $time );
		if ( is_wp_error( $slot_validation ) ) {
			return $slot_validation;
		}

		$max_capacity = $this->get_max_active_table_capacity();
		if ( $max_capacity > 0 && $party_size > $max_capacity ) {
			return $this->get_party_size_capacity_error();
		}

		$this->cleanup_expired_holds();
		$settings         = self::get_table_selection_settings();
		$available_tables = $this->get_available_tables_for_slot( $date, $time, $party_size );
		$selectable       = array();

		foreach ( $available_tables as $table ) {
			$duration            = self::sanitize_reservation_duration_minutes( $table->reservation_duration_minutes ?? self::DEFAULT_DURATION_MINUTES );
			$capacity_validation = $this->validate_capacity_limits( $date, $time, $duration, $party_size );
			if ( is_wp_error( $capacity_validation ) ) {
				continue;
			}

			$selectable[] = $table;
		}

		return $this->build_floor_plan_layouts( $selectable, $settings );
	}

	/**
	 * Get every renderable floor plan used by restaurant staff.
	 *
	 * @return array<int,array<string,mixed>>
	 */
	public function get_floor_plan_layouts(): array {
		return $this->build_floor_plan_layouts(
			$this->get_tables( false ),
			self::get_table_selection_settings()
		);
	}

	/**
	 * Build normalized floor-plan payloads from configured tables.
	 *
	 * @param array<int,object>   $tables   Tables to include.
	 * @param array<string,mixed> $settings Sanitized table selection settings.
	 * @return array<int,array<string,mixed>>
	 */
	private function build_floor_plan_layouts( array $tables, array $settings ): array {
		$settings    = self::sanitize_table_selection_settings( $settings );
		$floor_plans = array();
		$images      = array();

		foreach ( $tables as $table ) {
			$floor_id = absint( $table->floor_id ?? 0 );
			$table_id = absint( $table->id ?? 0 );
			$layout   = $settings['layouts'][ $floor_id ] ?? null;
			if ( $floor_id <= 0 || $table_id <= 0 || ! is_array( $layout ) ) {
				continue;
			}

			$mode     = 'grid' === ( $layout['mode'] ?? 'image' ) ? 'grid' : 'image';
			$position = 'grid' === $mode
				? ( $layout['grid_tables'][ $table_id ] ?? null )
				: ( $layout['tables'][ $table_id ] ?? null );
			$image_id = absint( $layout['image_id'] ?? 0 );
			if ( ! is_array( $position ) ) {
				continue;
			}

			if ( 'image' === $mode && ( $image_id <= 0 || ! wp_attachment_is_image( $image_id ) ) ) {
				continue;
			}

			if ( 'image' === $mode && ! array_key_exists( $image_id, $images ) ) {
				$image = wp_get_attachment_image_src( $image_id, 'large' );
				if ( ! is_array( $image ) || empty( $image[0] ) ) {
					$images[ $image_id ] = null;
				} else {
					$modified            = get_post_modified_time( 'U', true, $image_id );
					$images[ $image_id ] = array(
						'id'      => $image_id,
						'url'     => (string) $image[0],
						'width'   => absint( $image[1] ?? 0 ),
						'height'  => absint( $image[2] ?? 0 ),
						'version' => false === $modified ? '0' : (string) $modified,
					);
				}
			}

			$image = 'image' === $mode ? ( $images[ $image_id ] ?? null ) : null;
			if ( 'image' === $mode && ! is_array( $image ) ) {
				continue;
			}

			if ( ! isset( $floor_plans[ $floor_id ] ) ) {
				$floor_plans[ $floor_id ] = array(
					'id'            => $floor_id,
					'name'          => html_entity_decode( (string) ( $table->floor_name ?? '' ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
					'mode'          => $mode,
					'image_id'      => is_array( $image ) ? $image['id'] : 0,
					'image_url'     => is_array( $image ) ? $image['url'] : '',
					'image_width'   => is_array( $image ) ? $image['width'] : 0,
					'image_height'  => is_array( $image ) ? $image['height'] : 0,
					'image_version' => is_array( $image ) ? $image['version'] : '0',
					'aspect_ratio'  => 1.6,
					'snap_to_grid'  => ! isset( $layout['snap_to_grid'] ) || ! empty( $layout['snap_to_grid'] ),
					'grid_density'  => 'fine' === ( $layout['grid_density'] ?? 'coarse' ) ? 'fine' : 'coarse',
					'objects'       => 'grid' === $mode ? array_values( $layout['objects'] ?? array() ) : array(),
					'tables'        => array(),
				);
			}

			$table_payload = array(
				'id'               => $table_id,
				'name'             => html_entity_decode( (string) ( $table->name ?? '' ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
				'capacity'         => absint( $table->capacity ?? 0 ),
				'duration_minutes' => self::sanitize_reservation_duration_minutes( $table->reservation_duration_minutes ?? self::DEFAULT_DURATION_MINUTES ),
				'x'                => (float) $position['x'],
				'y'                => (float) $position['y'],
				'width'            => (float) $position['width'],
				'height'           => (float) $position['height'],
				'rotation'         => (float) $position['rotation'],
				'shape'            => (string) $position['shape'],
				'fill'             => (string) ( $position['fill'] ?? '' ),
				'text_color'       => (string) ( $position['text_color'] ?? '' ),
			);

			if ( 'grid' === $mode ) {
				$table_payload['seat_mode']   = (string) $position['seat_mode'];
				$table_payload['seat_top']    = absint( $position['seat_top'] );
				$table_payload['seat_right']  = absint( $position['seat_right'] );
				$table_payload['seat_bottom'] = absint( $position['seat_bottom'] );
				$table_payload['seat_left']   = absint( $position['seat_left'] );
			}

			$floor_plans[ $floor_id ]['tables'][] = $table_payload;
		}

		return array_values( $floor_plans );
	}

	/**
	 * Get all active tables that can serve a requested reservation slot.
	 *
	 * @param string $date                   Date in Y-m-d format.
	 * @param string $time                   Time in H:i:s format.
	 * @param int    $party_size             Party size.
	 * @param int    $exclude_reservation_id Optional reservation ID to ignore.
	 * @param int    $duration_minutes       Optional staff-selected duration in minutes.
	 * @return array<int,object>
	 */
	private function get_available_tables_for_slot( string $date, string $time, int $party_size, int $exclude_reservation_id = 0, int $duration_minutes = 0 ): array {
		$db                     = self::db();
		$date                   = $this->normalize_date( $date );
		$time                   = $this->normalize_time( $time );
		$party_size             = max( 1, absint( $party_size ) );
		$exclude_reservation_id = absint( $exclude_reservation_id );
		$duration_minutes       = self::sanitize_optional_reservation_duration_minutes( $duration_minutes );

		if ( '' === $date || '' === $time ) {
			return array();
		}

		$tables      = self::table_names();
		$now         = current_time( 'mysql', true );
		$starts_at   = $date . ' ' . $time;
		$date_window = $this->get_overlap_date_window( $date );

		$available_tables = $db->get_results(
			$db->prepare(
				"SELECT reservation_tables.*, reservation_floors.name AS floor_name
				FROM %i reservation_tables
				LEFT JOIN %i reservation_floors ON reservation_floors.id = reservation_tables.floor_id
				WHERE reservation_tables.is_active = 1
					AND reservation_tables.capacity >= %d
					AND NOT EXISTS (
						SELECT 1
						FROM %i reservation_holds
						WHERE reservation_holds.table_id = reservation_tables.id
							AND reservation_holds.reservation_date BETWEEN %s AND %s
							AND reservation_holds.expires_at_gmt > %s
							AND TIMESTAMP(reservation_holds.reservation_date, reservation_holds.reservation_time) < DATE_ADD(%s, INTERVAL IF(%d > 0, %d, reservation_tables.reservation_duration_minutes) MINUTE)
							AND DATE_ADD(TIMESTAMP(reservation_holds.reservation_date, reservation_holds.reservation_time), INTERVAL reservation_holds.duration_minutes MINUTE) > %s
					)
					AND NOT EXISTS (
						SELECT 1
						FROM %i reservations
						WHERE reservations.table_id = reservation_tables.id
							AND reservations.reservation_date BETWEEN %s AND %s
							AND reservations.status IN ('pending','paid_pending','confirmed')
							AND ( %d = 0 OR reservations.id <> %d )
							AND TIMESTAMP(reservations.reservation_date, reservations.reservation_time) < DATE_ADD(%s, INTERVAL IF(%d > 0, %d, reservation_tables.reservation_duration_minutes) MINUTE)
							AND DATE_ADD(TIMESTAMP(reservations.reservation_date, reservations.reservation_time), INTERVAL reservations.duration_minutes MINUTE) > %s
					)
				ORDER BY reservation_tables.capacity ASC, reservation_floors.sort_order ASC, reservation_tables.sort_order ASC, reservation_tables.id ASC",
				$tables['tables'],
				$tables['floors'],
				$party_size,
				$tables['holds'],
				$date_window['start'],
				$date_window['end'],
				$now,
				$starts_at,
				$duration_minutes,
				$duration_minutes,
				$starts_at,
				$tables['reservations'],
				$date_window['start'],
				$date_window['end'],
				$exclude_reservation_id,
				$exclude_reservation_id,
				$starts_at,
				$duration_minutes,
				$duration_minutes,
				$starts_at
			)
		);

		return is_array( $available_tables ) ? $available_tables : array();
	}

	/**
	 * Get an active hold by token.
	 *
	 * @param string $hold_token Hold token.
	 * @return object|null
	 */
	public function get_active_hold( string $hold_token ) {
		$db         = self::db();
		$hold_token = sanitize_text_field( $hold_token );
		if ( '' === $hold_token ) {
			return null;
		}

		$tables = self::table_names();
		$now    = current_time( 'mysql', true );

		return $db->get_row(
			$db->prepare(
				'SELECT * FROM %i WHERE hold_token = %s AND expires_at_gmt > %s',
				$tables['holds'],
				$hold_token,
				$now
			)
		);
	}

	/**
	 * Validate concurrent reservation and guest limits for a requested interval.
	 *
	 * @param string $date                   Reservation date.
	 * @param string $time                   Reservation time.
	 * @param int    $duration               Reservation duration in minutes.
	 * @param int    $party_size             Requested number of guests.
	 * @param int    $exclude_reservation_id Optional reservation ID to ignore.
	 * @param string $exclude_hold_token     Optional hold token to ignore.
	 * @return true|WP_Error
	 */
	private function validate_capacity_limits( string $date, string $time, int $duration, int $party_size, int $exclude_reservation_id = 0, string $exclude_hold_token = '' ) {
		$limits = self::get_booking_settings()['capacity_limits'];
		if ( ! $limits['enabled'] || ( 0 === $limits['max_reservations'] && 0 === $limits['max_guests'] ) ) {
			return true;
		}

		$usage = $this->get_overlapping_capacity_usage( $date, $time, $duration, $exclude_reservation_id, $exclude_hold_token );
		if ( $limits['max_reservations'] > 0 && ( $usage['reservations'] + 1 ) > $limits['max_reservations'] ) {
			return new WP_Error(
				'foodord_reservation_capacity_limit_reached',
				__( 'The maximum number of reservations has been reached for the selected time.', 'foodord' )
			);
		}

		if ( $limits['max_guests'] > 0 && ( $usage['guests'] + $party_size ) > $limits['max_guests'] ) {
			return new WP_Error(
				'foodord_reservation_guest_limit_reached',
				__( 'The maximum number of guests has been reached for the selected time.', 'foodord' )
			);
		}

		return true;
	}

	/**
	 * Get active reservations and holds overlapping a requested interval.
	 *
	 * @param string $date                   Reservation date.
	 * @param string $time                   Reservation time.
	 * @param int    $duration               Reservation duration in minutes.
	 * @param int    $exclude_reservation_id Optional reservation ID to ignore.
	 * @param string $exclude_hold_token     Optional hold token to ignore.
	 * @return array{reservations:int,guests:int}
	 */
	private function get_overlapping_capacity_usage( string $date, string $time, int $duration, int $exclude_reservation_id = 0, string $exclude_hold_token = '' ): array {
		$db                     = self::db();
		$date                   = $this->normalize_date( $date );
		$time                   = $this->normalize_time( $time );
		$duration               = self::sanitize_reservation_duration_minutes( $duration );
		$exclude_reservation_id = absint( $exclude_reservation_id );
		$exclude_hold_token     = sanitize_text_field( $exclude_hold_token );

		if ( '' === $date || '' === $time ) {
			return array(
				'reservations' => 0,
				'guests'       => 0,
			);
		}

		$tables       = self::table_names();
		$starts_at    = $date . ' ' . $time;
		$date_window  = $this->get_overlap_date_window( $date );
		$now          = current_time( 'mysql', true );
		$holds        = $db->get_row(
			$db->prepare(
				"SELECT COUNT(*) AS reservation_count, COALESCE(SUM(party_size), 0) AS guest_count
				FROM %i
				WHERE reservation_date BETWEEN %s AND %s
					AND expires_at_gmt > %s
					AND ( %s = '' OR hold_token <> %s )
					AND TIMESTAMP(reservation_date, reservation_time) < DATE_ADD(%s, INTERVAL %d MINUTE)
					AND DATE_ADD(TIMESTAMP(reservation_date, reservation_time), INTERVAL duration_minutes MINUTE) > %s",
				$tables['holds'],
				$date_window['start'],
				$date_window['end'],
				$now,
				$exclude_hold_token,
				$exclude_hold_token,
				$starts_at,
				$duration,
				$starts_at
			)
		);
		$reservations = $db->get_row(
			$db->prepare(
				"SELECT COUNT(*) AS reservation_count, COALESCE(SUM(party_size), 0) AS guest_count
				FROM %i
				WHERE reservation_date BETWEEN %s AND %s
					AND status IN ('pending', 'paid_pending', 'confirmed')
					AND ( %d = 0 OR id <> %d )
					AND TIMESTAMP(reservation_date, reservation_time) < DATE_ADD(%s, INTERVAL %d MINUTE)
					AND DATE_ADD(TIMESTAMP(reservation_date, reservation_time), INTERVAL duration_minutes MINUTE) > %s",
				$tables['reservations'],
				$date_window['start'],
				$date_window['end'],
				$exclude_reservation_id,
				$exclude_reservation_id,
				$starts_at,
				$duration,
				$starts_at
			)
		);

		return array(
			'reservations' => absint( $holds->reservation_count ?? 0 ) + absint( $reservations->reservation_count ?? 0 ),
			'guests'       => absint( $holds->guest_count ?? 0 ) + absint( $reservations->guest_count ?? 0 ),
		);
	}

	/**
	 * Validate whether a booking slot is allowed by admin settings.
	 *
	 * @param string $date Reservation date.
	 * @param string $time                  Reservation time.
	 * @param bool   $ignore_booking_limits Whether maximum advance and late booking limits should be bypassed.
	 * @return true|WP_Error
	 */
	public function validate_booking_slot( string $date, string $time, bool $ignore_booking_limits = false ) {
		$date = $this->normalize_date( $date );
		$time = $this->normalize_time( $time );
		if ( '' === $date || '' === $time ) {
			return new WP_Error( 'foodord_reservation_invalid_slot', __( 'Invalid reservation date or time.', 'foodord' ) );
		}

		if ( ! $this->is_booking_date_available( $date, $ignore_booking_limits ) ) {
			return new WP_Error( 'foodord_reservation_date_unavailable', __( 'Reservations are not available on this date.', 'foodord' ) );
		}

		$default_durations = null;
		$available_times   = $this->get_time_options_for_date( $date, 0, 0, false, false, 0, $default_durations, $ignore_booking_limits );
		if ( ! in_array( substr( $time, 0, 5 ), $available_times, true ) ) {
			return new WP_Error( 'foodord_reservation_time_unavailable', __( 'Reservations are not available at this time.', 'foodord' ) );
		}

		return true;
	}

	/**
	 * Check whether a date can accept reservations.
	 *
	 * @param string $date                  Reservation date.
	 * @param bool   $ignore_booking_limits Whether maximum advance and late booking limits should be bypassed.
	 */
	public function is_booking_date_available( string $date, bool $ignore_booking_limits = false ): bool {
		$date = $this->normalize_date( $date );
		if ( '' === $date ) {
			return false;
		}

		if ( ! $this->is_booking_date_within_limits( $date, $ignore_booking_limits ) ) {
			return false;
		}

		$settings = self::get_booking_settings();
		foreach ( $settings['holiday_ranges'] as $range ) {
			if ( $date >= $range['from'] && $date <= $range['to'] ) {
				return false;
			}
		}

		$schedule = $this->get_booking_schedule_for_date( $date );

		return ! empty( $schedule['options'] );
	}

	/**
	 * Check whether a date is allowed by booking window settings.
	 *
	 * @param string $date                  Reservation date in Y-m-d format.
	 * @param bool   $ignore_booking_limits Whether maximum advance and late booking limits should be bypassed.
	 */
	private function is_booking_date_within_limits( string $date, bool $ignore_booking_limits = false ): bool {
		$today = $this->get_today_date();
		if ( '' === $today || $date < $today ) {
			return false;
		}
		if ( $ignore_booking_limits ) {
			return true;
		}

		$settings = self::get_booking_settings();
		if ( self::LATE_BOOKING_SAME_DAY === $settings['late_booking'] && $date === $today ) {
			return false;
		}

		$maximum_advance_days = absint( $settings['maximum_advance_days'] );
		if ( $maximum_advance_days <= 0 ) {
			return true;
		}

		$maximum_date = $this->get_maximum_advance_date( $maximum_advance_days );

		return '' !== $maximum_date && $date <= $maximum_date;
	}

	/**
	 * Check whether a time is allowed by late booking settings.
	 *
	 * @param string $date                  Reservation date in Y-m-d format.
	 * @param string $time                  Reservation time in H:i format.
	 * @param bool   $ignore_booking_limits Whether maximum advance and late booking limits should be bypassed.
	 */
	private function is_booking_time_within_limits( string $date, string $time, bool $ignore_booking_limits = false ): bool {
		if ( ! $this->is_booking_date_within_limits( $date, $ignore_booking_limits ) ) {
			return false;
		}

		$settings = self::get_booking_settings();
		if ( ! $ignore_booking_limits && self::LATE_BOOKING_SAME_DAY === $settings['late_booking'] ) {
			return true;
		}

		$slot = DateTimeImmutable::createFromFormat( 'Y-m-d H:i', $date . ' ' . substr( $time, 0, 5 ), wp_timezone() );
		if ( ! $slot instanceof DateTimeImmutable ) {
			return false;
		}

		$minutes = $ignore_booking_limits ? 0 : absint( $settings['late_booking'] );
		$cutoff  = new DateTimeImmutable( 'now', wp_timezone() );
		if ( $minutes > 0 ) {
			$cutoff = $cutoff->modify( '+' . $minutes . ' minutes' );
		}

		return $slot >= $cutoff;
	}

	/**
	 * Get today's local date.
	 */
	private function get_today_date(): string {
		return wp_date( 'Y-m-d', null, wp_timezone() );
	}

	/**
	 * Get the furthest local date that can be booked.
	 *
	 * @param int $days Maximum days ahead.
	 */
	private function get_maximum_advance_date( int $days ): string {
		$today = new DateTimeImmutable( 'today', wp_timezone() );
		$date  = $today->modify( '+' . max( 0, $days ) . ' days' );

		return $date instanceof DateTimeImmutable ? $date->format( 'Y-m-d' ) : '';
	}

	/**
	 * Check whether a table slot overlaps an active hold.
	 *
	 * @param int    $table_id Table ID.
	 * @param string $date     Reservation date.
	 * @param string $time     Reservation time.
	 * @param int    $duration Proposed duration in minutes.
	 * @param string $exclude_hold_token Optional hold token to ignore.
	 */
	private function slot_has_active_hold_conflict( int $table_id, string $date, string $time, int $duration, string $exclude_hold_token = '' ): bool {
		$db                 = self::db();
		$table_id           = absint( $table_id );
		$date               = $this->normalize_date( $date );
		$time               = $this->normalize_time( $time );
		$duration           = self::sanitize_reservation_duration_minutes( $duration );
		$exclude_hold_token = sanitize_text_field( $exclude_hold_token );

		if ( $table_id <= 0 || '' === $date || '' === $time ) {
			return true;
		}

		$tables      = self::table_names();
		$starts_at   = $date . ' ' . $time;
		$date_window = $this->get_overlap_date_window( $date );
		$now         = current_time( 'mysql', true );
		$hold_count  = (int) $db->get_var(
			$db->prepare(
				'SELECT COUNT(*)
				FROM %i
				WHERE table_id = %d
					AND reservation_date BETWEEN %s AND %s
					AND expires_at_gmt > %s
					AND hold_token <> %s
					AND TIMESTAMP(reservation_date, reservation_time) < DATE_ADD(%s, INTERVAL %d MINUTE)
					AND DATE_ADD(TIMESTAMP(reservation_date, reservation_time), INTERVAL duration_minutes MINUTE) > %s',
				$tables['holds'],
				$table_id,
				$date_window['start'],
				$date_window['end'],
				$now,
				$exclude_hold_token,
				$starts_at,
				$duration,
				$starts_at
			)
		);

		return $hold_count > 0;
	}

	/**
	 * Get the longest staff-selectable duration before the next table conflict.
	 *
	 * @param int    $table_id Table ID.
	 * @param string $date     Reservation date.
	 * @param string $time     Reservation time.
	 */
	private function get_max_reservable_staff_duration( int $table_id, string $date, string $time ): int {
		$durations = $this->get_max_reservable_staff_durations( array( $table_id ), $date, $time );

		return absint( $durations[ absint( $table_id ) ] ?? 0 );
	}

	/**
	 * Get staff duration limits for multiple tables in one database query.
	 *
	 * @param int[]  $table_ids Table IDs.
	 * @param string $date      Reservation date.
	 * @param string $time      Reservation time.
	 * @return array<int,int>
	 */
	private function get_max_reservable_staff_durations( array $table_ids, string $date, string $time ): array {
		$db        = self::db();
		$table_ids = array_values( array_unique( array_filter( array_map( 'absint', $table_ids ) ) ) );
		$date      = $this->normalize_date( $date );
		$time      = $this->normalize_time( $time );
		if ( empty( $table_ids ) || '' === $date || '' === $time ) {
			return array();
		}

		$tables       = self::table_names();
		$now          = current_time( 'mysql', true );
		$starts_at    = $date . ' ' . $time;
		$date_window  = $this->get_overlap_date_window( $date );
		$placeholders = implode( ', ', array_fill( 0, count( $table_ids ), '%d' ) );
		$query        = "SELECT conflicts.table_id, MIN(conflicts.starts_at) AS next_start
			FROM (
				SELECT table_id, TIMESTAMP(reservation_date, reservation_time) AS starts_at
				FROM %i
				WHERE table_id IN ($placeholders)
					AND reservation_date BETWEEN %s AND %s
					AND expires_at_gmt > %s
					AND DATE_ADD(TIMESTAMP(reservation_date, reservation_time), INTERVAL duration_minutes MINUTE) > %s
					AND TIMESTAMP(reservation_date, reservation_time) < DATE_ADD(%s, INTERVAL %d MINUTE)
				UNION ALL
				SELECT table_id, TIMESTAMP(reservation_date, reservation_time) AS starts_at
				FROM %i
				WHERE table_id IN ($placeholders)
					AND reservation_date BETWEEN %s AND %s
					AND status IN ('pending', 'paid_pending', 'confirmed')
					AND DATE_ADD(TIMESTAMP(reservation_date, reservation_time), INTERVAL duration_minutes MINUTE) > %s
					AND TIMESTAMP(reservation_date, reservation_time) < DATE_ADD(%s, INTERVAL %d MINUTE)
			) conflicts
			GROUP BY conflicts.table_id";
		$args         = array_merge(
			array( $tables['holds'] ),
			$table_ids,
			array( $date_window['start'], $date_window['end'], $now, $starts_at, $starts_at, self::MAX_DURATION_MINUTES, $tables['reservations'] ),
			$table_ids,
			array( $date_window['start'], $date_window['end'], $starts_at, $starts_at, self::MAX_DURATION_MINUTES )
		);
		$rows         = $db->get_results( $db->prepare( $query, ...$args ) );
		$start_time   = strtotime( $starts_at );
		$durations    = array_fill_keys( $table_ids, self::MAX_DURATION_MINUTES );

		if ( false === $start_time ) {
			return array();
		}

		foreach ( is_array( $rows ) ? $rows : array() as $row ) {
			$table_id      = absint( $row->table_id ?? 0 );
			$conflict_time = strtotime( (string) ( $row->next_start ?? '' ) );
			if ( $table_id <= 0 || false === $conflict_time ) {
				continue;
			}

			$available_minutes      = max( 0, (int) floor( ( $conflict_time - $start_time ) / MINUTE_IN_SECONDS ) );
			$durations[ $table_id ] = min(
				self::MAX_DURATION_MINUTES,
				(int) ( floor( $available_minutes / self::DURATION_STEP_MINUTES ) * self::DURATION_STEP_MINUTES )
			);
		}

		return $durations;
	}

	/**
	 * Check whether a duration ends inside the booking-hours range containing its start time.
	 *
	 * @param string $date     Reservation date.
	 * @param string $time     Reservation time.
	 * @param int    $duration Proposed duration in minutes.
	 */
	private function reservation_duration_fits_booking_hours( string $date, string $time, int $duration ): bool {
		$start     = $this->get_booking_slot_datetime( $date, $time );
		$range_end = $this->get_booking_range_end_datetime( $date, $time );
		if ( ! $start instanceof DateTimeImmutable || ! $range_end instanceof DateTimeImmutable ) {
			return false;
		}

		return $start->modify( '+' . self::sanitize_reservation_duration_minutes( $duration ) . ' minutes' ) <= $range_end;
	}

	/**
	 * Build a reservation slot datetime in the site timezone.
	 *
	 * @param string $date Reservation date.
	 * @param string $time Reservation time.
	 */
	private function get_booking_slot_datetime( string $date, string $time ): ?DateTimeImmutable {
		$date = $this->normalize_date( $date );
		$time = $this->normalize_time( $time );
		if ( '' === $date || '' === $time ) {
			return null;
		}

		$date_time = DateTimeImmutable::createFromFormat( '!Y-m-d H:i', $date . ' ' . substr( $time, 0, 5 ), wp_timezone() );

		return $date_time instanceof DateTimeImmutable ? $date_time : null;
	}

	/**
	 * Get the latest closing datetime from all booking periods containing a slot.
	 *
	 * @param string $date Reservation date.
	 * @param string $time Reservation time.
	 */
	private function get_booking_range_end_datetime( string $date, string $time ): ?DateTimeImmutable {
		$slot = $this->get_booking_slot_datetime( $date, $time );
		if ( ! $slot instanceof DateTimeImmutable ) {
			return null;
		}

		$range_end = null;
		foreach ( $this->get_booking_periods_for_date( $date ) as $period ) {
			if ( $slot < $period['start'] || $slot > $period['end'] ) {
				continue;
			}

			if ( ! $range_end instanceof DateTimeImmutable || $period['end'] > $range_end ) {
				$range_end = $period['end'];
			}
		}

		return $range_end;
	}

	/**
	 * Check whether a table slot can still receive a reservation.
	 *
	 * @param int    $table_id Table ID.
	 * @param string $date     Reservation date.
	 * @param string $time     Reservation time.
	 * @param int    $duration               Reservation duration in minutes.
	 * @param int    $exclude_reservation_id Optional reservation ID to ignore.
	 * @return bool
	 */
	private function slot_can_be_reserved( int $table_id, string $date, string $time, int $duration, int $exclude_reservation_id = 0 ): bool {
		$db                     = self::db();
		$table_id               = absint( $table_id );
		$date                   = $this->normalize_date( $date );
		$time                   = $this->normalize_time( $time );
		$duration               = self::sanitize_reservation_duration_minutes( $duration );
		$exclude_reservation_id = absint( $exclude_reservation_id );

		if ( $table_id <= 0 || '' === $date || '' === $time ) {
			return false;
		}

		$tables      = self::table_names();
		$starts_at   = $date . ' ' . $time;
		$date_window = $this->get_overlap_date_window( $date );
		$count       = (int) $db->get_var(
			$db->prepare(
				"SELECT COUNT(*)
				FROM %i
					WHERE table_id = %d
						AND reservation_date BETWEEN %s AND %s
						AND status IN ('pending','paid_pending','confirmed')
						AND ( %d = 0 OR id <> %d )
						AND TIMESTAMP(reservation_date, reservation_time) < DATE_ADD(%s, INTERVAL %d MINUTE)
						AND DATE_ADD(TIMESTAMP(reservation_date, reservation_time), INTERVAL duration_minutes MINUTE) > %s",
				$tables['reservations'],
				$table_id,
				$date_window['start'],
				$date_window['end'],
				$exclude_reservation_id,
				$exclude_reservation_id,
				$starts_at,
				$duration,
				$starts_at
			)
		);

		return 0 === $count;
	}

	/**
	 * Get the local date window that can overlap a requested reservation.
	 *
	 * @param string $date Date in Y-m-d format.
	 * @return array{start:string,end:string}
	 */
	private function get_overlap_date_window( string $date ): array {
		$timestamp = strtotime( $date . ' 00:00:00' );
		if ( false === $timestamp ) {
			return array(
				'start' => $date,
				'end'   => $date,
			);
		}

		return array(
			'start' => gmdate( 'Y-m-d', $timestamp - DAY_IN_SECONDS ),
			'end'   => gmdate( 'Y-m-d', $timestamp + DAY_IN_SECONDS ),
		);
	}

	/**
	 * Sanitize floor payload.
	 *
	 * @param array<string,mixed> $data Raw floor data.
	 * @return array{name:string,sort_order:int}|WP_Error
	 */
	private function sanitize_floor_payload( array $data ) {
		$name       = isset( $data['name'] ) ? sanitize_text_field( (string) $data['name'] ) : '';
		$sort_order = isset( $data['sort_order'] ) ? (int) $data['sort_order'] : 0;

		if ( '' === $name ) {
			return new WP_Error( 'foodord_reservation_floor_name_required', __( 'Floor name is required.', 'foodord' ) );
		}

		return array(
			'name'       => $name,
			'sort_order' => $sort_order,
		);
	}

	/**
	 * Check whether a floor name already exists.
	 *
	 * @param string $name             Floor name.
	 * @param int    $exclude_floor_id Floor ID to exclude.
	 */
	private function floor_name_exists( string $name, int $exclude_floor_id ): bool {
		$db               = self::db();
		$tables           = self::table_names();
		$exclude_floor_id = absint( $exclude_floor_id );

		return (int) $db->get_var(
			$db->prepare(
				'SELECT COUNT(*) FROM %i WHERE LOWER(name) = LOWER(%s) AND id <> %d',
				$tables['floors'],
				$name,
				$exclude_floor_id
			)
		) > 0;
	}

	/**
	 * Check whether a table name already exists on a floor.
	 *
	 * @param string $name             Table name.
	 * @param int    $floor_id         Floor ID.
	 * @param int    $exclude_table_id Table ID to exclude.
	 */
	private function table_name_exists_in_floor( string $name, int $floor_id, int $exclude_table_id ): bool {
		$db               = self::db();
		$tables           = self::table_names();
		$floor_id         = absint( $floor_id );
		$exclude_table_id = absint( $exclude_table_id );

		return (int) $db->get_var(
			$db->prepare(
				'SELECT COUNT(*) FROM %i WHERE floor_id = %d AND LOWER(name) = LOWER(%s) AND id <> %d',
				$tables['tables'],
				$floor_id,
				$name,
				$exclude_table_id
			)
		) > 0;
	}

	/**
	 * Check whether a floor exists.
	 *
	 * @param int $floor_id Floor ID.
	 */
	private function floor_exists( int $floor_id ): bool {
		$db       = self::db();
		$tables   = self::table_names();
		$floor_id = absint( $floor_id );

		if ( $floor_id <= 0 ) {
			return false;
		}

		return (int) $db->get_var(
			$db->prepare(
				'SELECT COUNT(*) FROM %i WHERE id = %d',
				$tables['floors'],
				$floor_id
			)
		) > 0;
	}

	/**
	 * Sanitize table payload.
	 *
	 * @param array<string,mixed> $data Raw table data.
	 * @return array{floor_id:int,name:string,capacity:int,reservation_duration_minutes:int,is_active:int,sort_order:int}|WP_Error
	 */
	private function sanitize_table_payload( array $data ) {
		$floor_id   = isset( $data['floor_id'] ) ? absint( $data['floor_id'] ) : 0;
		$name       = isset( $data['name'] ) ? sanitize_text_field( (string) $data['name'] ) : '';
		$capacity   = isset( $data['capacity'] ) ? absint( $data['capacity'] ) : 0;
		$duration   = self::sanitize_reservation_duration_minutes( $data['reservation_duration_minutes'] ?? self::DEFAULT_DURATION_MINUTES );
		$is_active  = array_key_exists( 'is_active', $data ) && empty( $data['is_active'] ) ? 0 : 1;
		$sort_order = isset( $data['sort_order'] ) ? (int) $data['sort_order'] : 0;

		if ( ! $this->floor_exists( $floor_id ) ) {
			return new WP_Error( 'foodord_reservation_floor_required', __( 'Please select a valid floor.', 'foodord' ) );
		}

		if ( '' === $name ) {
			return new WP_Error( 'foodord_reservation_table_name_required', __( 'Table name is required.', 'foodord' ) );
		}

		if ( $capacity <= 0 ) {
			return new WP_Error( 'foodord_reservation_table_capacity_required', __( 'Table capacity must be at least 1.', 'foodord' ) );
		}

		return array(
			'floor_id'                     => $floor_id,
			'name'                         => $name,
			'capacity'                     => $capacity,
			'reservation_duration_minutes' => $duration,
			'is_active'                    => $is_active,
			'sort_order'                   => $sort_order,
		);
	}

	/**
	 * Sanitize frontend customer payload.
	 *
	 * @param array<string,mixed> $data Raw customer data.
	 * @return array{name:string,email:string,phone:string,note:string}|WP_Error
	 */
	private function sanitize_customer_payload( array $data ) {
		$name  = isset( $data['name'] ) ? sanitize_text_field( (string) $data['name'] ) : '';
		$email = isset( $data['email'] ) ? sanitize_email( (string) $data['email'] ) : '';
		$phone = isset( $data['phone'] ) ? sanitize_text_field( (string) $data['phone'] ) : '';
		$note  = isset( $data['note'] ) ? sanitize_textarea_field( (string) $data['note'] ) : '';

		if ( '' === $name ) {
			return new WP_Error( 'foodord_reservation_name_required', __( 'Please enter your name.', 'foodord' ) );
		}

		if ( '' === $email || ! is_email( $email ) ) {
			return new WP_Error( 'foodord_reservation_email_required', __( 'Please enter a valid email address.', 'foodord' ) );
		}

		if ( '' === $phone ) {
			return new WP_Error( 'foodord_reservation_phone_required', __( 'Please enter your phone number.', 'foodord' ) );
		}

		return array(
			'name'  => $name,
			'email' => $email,
			'phone' => $phone,
			'note'  => $note,
		);
	}

	/**
	 * Generate a unique reservation code.
	 */
	private function generate_reservation_code(): string {
		$db     = self::db();
		$tables = self::table_names();

		for ( $attempt = 0; $attempt < 10; $attempt++ ) {
			$code   = 'FR-' . strtoupper( wp_generate_password( 8, false, false ) );
			$exists = (int) $db->get_var(
				$db->prepare(
					'SELECT COUNT(*) FROM %i WHERE reservation_code = %s',
					$tables['reservations'],
					$code
				)
			);

			if ( 0 === $exists ) {
				return $code;
			}
		}

		return 'FR-' . strtoupper( wp_generate_password( 12, false, false ) );
	}

	/**
	 * Generate a raw reservation management token.
	 */
	private static function generate_manage_token(): string {
		return wp_generate_password( 64, false, false );
	}

	/**
	 * Hash a raw reservation management token for storage.
	 *
	 * @param string $token Raw token.
	 */
	private static function hash_manage_token( string $token ): string {
		return hash_hmac( 'sha256', sanitize_text_field( $token ), wp_salt( 'auth' ) );
	}

	/**
	 * Check whether a reservation already started.
	 *
	 * @param object $reservation Reservation row.
	 */
	private function reservation_has_started( $reservation ): bool {
		$starts_at = $this->get_reservation_start_datetime( $reservation );

		return ! $starts_at instanceof DateTimeImmutable || $starts_at <= new DateTimeImmutable( 'now', wp_timezone() );
	}

	/**
	 * Get a reservation start date/time in the site timezone.
	 *
	 * @param object $reservation Reservation row.
	 * @return DateTimeImmutable|null
	 */
	private function get_reservation_start_datetime( $reservation ): ?DateTimeImmutable {
		$date = isset( $reservation->reservation_date ) ? (string) $reservation->reservation_date : '';
		$time = isset( $reservation->reservation_time ) ? (string) $reservation->reservation_time : '';
		if ( '' === $date || '' === $time ) {
			return null;
		}

		if ( 5 === strlen( $time ) ) {
			$time .= ':00';
		}

		$starts_at = DateTimeImmutable::createFromFormat( 'Y-m-d H:i:s', $date . ' ' . $time, wp_timezone() );
		if ( ! $starts_at instanceof DateTimeImmutable ) {
			return null;
		}

		return $starts_at;
	}

	/**
	 * Normalize a reservation date.
	 *
	 * @param string $date Date string.
	 * @return string
	 */
	private function normalize_date( string $date ): string {
		return self::normalize_date_value( $date );
	}

	/**
	 * Normalize a public reservation date input.
	 *
	 * @param string $date Date string.
	 * @return string
	 */
	public static function normalize_date_input( string $date ): string {
		return self::normalize_date_value( $date );
	}

	/**
	 * Normalize a date value.
	 *
	 * @param string $date Date string.
	 * @return string
	 */
	private static function normalize_date_value( string $date ): string {
		$date = sanitize_text_field( $date );
		if ( '' === $date ) {
			return '';
		}

		$formats = array_unique(
			array_filter(
				array(
					'Y-m-d',
					self::get_display_date_format(),
					'd-m-Y',
					'd/m/Y',
					'd.m.Y',
					'm/d/Y',
				)
			)
		);

		foreach ( $formats as $format ) {
			$parsed = DateTimeImmutable::createFromFormat( '!' . $format, $date, wp_timezone() );
			$errors = DateTimeImmutable::getLastErrors();

			if (
				! $parsed instanceof DateTimeImmutable ||
				( is_array( $errors ) && ( $errors['warning_count'] > 0 || $errors['error_count'] > 0 ) ) ||
				$parsed->format( $format ) !== $date
			) {
				continue;
			}

			return $parsed->format( 'Y-m-d' );
		}

		return '';
	}

	/**
	 * Normalize a reservation time.
	 *
	 * @param string $time Time string.
	 * @return string
	 */
	private function normalize_time( string $time ): string {
		$time = sanitize_text_field( $time );
		if ( preg_match( '/^\d{2}:\d{2}$/', $time ) ) {
			$time .= ':00';
		}

		$parsed = DateTimeImmutable::createFromFormat( '!H:i:s', $time, wp_timezone() );
		$errors = DateTimeImmutable::getLastErrors();

		if (
			! $parsed instanceof DateTimeImmutable ||
			( is_array( $errors ) && ( $errors['warning_count'] > 0 || $errors['error_count'] > 0 ) ) ||
			$parsed->format( 'H:i:s' ) !== $time
		) {
			return '';
		}

		return $parsed->format( 'H:i:s' );
	}
}
