<?php
/**
 * Business hours utilities for FoodOrd.
 *
 * @package FoodOrd
 */

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

/**
 * Helper class to evaluate restaurant opening status and expose it to frontend scripts.
 */
class Foodord_Business_Hours {

	/**
	 * Cached status payload.
	 *
	 * @var array|null
	 */
	private static $status_cache = null;

	/**
	 * Track localized script handles to avoid duplicate localization.
	 *
	 * @var array
	 */
	private static $localized_handles = array();

	/**
	 * Option key for manual mode overrides.
	 */
	private const MODE_OVERRIDES_OPTION = 'foodord_mode_overrides';

	/**
	 * Desktop app temporary pause reason.
	 */
	private const APP_TEMPORARY_PAUSE_REASON = 'Temporarily paused via FoodOrd App';

	/**
	 * Clear cached status payload and localized handles.
	 */
	public static function reset_cache(): void {
		self::$status_cache      = null;
		self::$localized_handles = array();
	}

	/**
	 * Fetch sanitized mode override data.
	 *
	 * @return array<string, array{locked: bool, reason: string, updated_at: string, unlock_after: string}>
	 */
	private static function get_mode_overrides(): array {
		$raw = get_option( self::MODE_OVERRIDES_OPTION, array() );
		if ( ! is_array( $raw ) ) {
			return array();
		}

		$result  = array();
		$changed = false;

		foreach ( array( 'delivery', 'pickup' ) as $mode_key ) {
			if ( empty( $raw[ $mode_key ] ) || ! is_array( $raw[ $mode_key ] ) ) {
				continue;
			}

			$entry        = $raw[ $mode_key ];
			$locked       = ! empty( $entry['locked'] );
			$reason       = sanitize_text_field( (string) ( $entry['reason'] ?? '' ) );
			$updated_at   = isset( $entry['updated_at'] ) ? (string) $entry['updated_at'] : '';
			$unlock_after = isset( $entry['unlock_after'] ) ? (string) $entry['unlock_after'] : '';

			if ( $locked && self::APP_TEMPORARY_PAUSE_REASON === $reason ) {
				$calculated_unlock_after = self::calculate_mode_unlock_after( $mode_key, $updated_at );
				if ( '' !== $calculated_unlock_after && $calculated_unlock_after !== $unlock_after ) {
					$unlock_after                    = $calculated_unlock_after;
					$raw[ $mode_key ]['unlock_after'] = $unlock_after;
					$changed                         = true;
				}

				if ( self::is_unlock_after_due( $unlock_after ) ) {
					unset( $raw[ $mode_key ] );
					$changed = true;
					continue;
				}
			}

			$result[ $mode_key ] = array(
				'locked'       => $locked,
				'reason'       => $reason,
				'updated_at'   => $updated_at,
				'unlock_after' => sanitize_text_field( $unlock_after ),
			);
		}

		if ( $changed ) {
			update_option( self::MODE_OVERRIDES_OPTION, $raw );
			self::reset_cache();
		}

		return $result;
	}

	/**
	 * Get specific mode lock metadata.
	 *
	 * @param string $mode Mode key.
	 * @return array{locked: bool, reason: string, updated_at: string, unlock_after: string}
	 */
	public static function get_mode_lock( string $mode ): array {
		$normalized = ( 'pickup' === $mode ) ? 'pickup' : 'delivery';
		$overrides  = self::get_mode_overrides();
		$entry      = $overrides[ $normalized ] ?? array();

		return array(
			'locked'       => ! empty( $entry['locked'] ),
			'reason'       => isset( $entry['reason'] ) ? (string) $entry['reason'] : '',
			'updated_at'   => isset( $entry['updated_at'] ) ? (string) $entry['updated_at'] : '',
			'unlock_after' => isset( $entry['unlock_after'] ) ? (string) $entry['unlock_after'] : '',
		);
	}

	/**
	 * Persist manual lock state for a mode.
	 *
	 * @param string $mode   Mode key.
	 * @param bool   $locked Whether the mode is manually locked.
	 * @param string $reason Optional reason shown to customers.
	 * @return array{locked: bool, reason: string, updated_at: string, unlock_after: string}
	 */
	public static function set_mode_lock( string $mode, bool $locked, string $reason = '' ): array {
		$normalized = ( 'pickup' === $mode ) ? 'pickup' : 'delivery';
		$overrides  = self::get_mode_overrides();

		if ( $locked ) {
			$clean_reason = sanitize_text_field( $reason );
			$updated_at   = gmdate( 'c' );

			$overrides[ $normalized ] = array(
				'locked'     => true,
				'reason'     => $clean_reason,
				'updated_at' => $updated_at,
			);

			if ( self::APP_TEMPORARY_PAUSE_REASON === $clean_reason ) {
				$unlock_after = self::calculate_mode_unlock_after( $normalized, $updated_at );
				if ( '' !== $unlock_after ) {
					$overrides[ $normalized ]['unlock_after'] = $unlock_after;
				}
			}
		} else {
			unset( $overrides[ $normalized ] );
		}

		update_option( self::MODE_OVERRIDES_OPTION, $overrides );
		self::reset_cache();

		return self::get_mode_lock( $normalized );
	}

	/**
	 * Calculate the next opening timestamp for a temporary desktop pause.
	 *
	 * @param string $mode       Mode key.
	 * @param string $updated_at Lock update timestamp.
	 * @return string DATE_ATOM timestamp or empty string.
	 */
	private static function calculate_mode_unlock_after( string $mode, string $updated_at ): string {
		$timezone = wp_timezone();

		try {
			$reference = '' !== trim( $updated_at ) ? new DateTimeImmutable( $updated_at ) : new DateTimeImmutable( 'now', $timezone );
			$reference = $reference->setTimezone( $timezone );
		} catch ( Exception $e ) {
			unset( $e );
			$reference = new DateTimeImmutable( 'now', $timezone );
		}

		if ( 'pickup' === $mode ) {
			$hours_option  = 'foodord_pickup_opening_hours';
			$custom_option = 'foodord_pickup_custom_hours';
		} else {
			$hours_option  = 'foodord_opening_hours';
			$custom_option = 'foodord_custom_hours';
		}

		$hours = get_option( $hours_option, self::get_default_hours() );
		if ( ! is_array( $hours ) ) {
			$hours = self::get_default_hours();
		}

		$custom_hours = get_option( $custom_option, array() );
		if ( ! is_array( $custom_hours ) ) {
			$custom_hours = array();
		}

		$candidates        = array();
		$preorder_enabled  = self::option_yes( get_option( 'foodord_preorder_enabled_' . $mode, 'no' ) );
		$preorder_cutoff   = '';
		$raw_preorder_time = get_option( 'foodord_preorder_time_' . $mode, '' );
		if ( is_string( $raw_preorder_time ) ) {
			$preorder_cutoff = trim( $raw_preorder_time );
		}

		for ( $offset = 0; $offset <= 8; $offset++ ) {
			$day   = $reference->modify( '+' . $offset . ' day' );
			$entry = self::get_entry_for_day( $hours, $custom_hours, $day );
			if ( empty( $entry ) || ! is_array( $entry ) || ! empty( $entry['closed'] ) ) {
				continue;
			}

			foreach ( self::build_entry_periods( $entry, $day, false ) as $period ) {
				if ( $period['start'] <= $reference ) {
					continue;
				}

				if ( $preorder_enabled && '' !== $preorder_cutoff ) {
					$preorder_start = self::build_time( $preorder_cutoff, $period['start'] );
					if ( $preorder_start && $preorder_start > $reference && $preorder_start < $period['start'] ) {
						$candidates[] = $preorder_start;
					}
				}

				$candidates[] = $period['start'];
			}
		}

		if ( empty( $candidates ) ) {
			return '';
		}

		usort(
			$candidates,
			function ( $a, $b ) {
				return $a <=> $b;
			}
		);

		return $candidates[0]->format( DATE_ATOM );
	}

	/**
	 * Determine whether a temporary desktop pause should be released.
	 *
	 * @param string $unlock_after Unlock timestamp.
	 * @return bool
	 */
	private static function is_unlock_after_due( string $unlock_after ): bool {
		if ( '' === trim( $unlock_after ) ) {
			return false;
		}

		try {
			$unlock_time = new DateTimeImmutable( $unlock_after );
			$now         = new DateTimeImmutable( 'now', wp_timezone() );
		} catch ( Exception $e ) {
			unset( $e );
			return false;
		}

		return $now >= $unlock_time;
	}

	/**
	 * Public accessor for mode-specific status messages.
	 *
	 * @return array<string, string>
	 */
	public static function get_mode_messages(): array {
		$payload = self::get_status_payload();
		$ref     = isset( $payload['ref_time'] ) ? (string) $payload['ref_time'] : '';

		try {
			$reference_now = $ref ? new DateTimeImmutable( $ref ) : new DateTimeImmutable( 'now', wp_timezone() );
		} catch ( Exception $e ) {
			unset( $e );
			$reference_now = new DateTimeImmutable( 'now', wp_timezone() );
		}

		$modes = isset( $payload['modes'] ) && is_array( $payload['modes'] ) ? $payload['modes'] : array();

		return self::build_mode_messages( $modes, $reference_now );
	}

	/**
	 * Determine whether the restaurant is currently open.
	 *
	 * @return bool
	 */
	public static function is_open(): bool {
		$status = self::get_status_payload();
		return ! empty( $status['is_open'] );
	}

	/**
	 * Get status payload (cached per request).
	 *
	 * @return array
	 */
	public static function get_status_payload(): array {
		if ( null !== self::$status_cache ) {
			return self::$status_cache;
		}

		$timezone = wp_timezone();
		$now      = new DateTimeImmutable( 'now', $timezone );

		// Table orders (dine-in) should not be blocked by business hours.
		if ( self::is_table_order_context() ) {
			$delivery_state                       = self::get_mode_state( 'delivery', $now );
			$pickup_state                         = self::get_mode_state( 'pickup', $now );
			$delivery_state['is_open']            = true;
			$delivery_state['custom_closed']      = false;
			$delivery_state['custom_description'] = '';
			$delivery_state['manual_locked']      = false;
			$pickup_state['is_open']              = true;
			$pickup_state['custom_closed']        = false;
			$pickup_state['custom_description']   = '';
			$pickup_state['manual_locked']        = false;

			self::$status_cache = array(
				'is_open'  => true,
				'modes'    => array(
					'delivery' => $delivery_state,
					'pickup'   => $pickup_state,
				),
				'ref_time' => $now->format( DATE_ATOM ),
				'timezone' => $timezone->getName(),
			);

			return self::$status_cache;
		}

		$delivery_state = self::get_mode_state( 'delivery', $now );
		$pickup_state   = self::get_mode_state( 'pickup', $now );

		$is_open = ( ! empty( $delivery_state['is_open'] ) || ! empty( $pickup_state['is_open'] ) );

		self::$status_cache = array(
			'is_open'  => $is_open,
			'modes'    => array(
				'delivery' => $delivery_state,
				'pickup'   => $pickup_state,
			),
			'ref_time' => $now->format( DATE_ATOM ),
			'timezone' => $timezone->getName(),
		);

		return self::$status_cache;
	}

	/**
	 * Determine if current request is running under table ordering context.
	 *
	 * @return bool
	 */
	private static function is_table_order_context(): bool {
		$source = '';
		if ( function_exists( 'WC' ) && WC()->session ) {
			$source = (string) WC()->session->get( 'foodord_order_source', '' );
		}
		if ( 'table' === $source ) {
			return true;
		}

		$cookie = filter_input( INPUT_COOKIE, 'foodord_table_id', FILTER_SANITIZE_FULL_SPECIAL_CHARS );
		$cookie = is_string( $cookie ) ? sanitize_text_field( wp_unslash( $cookie ) ) : '';
		return '' !== $cookie;
	}

	/**
	 * Localize store status payload for a given script handle.
	 *
	 * @param string $handle Script handle.
	 */
	public static function localize_for_handle( string $handle ): void {
		if ( isset( self::$localized_handles[ $handle ] ) ) {
			return;
		}

		// CSS selectors for frontend elements.
		$product_card_selector = '.foodord-product-card';
		$add_to_cart_selector  = '.foodord-product-card .add_to_cart_button';
		$direct_add_selector   = '.foodord-product-card .foodord-direct-add-button';
		$container_selector    = 'body';

		// JavaScript object keys.
		$key_product_card  = 'productCard';
		$key_add_to_cart   = 'addToCartButton';
		$key_direct_add    = 'directAddButton';
		$key_container     = 'containerSelector';
		$key_is_open       = 'isOpen';
		$key_message       = 'message';
		$key_mode_messages = 'modeMessages';
		$key_modes         = 'modes';
		$key_duration      = 'duration';
		$key_selectors     = 'selectors';
		$key_close_label   = 'closeLabel';

		$status_payload = self::get_status_payload();
		$ref_time       = isset( $status_payload['ref_time'] ) ? $status_payload['ref_time'] : '';
		try {
			$reference_now = $ref_time ? new DateTimeImmutable( $ref_time ) : new DateTimeImmutable( 'now', wp_timezone() );
		} catch ( Exception $e ) {
			unset( $e );
			$reference_now = new DateTimeImmutable( 'now', wp_timezone() );
		}
		$mode_messages = self::build_mode_messages( isset( $status_payload['modes'] ) ? $status_payload['modes'] : array(), $reference_now );

		wp_localize_script(
			$handle,
			'FoodordStoreStatus',
			array(
				$key_is_open       => ! empty( $status_payload['is_open'] ),
				$key_message       => __( 'We are currently closed. Please try again later.', 'foodord' ),
				$key_mode_messages => $mode_messages,
				$key_modes         => isset( $status_payload['modes'] ) ? $status_payload['modes'] : array(),
				$key_duration      => apply_filters( 'foodord_closed_toast_duration', 5000 ),
				$key_selectors     => array(
					$key_product_card => $product_card_selector,
					$key_add_to_cart  => $add_to_cart_selector,
					$key_direct_add   => $direct_add_selector,
				),
				$key_container     => $container_selector,
				$key_close_label   => __( 'Close', 'foodord' ),
			)
		);

		self::$localized_handles[ $handle ] = true;
	}

	/**
	 * Ensure the toast script is enqueued and localized.
	 */
	public static function enqueue_toast_script(): void {
		wp_enqueue_script(
			'foodord-store-status',
			FOODORD_URL . 'public/js/foodord-store-status.js',
			array(),
			FOODORD_VERSION,
			true
		);

		self::localize_for_handle( 'foodord-store-status' );
	}

	/**
	 * Evaluate whether a given mode (delivery/pickup) is currently open.
	 *
	 * @param string            $mode Mode key: delivery|pickup.
	 * @param DateTimeImmutable $now  Current time in site timezone.
	 * @return bool
	 */
	private static function is_open_for_mode( string $mode, DateTimeImmutable $now ): bool {
		$state = self::get_mode_state( $mode, $now );
		return ! empty( $state['is_open'] );
	}

	/**
	 * Build a detailed state array for the requested mode.
	 *
	 * @param string            $mode Mode key: delivery|pickup.
	 * @param DateTimeImmutable $now  Current time in site timezone.
	 * @return array
	 */
	private static function get_mode_state( string $mode, DateTimeImmutable $now ): array {
		if ( 'pickup' === $mode ) {
			$hours_option  = 'foodord_pickup_opening_hours';
			$custom_option = 'foodord_pickup_custom_hours';
		} else {
			$hours_option  = 'foodord_opening_hours';
			$custom_option = 'foodord_custom_hours';
		}

		$hours = get_option( $hours_option, self::get_default_hours() );
		if ( ! is_array( $hours ) ) {
			$hours = self::get_default_hours();
		}

		$custom_hours = get_option( $custom_option, array() );
		if ( ! is_array( $custom_hours ) ) {
			$custom_hours = array();
		}

		$regular_open = self::evaluate_hours( $hours, $custom_hours, $now );

		$preorder      = self::get_preorder_status( $mode, $now, $custom_hours );
		$preorder_open = ! empty( $preorder['enabled'] ) && ! empty( $preorder['is_open'] );

		$slots_data = self::compute_mode_slots( $mode, $hours, $custom_hours, $now );

		// Determine if there are any time slots available today.
		$today_entry             = self::get_today_entry( $mode, $hours, $custom_hours, $now );
		$entry_from_previous_day = false;
		if ( $today_entry && isset( $today_entry['__foodord_from_previous_day'] ) ) {
			$entry_from_previous_day = (bool) $today_entry['__foodord_from_previous_day'];
			unset( $today_entry['__foodord_from_previous_day'] );
		}
		$has_slots_today = ! self::entry_is_effectively_closed( $today_entry, $now, $entry_from_previous_day );

		// Check if we're past the last closing time of the day.
		$past_closing = false;
		if ( $has_slots_today && $today_entry && is_array( $today_entry ) ) {
			$last_close_dt = self::last_close_time_from_entry( $today_entry, $now, $entry_from_previous_day );
			if ( $last_close_dt && $now >= $last_close_dt ) {
				$past_closing = true;
			}
		}

		// Keep restaurant "open" during midday breaks if there are slots later today.
		// But close after last closing time.
		$is_open_for_ordering = ( $regular_open || $preorder_open );

		$custom_closed      = ! empty( $preorder['custom_closed'] );
		$custom_description = $preorder['custom_description'] ?? '';

		$lock = self::get_mode_lock( $mode );
		if ( ! empty( $lock['locked'] ) ) {
			$is_open_for_ordering = false;
			$preorder_open        = false;
			$preorder['is_open']  = false;
			$custom_closed        = true;
			if ( ! empty( $lock['reason'] ) ) {
				$custom_description = $lock['reason'];
			}
		}

		$slots_options      = isset( $slots_data['options'] ) && is_array( $slots_data['options'] ) ? array_values( $slots_data['options'] ) : array();
		$next_slot          = isset( $slots_data['next_slot'] ) ? (string) $slots_data['next_slot'] : '';
		$next_start_raw     = isset( $slots_data['next_start'] ) ? (string) $slots_data['next_start'] : '';
		$next_start_dt      = null;
		$preorder_cutoff    = isset( $preorder['cutoff'] ) ? trim( (string) $preorder['cutoff'] ) : '';
		$preorder_cutoff_dt = null;
		$first_open_raw     = isset( $slots_data['opening'] ) ? (string) $slots_data['opening'] : '';
		$first_open_dt      = $first_open_raw ? self::build_time( $first_open_raw, $now ) : null;

		if ( '' !== $next_start_raw ) {
			try {
				$next_start_dt = new DateTimeImmutable( $next_start_raw );
			} catch ( Exception $e ) {
				unset( $e );
			}
		}

		if ( '' !== $preorder_cutoff ) {
			$cutoff_candidate = self::build_time( $preorder_cutoff, $now );
			if ( $cutoff_candidate ) {
				$preorder_cutoff_dt = $cutoff_candidate;
			}
		}

		if ( null === $next_start_dt && '' !== $next_slot ) {
			$slot_candidate = self::build_time( $next_slot, $now );
			if ( $slot_candidate ) {
				if ( $slot_candidate < $now ) {
					$slot_candidate = $slot_candidate->modify( '+1 day' );
				}
				$next_start_dt = $slot_candidate;
			}
		}

		if ( ! $regular_open
			&& empty( $lock['locked'] )
			&& ! $preorder_open
			&& ! empty( $preorder['enabled'] )
			&& ( $preorder_cutoff_dt instanceof DateTimeImmutable ? ( $now >= $preorder_cutoff_dt ) : true )
			&& $next_start_dt instanceof DateTimeImmutable
			&& $now < $next_start_dt
			&& ! $past_closing
		) {
			$preorder_open              = true;
			$preorder['is_open']        = true;
			$preorder['cutoff_reached'] = true;
			$preorder['custom_closed']  = false;
			$custom_closed              = false;
			$custom_description         = $preorder['custom_description'] ?? '';
		}

		if ( ! $is_open_for_ordering && empty( $lock['locked'] ) && ! empty( $slots_options ) && ! empty( $slots_data['enabled'] ) ) {
			if ( $preorder_open ) {
				$is_open_for_ordering = true;
			} elseif ( ! empty( $preorder['enabled'] ) && null === $preorder_cutoff_dt ) {
				$is_open_for_ordering = true;
			} elseif ( $first_open_dt instanceof DateTimeImmutable && $now >= $first_open_dt && ! $past_closing ) {
				$is_open_for_ordering = true;
			} elseif ( $next_start_dt instanceof DateTimeImmutable ) {
				$is_open_for_ordering = ( $now >= $next_start_dt );
			} else {
				$is_open_for_ordering = true;
			}
		}

		return array(
			'is_open'                 => $is_open_for_ordering,
			'regular_open'            => $regular_open,
			'preorder_enabled'        => ! empty( $preorder['enabled'] ),
			'preorder_open'           => $preorder_open,
			'preorder_cutoff'         => $preorder['cutoff'] ?? '',
			'preorder_cutoff_reached' => $preorder['cutoff_reached'] ?? true,
			'custom_closed'           => $custom_closed,
			'custom_description'      => $custom_description,
			'has_slots_today'         => $has_slots_today,
			'manual_locked'           => ! empty( $lock['locked'] ),
			'slots'                   => $slots_options,
			'next_slot'               => $next_slot,
			'slot_next_start'         => $next_start_raw,
			'slot_opening'            => isset( $slots_data['opening'] ) ? (string) $slots_data['opening'] : '',
			'slot_closing'            => isset( $slots_data['closing'] ) ? (string) $slots_data['closing'] : '',
			'slot_breakdown'          => isset( $slots_data['breakdown'] ) ? (int) $slots_data['breakdown'] : 0,
			'slot_average'            => isset( $slots_data['average'] ) ? (int) $slots_data['average'] : 0,
			'slots_enabled'           => isset( $slots_data['enabled'] ) ? (bool) $slots_data['enabled'] : false,
		);
	}

	/**
	 * Evaluate hours using regular and custom definitions.
	 *
	 * @param array             $hours        Weekly hours.
	 * @param array             $custom_hours Custom overrides.
	 * @param DateTimeImmutable $now          Current time.
	 * @return bool
	 */
	private static function evaluate_hours( array $hours, array $custom_hours, DateTimeImmutable $now ): bool {
		$custom_entry = self::find_custom_entry( $custom_hours, $now );
		if ( $custom_entry ) {
			if ( ! empty( $custom_entry['closed'] ) ) {
				return false;
			}
			return self::evaluate_entry( $custom_entry, $now );
		}

		$day_key = strtolower( $now->format( 'l' ) );
		if ( isset( $hours[ $day_key ] ) && is_array( $hours[ $day_key ] ) && self::evaluate_entry( $hours[ $day_key ], $now ) ) {
			return true;
		}

		$yesterday        = $now->modify( '-1 day' );
		$custom_yesterday = self::find_custom_entry( $custom_hours, $yesterday );
		if ( $custom_yesterday && self::evaluate_entry( $custom_yesterday, $now, true ) ) {
			return true;
		}

		$yesterday_key = strtolower( $yesterday->format( 'l' ) );
		if ( isset( $hours[ $yesterday_key ] ) && is_array( $hours[ $yesterday_key ] ) && self::evaluate_entry( $hours[ $yesterday_key ], $now, true ) ) {
			return true;
		}

		return false;
	}

	/**
	 * Find custom hours entry for the current date.
	 *
	 * @param array             $custom_hours Custom overrides.
	 * @param DateTimeImmutable $now          Current time.
	 * @return array|null
	 */
	private static function find_custom_entry( array $custom_hours, DateTimeImmutable $now ) {
		$today = $now->format( 'Y-m-d' );
		foreach ( $custom_hours as $entry ) {
			if ( ! is_array( $entry ) || empty( $entry['date'] ) ) {
				continue;
			}
			if ( $entry['date'] === $today ) {
				return $entry;
			}
		}

		return null;
	}

	/**
	 * Get today's hours entry (custom or regular) for a mode.
	 *
	 * @param string            $mode         Mode key: delivery|pickup.
	 * @param array             $hours        Regular hours.
	 * @param array             $custom_hours Custom hours.
	 * @param DateTimeImmutable $now          Current time.
	 * @return array|null
	 */
	private static function get_today_entry( string $mode, array $hours, array $custom_hours, DateTimeImmutable $now ) {
		unset( $mode );

		$custom_entry = self::find_custom_entry( $custom_hours, $now );
		if ( $custom_entry ) {
			$entry                                = $custom_entry;
			$entry['__foodord_from_previous_day'] = false;
			return $entry;
		}

		$day_key = strtolower( $now->format( 'l' ) );
		if ( isset( $hours[ $day_key ] ) && is_array( $hours[ $day_key ] ) ) {
			$entry = $hours[ $day_key ];
			if ( ! empty( $entry ) && empty( $entry['closed'] ) ) {
				$entry['__foodord_from_previous_day'] = false;
				return $entry;
			}
		}

		$yesterday   = $now->modify( '-1 day' );
		$custom_prev = self::find_custom_entry( $custom_hours, $yesterday );
		if ( $custom_prev && empty( $custom_prev['closed'] ) && ! self::entry_is_effectively_closed( $custom_prev, $now, true ) ) {
			$entry                                = $custom_prev;
			$entry['__foodord_from_previous_day'] = true;
			return $entry;
		}

		$prev_key = strtolower( $yesterday->format( 'l' ) );
		if ( isset( $hours[ $prev_key ] ) && is_array( $hours[ $prev_key ] ) ) {
			$entry = $hours[ $prev_key ];
			if ( ! empty( $entry ) && empty( $entry['closed'] ) && ! self::entry_is_effectively_closed( $entry, $now, true ) ) {
				$entry['__foodord_from_previous_day'] = true;
				return $entry;
			}
		}

		return null;
	}

	/**
	 * Get earliest opening time from an hours entry.
	 *
	 * @param array $entry Hours entry.
	 * @return string Earliest HH:MM time or empty string.
	 */
	private static function first_open_time_from_entry( array $entry ): string {
		$candidates = array(
			$entry['morning_start'] ?? '',
			$entry['evening_start'] ?? '',
		);
		foreach ( $candidates as $time_string ) {
			if ( is_string( $time_string ) && preg_match( '/^\d{2}:\d{2}$/', $time_string ) ) {
				return $time_string;
			}
		}

		return '';
	}

	/**
	 * Get the next opening segment start from an entry.
	 *
	 * @param array             $entry Hours entry.
	 * @param DateTimeImmutable $now   Reference time.
	 * @return DateTimeImmutable|null Next opening moment.
	 */
	private static function next_open_start_from_entry( array $entry, DateTimeImmutable $now ) {
		foreach ( self::build_entry_periods( $entry, $now, false ) as $period ) {
			if ( $period['start'] > $now ) {
				return $period['start'];
			}
		}

		return null;
	}

	/**
	 * Get latest closing time from an hours entry.
	 *
	 * @param array             $entry             Hours entry.
	 * @param DateTimeImmutable $now               Reference time.
	 * @param bool              $from_previous_day Whether the entry belongs to the previous day.
	 * @return DateTimeImmutable|null Latest closing moment or null if none.
	 */
	private static function last_close_time_from_entry( array $entry, DateTimeImmutable $now, bool $from_previous_day = false ) {
		$periods = self::build_entry_periods( $entry, $now, $from_previous_day );
		$latest  = null;

		foreach ( $periods as $period ) {
			if ( null === $latest || $period['end'] > $latest ) {
				$latest = $period['end'];
			}
		}

		return $latest;
	}

	/**
	 * Evaluate a single entry (day/custom) to determine if open.
	 *
	 * @param array             $entry             Entry data.
	 * @param DateTimeImmutable $now               Current time.
	 * @param bool              $from_previous_day Whether the entry belongs to the previous day.
	 * @return bool
	 */
	private static function evaluate_entry( array $entry, DateTimeImmutable $now, bool $from_previous_day = false ): bool {
		$periods = self::build_entry_periods( $entry, $now, $from_previous_day );

		foreach ( $periods as $period ) {
			if ( $now >= $period['start'] && $now < $period['end'] ) {
				return true;
			}
		}

		return false;
	}

	/**
	 * Determine whether an entry should be treated as closed for the day.
	 *
	 * @param array|array<string, mixed>|null $entry             Entry data.
	 * @param DateTimeImmutable               $now               Current time.
	 * @param bool                            $from_previous_day Whether the entry belongs to the previous day.
	 * @return bool
	 */
	private static function entry_is_effectively_closed( $entry, DateTimeImmutable $now, bool $from_previous_day = false ): bool {
		if ( empty( $entry ) || ! is_array( $entry ) ) {
			return true;
		}

		if ( isset( $entry['__foodord_from_previous_day'] ) ) {
			unset( $entry['__foodord_from_previous_day'] );
		}

		$periods = self::build_entry_periods( $entry, $now, $from_previous_day );

		if ( empty( $periods ) ) {
			return true;
		}

		foreach ( $periods as $period ) {
			if ( $period['end'] > $now ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Normalize valid time periods for an entry relative to the current moment.
	 *
	 * @param array             $entry             Entry data.
	 * @param DateTimeImmutable $now               Current time.
	 * @param bool              $from_previous_day Whether the entry belongs to the previous day.
	 * @return array<int, array{start: DateTimeImmutable, end: DateTimeImmutable}>
	 */
	private static function build_entry_periods( array $entry, DateTimeImmutable $now, bool $from_previous_day = false ): array {
		if ( empty( $entry ) || ! is_array( $entry ) || ! empty( $entry['closed'] ) ) {
			return array();
		}

		$reference = $from_previous_day ? $now->modify( '-1 day' ) : $now;
		$slots     = array(
			array(
				'from' => $entry['morning_start'] ?? '',
				'to'   => $entry['morning_end'] ?? '',
			),
			array(
				'from' => $entry['evening_start'] ?? '',
				'to'   => $entry['evening_end'] ?? '',
			),
		);

		$periods = array();

		foreach ( $slots as $slot ) {
			$start_time = is_string( $slot['from'] ) ? $slot['from'] : '';
			$end_time   = is_string( $slot['to'] ) ? $slot['to'] : '';
			$start      = self::build_time( $start_time, $reference );

			if ( ! $start ) {
				continue;
			}

			$overnight     = self::is_overnight_time_range( $start_time, $end_time );
			$end_reference = $overnight ? $reference->modify( '+1 day' ) : $reference;
			$end           = self::build_time( $end_time, $end_reference );

			if ( ! $end ) {
				continue;
			}

			if ( ! $overnight && $end <= $start ) {
				continue;
			}

			$periods[] = array(
				'start' => $start,
				'end'   => $end,
			);
		}

		return $periods;
	}

	/**
	 * Retrieve merged time slot data for a mode.
	 *
	 * @param string                 $mode Mode key.
	 * @param DateTimeImmutable|null $reference Reference time (defaults to now in site timezone).
	 * @return array{enabled: bool, options: array<int, string>, opening: string, closing: string, breakdown: int, average: int, next_slot: string, next_start: string}
	 */
	public static function get_mode_slots( string $mode, ?DateTimeImmutable $reference = null, bool $include_previous_day_spillover = true ): array {
		$normalized = ( 'pickup' === $mode ) ? 'pickup' : 'delivery';
		if ( null === $reference ) {
			$reference = new DateTimeImmutable( 'now', wp_timezone() );
		}

		if ( 'pickup' === $normalized ) {
			$hours_option  = 'foodord_pickup_opening_hours';
			$custom_option = 'foodord_pickup_custom_hours';
		} else {
			$hours_option  = 'foodord_opening_hours';
			$custom_option = 'foodord_custom_hours';
		}

		$hours = get_option( $hours_option, self::get_default_hours() );
		if ( ! is_array( $hours ) ) {
			$hours = self::get_default_hours();
		}

		$custom_hours = get_option( $custom_option, array() );
		if ( ! is_array( $custom_hours ) ) {
			$custom_hours = array();
		}

		return self::compute_mode_slots( $normalized, $hours, $custom_hours, $reference, $include_previous_day_spillover );
	}

	/**
	 * Internal helper to compute slot payload for a mode using provided hours.
	 *
	 * @param string            $mode         Mode key.
	 * @param array             $hours        Regular hours.
	 * @param array             $custom_hours Custom hours.
	 * @param DateTimeImmutable $now          Reference time.
	 * @return array{enabled: bool, options: array<int, string>, opening: string, closing: string, breakdown: int, average: int, next_slot: string, next_start: string}
	 */
	private static function compute_mode_slots( string $mode, array $hours, array $custom_hours, DateTimeImmutable $now, bool $include_previous_day_spillover = true ): array {
		$config = self::get_slot_configuration( $mode );
		$tz     = $now->getTimezone();

		if ( ! $config['enabled'] ) {
			return array(
				'enabled'   => false,
				'options'   => array(),
				'opening'   => '',
				'closing'   => '',
				'breakdown' => $config['breakdown'],
				'average'   => $config['average'],
				'next_slot' => '',
			);
		}

		$segments = self::collect_segments_for_mode( $mode, $hours, $custom_hours, $now, $include_previous_day_spillover );
		if ( empty( $segments ) ) {
			return array(
				'enabled'   => true,
				'options'   => array(),
				'opening'   => '',
				'closing'   => '',
				'breakdown' => $config['breakdown'],
				'average'   => $config['average'],
				'next_slot' => '',
			);
		}

		$opening_ts = $segments[0]['start']->getTimestamp();
		if ( $opening_ts < $now->getTimestamp() ) {
			$opening_ts = $now->getTimestamp();
		}
		$closing_ts = $segments[ count( $segments ) - 1 ]['end']->getTimestamp();

		$options_map       = array();
		$now_ts            = $now->getTimestamp();
		$average_seconds   = max( 0, (int) $config['average'] ) * MINUTE_IN_SECONDS;
		$increment_seconds = max( 1, (int) $config['breakdown'] ) * MINUTE_IN_SECONDS;
		$next_start_ts     = null;

		foreach ( $segments as $segment ) {
			$segment_start_ts = $segment['start']->getTimestamp();
			$segment_end_ts   = $segment['end']->getTimestamp();

			if ( $segment_end_ts <= $now_ts ) {
				continue;
			}

			if ( null === $next_start_ts ) {
				if ( $now_ts < $segment_start_ts ) {
					$next_start_ts = $segment_start_ts;
				} elseif ( $now_ts >= $segment_start_ts && $now_ts < $segment_end_ts ) {
					$next_start_ts = $now_ts;
				}
			}

			$base_ts = $segment_start_ts + $average_seconds;
			if ( $base_ts < $segment_start_ts ) {
				$base_ts = $segment_start_ts;
			}

			$desired_ts = $now_ts + $average_seconds;
			if ( $base_ts < $desired_ts ) {
				$difference = $desired_ts - $base_ts;
				$steps      = (int) ceil( $difference / $increment_seconds );
				$base_ts   += $steps * $increment_seconds;
			}

			if ( $base_ts > $segment_end_ts ) {
				if ( $segment_end_ts >= $now_ts ) {
					$options_map[ $segment_end_ts ] = wp_date( 'H:i', $segment_end_ts, $tz );
				}
				continue;
			}

			for ( $cursor_ts = $base_ts; $cursor_ts <= $segment_end_ts; $cursor_ts += $increment_seconds ) {
				if ( $cursor_ts < $now_ts ) {
					continue;
				}
				$options_map[ $cursor_ts ] = wp_date( 'H:i', $cursor_ts, $tz );
			}

			if ( $segment_end_ts >= $now_ts ) {
				$options_map[ $segment_end_ts ] = wp_date( 'H:i', $segment_end_ts, $tz );
			}
		}

		ksort( $options_map );
		$options   = array_values( array_unique( $options_map ) );
		$next_slot = $options ? (string) $options[0] : '';

		if ( null === $next_start_ts && ! empty( $segments ) ) {
			$next_start_ts = $segments[0]['start']->getTimestamp();
		}

		$next_start = '';
		if ( null !== $next_start_ts ) {
			$next_start = wp_date( DATE_ATOM, $next_start_ts, $tz );
		}

		return array(
			'enabled'    => true,
			'options'    => $options,
			'opening'    => wp_date( 'H:i', $opening_ts, $tz ),
			'closing'    => wp_date( 'H:i', $closing_ts, $tz ),
			'breakdown'  => $config['breakdown'],
			'average'    => $config['average'],
			'next_slot'  => $next_slot,
			'next_start' => $next_start,
		);
	}

	/**
	 * Assemble segments for the given mode, including spillover from the previous day.
	 *
	 * @param string            $mode         Mode key.
	 * @param array             $hours        Regular hours.
	 * @param array             $custom_hours Custom hours.
	 * @param DateTimeImmutable $now          Reference time.
	 * @return array<int, array{start: DateTimeImmutable, end: DateTimeImmutable}>
	 */
	private static function collect_segments_for_mode( string $mode, array $hours, array $custom_hours, DateTimeImmutable $now, bool $include_previous_day_spillover = true ): array {
		unset( $mode );

		$segments = array();

		$today_entry = self::get_entry_for_day( $hours, $custom_hours, $now );
		if ( $today_entry && empty( $today_entry['closed'] ) ) {
			foreach ( self::build_entry_periods( $today_entry, $now, false ) as $period ) {
				if ( $period['end'] > $now ) {
					$segments[] = $period;
				}
			}
		}

		if ( $include_previous_day_spillover ) {
			$yesterday  = $now->modify( '-1 day' );
			$prev_entry = self::get_entry_for_day( $hours, $custom_hours, $yesterday );
			if ( $prev_entry && empty( $prev_entry['closed'] ) ) {
				foreach ( self::build_entry_periods( $prev_entry, $now, true ) as $period ) {
					if ( $period['end'] > $now ) {
						$segments[] = $period;
					}
				}
			}
		}

		usort(
			$segments,
			function ( $a, $b ) {
				return $a['start'] <=> $b['start'];
			}
		);

		return $segments;
	}

	/**
	 * Locate the entry (custom or regular) for a specific calendar day.
	 *
	 * @param array             $hours        Regular hours map.
	 * @param array             $custom_hours Custom hours list.
	 * @param DateTimeImmutable $day          Target day.
	 * @return array|null Entry array or null when not found.
	 */
	private static function get_entry_for_day( array $hours, array $custom_hours, DateTimeImmutable $day ) {
		$date = $day->format( 'Y-m-d' );
		foreach ( $custom_hours as $entry ) {
			if ( ! is_array( $entry ) || empty( $entry['date'] ) ) {
				continue;
			}
			if ( $entry['date'] === $date ) {
				return $entry;
			}
		}

		$day_key = strtolower( $day->format( 'l' ) );
		if ( isset( $hours[ $day_key ] ) && is_array( $hours[ $day_key ] ) ) {
			return $hours[ $day_key ];
		}

		return null;
	}

	/**
	 * Check whether a HH:MM range crosses midnight using configured wall-clock values.
	 *
	 * @param string $start_time Start time.
	 * @param string $end_time   End time.
	 * @return bool
	 */
	private static function is_overnight_time_range( string $start_time, string $end_time ): bool {
		$start_minutes = self::time_string_to_minutes( $start_time );
		$end_minutes   = self::time_string_to_minutes( $end_time );

		if ( null === $start_minutes || null === $end_minutes ) {
			return false;
		}

		return $end_minutes <= $start_minutes;
	}

	/**
	 * Convert a HH:MM value into wall-clock minutes.
	 *
	 * @param string $time_string Time string.
	 * @return int|null
	 */
	private static function time_string_to_minutes( string $time_string ): ?int {
		if ( ! preg_match( '/^\d{2}:\d{2}$/', $time_string ) ) {
			return null;
		}

		list( $hour, $minute ) = array_map( 'intval', explode( ':', $time_string ) );

		return ( $hour * 60 ) + $minute;
	}

	/**
	 * Gather slot configuration for a mode.
	 *
	 * @param string $mode Mode key.
	 * @return array{enabled: bool, breakdown: int, average: int}
	 */
	private static function get_slot_configuration( string $mode ): array {
		$normalized = ( 'pickup' === $mode ) ? 'pickup' : 'delivery';

		if ( 'pickup' === $normalized ) {
			$enabled   = ( 'yes' === get_option( 'foodord_time_to_pickup_enabled', 'no' ) );
			$breakdown = (int) get_option( 'foodord_time_to_pickup_breakdown', '30' );
			$average   = (int) get_option( 'foodord_average_pickup_time', 20 );
		} else {
			$enabled   = ( 'yes' === get_option( 'foodord_time_to_deliver_enabled', 'no' ) );
			$breakdown = (int) get_option( 'foodord_time_to_deliver_breakdown', '30' );
			$average   = (int) get_option( 'foodord_average_delivery_time', 30 );
		}

		if ( $breakdown < 1 ) {
			$breakdown = 30;
		}

		if ( $average < 0 ) {
			$average = 0;
		}

		return array(
			'enabled'   => $enabled,
			'breakdown' => $breakdown,
			'average'   => $average,
		);
	}

	/**
	 * Determine if pre-orders allow placing orders for the given mode.
	 *
	 * @param string            $mode Mode key: delivery|pickup.
	 * @param DateTimeImmutable $now  Current time in site timezone.
	 * @return bool
	 */
	private static function is_preorder_available( string $mode, DateTimeImmutable $now ): bool {
		$preorder = self::get_preorder_status( $mode, $now, null );
		return ! empty( $preorder['enabled'] ) && ! empty( $preorder['is_open'] );
	}

	/**
	 * Retrieve preorder availability metadata for the given mode.
	 *
	 * @param string            $mode         Mode key: delivery|pickup.
	 * @param DateTimeImmutable $now          Current time in site timezone.
	 * @param array|null        $custom_hours Custom hours array override.
	 * @return array
	 */
	private static function get_preorder_status( string $mode, DateTimeImmutable $now, ?array $custom_hours = null ): array {
		$enabled_option = 'foodord_preorder_enabled_' . $mode;
		$time_option    = 'foodord_preorder_time_' . $mode;

		$enabled_value = get_option( $enabled_option, 'no' );
		$enabled       = self::option_yes( $enabled_value );

		$hours_option = ( 'pickup' === $mode ) ? 'foodord_pickup_opening_hours' : 'foodord_opening_hours';
		$hours        = get_option( $hours_option, self::get_default_hours() );
		if ( ! is_array( $hours ) ) {
			$hours = self::get_default_hours();
		}

		$day_key        = strtolower( $now->format( 'l' ) );
		$regular_entry  = isset( $hours[ $day_key ] ) && is_array( $hours[ $day_key ] ) ? $hours[ $day_key ] : array();
		$day_is_closed  = self::entry_is_effectively_closed( $regular_entry, $now, false );
		$custom_applied = false;

		if ( null === $custom_hours ) {
			if ( 'pickup' === $mode ) {
				$custom_hours = get_option( 'foodord_pickup_custom_hours', array() );
			} else {
				$custom_hours = get_option( 'foodord_custom_hours', array() );
			}
			if ( ! is_array( $custom_hours ) ) {
				$custom_hours = array();
			}
		}

		$window_string      = '';
		$window_time        = null;
		$custom_closed      = false;
		$custom_description = '';
		$custom_entry       = self::find_custom_entry( $custom_hours, $now );
		if ( $custom_entry ) {
			$custom_applied     = true;
			$custom_description = is_string( $custom_entry['description'] ?? '' ) ? $custom_entry['description'] : '';
			$custom_description = self::normalize_custom_description( $custom_description );
			if ( ! empty( $custom_entry['closed'] ) ) {
				$enabled       = false;
				$custom_closed = true;
				$window_string = '';
				$window_time   = null;
			} else {
				$entry_cutoff = self::first_open_time_from_entry( $custom_entry );
				if ( $entry_cutoff ) {
					$window_string = $entry_cutoff;
					$window_time   = self::build_time( $window_string, $now );
				}
			}
		}

		if ( ! $custom_applied && $day_is_closed ) {
			$enabled = false;
		}

		if ( $enabled ) {
			$raw_cutoff = get_option( $time_option, '' );
			if ( is_string( $raw_cutoff ) ) {
				$raw_cutoff = trim( $raw_cutoff );
				if ( '' !== $raw_cutoff ) {
					$window_string = $raw_cutoff;
					$window_time   = self::build_time( $window_string, $now );
					if ( ! $window_time ) {
						$window_string = '';
					}
				}
			}
		}

		$window_started = false;
		if ( $enabled && ! $custom_closed ) {
			if ( $window_time ) {
				// Preorder window is active from cutoff time until the next opening segment.
				$cutoff_reached = ( $now >= $window_time );
				$active_entry    = ( $custom_applied && ! $custom_closed && is_array( $custom_entry ) ) ? $custom_entry : $regular_entry;
				$next_open_dt   = self::next_open_start_from_entry( $active_entry, $now );
				if ( $next_open_dt ) {
					$window_started = $cutoff_reached && ( $now < $next_open_dt );
				} else {
					// No upcoming opening segment today, preorder not available.
					$window_started = false;
				}
			} else {
				// If no preorder cutoff time is set, window should NOT open automatically.
				$window_started = false;
			}
		}

		$is_open = $enabled && ! $custom_closed && $window_started;

		return array(
			'enabled'            => $enabled,
			'is_open'            => $is_open,
			'cutoff'             => $window_string,
			'cutoff_reached'     => $window_started,
			'custom_closed'      => $custom_closed,
			'custom_description' => $custom_description,
		);
	}

	/**
	 * Build localized messages for each mode considering custom hours and pre-order state.
	 *
	 * @param array             $modes Mode state payloads.
	 * @param DateTimeImmutable $now   Reference time.
	 * @return array
	 */
	private static function build_mode_messages( array $modes, DateTimeImmutable $now ): array {
		$messages   = array();
		$all_closed = true;
		foreach ( $modes as $mode => $state ) {
			if ( ! is_array( $state ) ) {
				continue;
			}
			if ( ! empty( $state['is_open'] ) ) {
				$all_closed = false;
			}
			$messages[ $mode ] = self::determine_mode_message( (string) $mode, $state, $now );
		}

		if ( $all_closed && ! empty( $messages ) ) {
			$general_message = __( 'We are currently closed. Please try again later.', 'foodord' );
			foreach ( $messages as $mode => $_message ) {
				$messages[ $mode ] = $general_message;
			}
		}

		return $messages;
	}

	/**
	 * Determine the toast message for a mode.
	 *
	 * @param string            $mode  Mode key.
	 * @param array             $state Mode state data.
	 * @param DateTimeImmutable $now   Reference time.
	 * @return string
	 */
	private static function determine_mode_message( string $mode, array $state, DateTimeImmutable $now ): string {
		$label          = self::get_mode_label( $mode );
		$sentence_label = ucfirst( $label );

		if ( ! empty( $state['custom_closed'] ) ) {
			return self::get_unavailable_mode_message( $mode );
		}

		if ( empty( $state['preorder_enabled'] ) ) {
			return self::get_currently_closed_mode_message( $mode );
		}

		$cutoff            = isset( $state['preorder_cutoff'] ) ? $state['preorder_cutoff'] : '';
		$cutoff_reached    = isset( $state['preorder_cutoff_reached'] ) ? (bool) $state['preorder_cutoff_reached'] : true;
		$custom_descriptor = isset( $state['custom_description'] ) ? $state['custom_description'] : '';
		$custom_descriptor = self::normalize_custom_description( $custom_descriptor );

		if ( $cutoff && ! $cutoff_reached ) {
			$time_display = self::format_time_for_display( $cutoff, $now );
			if ( $custom_descriptor ) {
				return self::get_preorder_open_message( $mode, $time_display, $custom_descriptor );
			}
			return self::get_preorder_open_message( $mode, $time_display );
		}

		// translators: %s: order mode (delivery|pickup).
		return sprintf( __( '%s pre-orders are temporarily unavailable. Please try again later.', 'foodord' ), esc_html( $sentence_label ) );
	}

	/**
	 * Get a fully translatable pre-order open message for an order mode.
	 *
	 * @param string $mode        Mode key.
	 * @param string $time        Formatted opening time.
	 * @param string $description Optional custom hours description.
	 * @return string
	 */
	private static function get_preorder_open_message( string $mode, string $time, string $description = '' ): string {
		$description = self::normalize_custom_description( $description );

		if ( '' !== $description ) {
			if ( 'pickup' === $mode ) {
				// translators: 1: time, 2: custom hours description.
				return sprintf( __( 'Pickup pre-orders open at %1$s (%2$s).', 'foodord' ), esc_html( $time ), esc_html( $description ) );
			}

			// translators: 1: time, 2: custom hours description.
			return sprintf( __( 'Delivery pre-orders open at %1$s (%2$s).', 'foodord' ), esc_html( $time ), esc_html( $description ) );
		}

		if ( 'pickup' === $mode ) {
			// translators: %s: time.
			return sprintf( __( 'Pickup pre-orders open at %s.', 'foodord' ), esc_html( $time ) );
		}

		// translators: %s: time.
		return sprintf( __( 'Delivery pre-orders open at %s.', 'foodord' ), esc_html( $time ) );
	}

	/**
	 * Get a fully translatable unavailable message for an order mode.
	 *
	 * @param string $mode Mode key.
	 * @return string
	 */
	private static function get_unavailable_mode_message( string $mode ): string {
		if ( 'pickup' === $mode ) {
			return __( 'Pickup is unavailable. Please try again later.', 'foodord' );
		}

		return __( 'Delivery is unavailable. Please try again later.', 'foodord' );
	}

	/**
	 * Format a HH:MM string respecting site timezone.
	 *
	 * @param string            $time_string Time string.
	 * @param DateTimeImmutable $reference   Reference time.
	 * @return string
	 */
	private static function format_time_for_display( string $time_string, DateTimeImmutable $reference ): string {
		$time = self::build_time( $time_string, $reference );
		if ( $time ) {
			return wp_date( 'H:i', $time->getTimestamp(), wp_timezone() );
		}

		return $time_string;
	}

	/**
	 * Human-readable label for order modes.
	 *
	 * @param string $mode Mode key.
	 * @return string
	 */
	private static function get_mode_label( string $mode ): string {
		return ( 'pickup' === $mode ) ? __( 'pickup', 'foodord' ) : __( 'delivery', 'foodord' );
	}

	/**
	 * Get a fully translatable closed message for an order mode.
	 *
	 * @param string $mode Mode key.
	 * @return string
	 */
	private static function get_currently_closed_mode_message( string $mode ): string {
		if ( 'pickup' === $mode ) {
			return __( 'We are currently closed for pickup. Please try again later.', 'foodord' );
		}

		return __( 'We are currently closed for delivery. Please try again later.', 'foodord' );
	}

	/**
	 * Normalize custom hour description values (avoid raw dates).
	 *
	 * @param string $description Raw description.
	 * @return string
	 */
	private static function normalize_custom_description( string $description ): string {
		$description = trim( $description );
		if ( preg_match( '/^\d{4}-\d{2}-\d{2}$/', $description ) ) {
			return '';
		}
		return $description;
	}

	/**
	 * Normalize option boolean values stored as yes/no or 1/0.
	 *
	 * @param mixed $value Raw option value.
	 * @return bool
	 */
	private static function option_yes( $value ): bool {
		if ( true === $value ) {
			return true;
		}
		if ( is_string( $value ) ) {
			$value = strtolower( $value );
			return in_array( $value, array( 'yes', '1', 'true', 'on' ), true );
		}
		if ( is_numeric( $value ) ) {
			return 1 === (int) $value;
		}

		return false;
	}

	/**
	 * Convert HH:MM string to DateTimeImmutable using today's date.
	 *
	 * @param string            $time_string Time string.
	 * @param DateTimeImmutable $reference   Reference date.
	 * @return DateTimeImmutable|null
	 */
	private static function build_time( $time_string, DateTimeImmutable $reference ) {
		if ( ! is_string( $time_string ) || ! preg_match( '/^\d{2}:\d{2}$/', $time_string ) ) {
			return null;
		}

		list( $hour, $minute ) = array_map( 'intval', explode( ':', $time_string ) );

		try {
			return $reference->setTime( $hour, $minute, 0 );
		} catch ( Exception $e ) {
			unset( $e );
			// Fall through and return null for invalid times.
		}

		return null;
	}

	/**
	 * Default weekly hours fallback.
	 *
	 * @return array
	 */
	private static function get_default_hours(): array {
		return array(
			'monday'    => array(
				'closed'        => false,
				'morning_start' => '10:00',
				'morning_end'   => '14:00',
				'evening_start' => '16:00',
				'evening_end'   => '22:00',
			),
			'tuesday'   => array(
				'closed'        => false,
				'morning_start' => '10:00',
				'morning_end'   => '14:00',
				'evening_start' => '16:00',
				'evening_end'   => '22:00',
			),
			'wednesday' => array(
				'closed'        => false,
				'morning_start' => '10:00',
				'morning_end'   => '14:00',
				'evening_start' => '16:00',
				'evening_end'   => '22:00',
			),
			'thursday'  => array(
				'closed'        => false,
				'morning_start' => '10:00',
				'morning_end'   => '14:00',
				'evening_start' => '16:00',
				'evening_end'   => '22:00',
			),
			'friday'    => array(
				'closed'        => false,
				'morning_start' => '10:00',
				'morning_end'   => '14:00',
				'evening_start' => '16:00',
				'evening_end'   => '22:00',
			),
			'saturday'  => array(
				'closed'        => false,
				'morning_start' => '10:00',
				'morning_end'   => '14:00',
				'evening_start' => '16:00',
				'evening_end'   => '22:00',
			),
			'sunday'    => array(
				'closed'        => true,
				'morning_start' => '',
				'morning_end'   => '',
				'evening_start' => '',
				'evening_end'   => '',
			),
		);
	}
}
