<?php
/**
 * Timed Products core service.
 *
 * @package FoodOrd
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class Foodord_Timed_Products
 */
class Foodord_Timed_Products {
	/**
	 * Custom post type slug.
	 */
	public const POST_TYPE = 'foodord_timed';

	private const SCHEDULE_TYPES = array(
		'on_date',
		'before_date',
		'after_date',
		'date_range',
		'days_of_week',
		'time_range',
	);

	private const META_ACTION            = '_foodord_timed_action';
	private const META_SCHEDULE          = '_foodord_timed_schedule';
	private const META_CONDITION         = '_foodord_timed_conditions';
	private const META_ORDER_MODE        = '_foodord_timed_order_mode';
	private const META_HIDDEN_BY_DEFAULT = '_foodord_timed_hidden_by_default';
	private const CACHE_GROUP            = 'foodord';
	private const CACHE_KEY_RULES        = 'foodord_timed_rules';

	/**
	 * Cached rule list.
	 *
	 * @var array<int,array<string,mixed>>|null
	 */
	private static $rules_cache = null;

	/**
	 * Product allowance cache for current request.
	 *
	 * @var array<string,bool>
	 */
	private static $product_cache = array();

	/**
	 * Bootstrap hooks.
	 */
	public static function init(): void {
		add_action( 'init', array( __CLASS__, 'register_post_type' ) );
		add_action( 'save_post_' . self::POST_TYPE, array( __CLASS__, 'handle_post_change' ), 10, 2 );
		add_action( 'deleted_post', array( __CLASS__, 'handle_deleted_post' ), 10, 1 );
		add_action( 'trashed_post', array( __CLASS__, 'handle_trashed_post' ), 10, 1 );
		add_action( 'untrashed_post', array( __CLASS__, 'handle_untrashed_post' ), 10, 1 );
	}

	/**
	 * Register the Timed Products custom post type.
	 */
	public static function register_post_type(): void {
		$labels = array(
			'name'                  => _x( 'Timed Products', 'post type general name', 'foodord' ),
			'singular_name'         => _x( 'Timed Products Rule', 'post type singular name', 'foodord' ),
			'menu_name'             => __( 'Timed Products', 'foodord' ),
			'name_admin_bar'        => __( 'Timed Product', 'foodord' ),
			'add_new'               => __( 'Add New', 'foodord' ),
			'add_new_item'          => __( 'Add New Timed Product Rule', 'foodord' ),
			'edit_item'             => __( 'Edit Timed Products Rule', 'foodord' ),
			'new_item'              => __( 'New Timed Products Rule', 'foodord' ),
			'view_item'             => __( 'View Timed Products Rule', 'foodord' ),
			'all_items'             => __( 'Timed Products Rules', 'foodord' ),
			'search_items'          => __( 'Search Timed Products Rules', 'foodord' ),
			'not_found'             => __( 'No timed products rules found.', 'foodord' ),
			'not_found_in_trash'    => __( 'No timed products rules found in Trash.', 'foodord' ),
			'parent_item_colon'     => __( 'Parent Timed Product:', 'foodord' ),
			'featured_image'        => __( 'Rule image', 'foodord' ),
			'set_featured_image'    => __( 'Set rule image', 'foodord' ),
			'remove_featured_image' => __( 'Remove rule image', 'foodord' ),
			'use_featured_image'    => __( 'Use as rule image', 'foodord' ),
			'archives'              => __( 'Timed Products archives', 'foodord' ),
			'insert_into_item'      => __( 'Insert into rule', 'foodord' ),
			'uploaded_to_this_item' => __( 'Uploaded to this rule', 'foodord' ),
		);

		$args = array(
			'labels'              => $labels,
			'public'              => false,
			'show_ui'             => true,
			'show_in_menu'        => false,
			'exclude_from_search' => true,
			'publicly_queryable'  => false,
			'show_in_nav_menus'   => false,
			'supports'            => array( 'title', 'page-attributes' ),
			'capability_type'     => 'post',
			'has_archive'         => false,
			'rewrite'             => false,
		);

		register_post_type( self::POST_TYPE, $args );
	}

	/**
	 * Ensure cache invalidation when rule changes.
	 *
	 * @param int     $post_id Post ID.
	 * @param WP_Post $post    Post object.
	 */
	public static function handle_post_change( int $post_id, $post ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName
		if ( ! $post instanceof WP_Post ) {
			return;
		}

		if ( self::POST_TYPE !== $post->post_type ) {
			return;
		}

		self::flush_cache();
	}

	/**
	 * Flush cache after delete.
	 *
	 * @param int $post_id Post ID.
	 */
	public static function handle_deleted_post( int $post_id ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName
		self::maybe_flush_by_post( $post_id );
	}

	/**
	 * Flush cache when trashed.
	 *
	 * @param int $post_id Post ID.
	 */
	public static function handle_trashed_post( int $post_id ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName
		self::maybe_flush_by_post( $post_id );
	}

	/**
	 * Flush cache when untrashed.
	 *
	 * @param int $post_id Post ID.
	 */
	public static function handle_untrashed_post( int $post_id ): void { // phpcs:ignore WordPress.NamingConventions.ValidFunctionName
		self::maybe_flush_by_post( $post_id );
	}

	/**
	 * Flush cache when the provided ID matches CPT.
	 *
	 * @param int $post_id Post ID.
	 */
	private static function maybe_flush_by_post( int $post_id ): void {
		$post = get_post( $post_id );
		if ( ! $post instanceof WP_Post ) {
			return;
		}

		if ( self::POST_TYPE !== $post->post_type ) {
			return;
		}

		self::flush_cache();
	}

	/**
	 * Flush cached rules + evaluation state.
	 */
	public static function flush_cache(): void {
		self::$rules_cache   = null;
		self::$product_cache = array();
		wp_cache_delete( self::CACHE_KEY_RULES, self::CACHE_GROUP );
	}

	/**
	 * Retrieve active published rules.
	 *
	 * @return array<int,array<string,mixed>>
	 */
	public static function get_active_rules(): array {
		if ( null !== self::$rules_cache ) {
			return self::$rules_cache;
		}

		$cached = wp_cache_get( self::CACHE_KEY_RULES, self::CACHE_GROUP );
		if ( false !== $cached && is_array( $cached ) ) {
			self::$rules_cache = $cached;
			return $cached;
		}

		$query = new WP_Query(
			array(
				'post_type'      => self::POST_TYPE,
				'post_status'    => 'publish',
				'posts_per_page' => -1,
				'orderby'        => array(
					'menu_order' => 'ASC',
					'date'       => 'ASC',
				),
			)
		);

		$rules = array();
		if ( $query->have_posts() ) {
			foreach ( $query->posts as $post ) {
				if ( ! $post instanceof WP_Post ) {
					continue;
				}

				$action            = self::sanitize_action( get_post_meta( $post->ID, self::META_ACTION, true ) );
				$schedule          = self::sanitize_schedule( get_post_meta( $post->ID, self::META_SCHEDULE, true ) );
				$conditions        = self::sanitize_conditions( get_post_meta( $post->ID, self::META_CONDITION, true ) );
				$order_mode        = self::sanitize_order_mode( get_post_meta( $post->ID, self::META_ORDER_MODE, true ) );
				$hidden_by_default = self::sanitize_hidden_by_default( get_post_meta( $post->ID, self::META_HIDDEN_BY_DEFAULT, true ) );

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

				$rules[] = array(
					'id'                => (int) $post->ID,
					'action'            => $action,
					'order_mode'        => $order_mode,
					'schedule'          => $schedule,
					'conditions'        => $conditions,
					'hidden_by_default' => ( 'show' === $action && $hidden_by_default ),
					'title'             => get_the_title( $post ),
				);
			}
		}

		wp_reset_postdata();

		self::$rules_cache = $rules;
		wp_cache_set( self::CACHE_KEY_RULES, $rules, self::CACHE_GROUP, MINUTE_IN_SECONDS * 10 );

		return $rules;
	}

	/**
	 * Persist rule action.
	 *
	 * @param mixed $value Raw value.
	 */
	public static function sanitize_action( $value ): string {
		$normalized = is_string( $value ) ? strtolower( trim( $value ) ) : '';
		return in_array( $normalized, array( 'show', 'hide' ), true ) ? $normalized : 'hide';
	}

	/**
	 * Persist rule order mode.
	 *
	 * @param mixed $value Raw value.
	 */
	public static function sanitize_order_mode( $value ): string {
		$normalized = is_string( $value ) ? strtolower( trim( $value ) ) : '';
		return in_array( $normalized, array( 'both', 'delivery', 'pickup' ), true ) ? $normalized : 'both';
	}

	/**
	 * Sanitize the hidden-by-default flag.
	 *
	 * @param mixed $value Raw value.
	 */
	public static function sanitize_hidden_by_default( $value ): bool {
		if ( is_bool( $value ) ) {
			return $value;
		}

		if ( is_numeric( $value ) ) {
			return 1 === (int) $value;
		}

		if ( is_string( $value ) ) {
			return in_array( strtolower( trim( $value ) ), array( '1', 'yes', 'on', 'true' ), true );
		}

		return false;
	}

	/**
	 * Sanitize schedule structure.
	 *
	 * @param mixed $raw Raw value.
	 * @return array<int,array<string,mixed>>
	 */
	public static function sanitize_schedule( $raw ): array {
		$result = array();
		if ( ! is_array( $raw ) ) {
			return $result;
		}

		foreach ( $raw as $entry ) {
			if ( ! is_array( $entry ) ) {
				continue;
			}

			$normalized = self::sanitize_schedule_entry( $entry );
			if ( ! empty( $normalized ) ) {
				$result[] = $normalized;
			}
		}

		return $result;
	}

	/**
	 * Sanitize a single schedule entry payload.
	 *
	 * @param array $entry Raw entry.
	 * @return array<string,mixed>
	 */
	private static function sanitize_schedule_entry( array $entry ): array {
		$type = isset( $entry['type'] ) ? sanitize_key( $entry['type'] ) : '';
		if ( ! in_array( $type, self::SCHEDULE_TYPES, true ) ) {
			// Back-compat: infer type from legacy fields.
			$has_days = ! empty( $entry['days'] );
			$has_time = ! empty( $entry['from'] ) || ! empty( $entry['to'] );
			$type     = $has_days ? 'days_of_week' : ( $has_time ? 'time_range' : 'days_of_week' );
		}

		switch ( $type ) {
			case 'days_of_week':
				$days_raw = $entry['days'] ?? array();
				$days     = array();
				if ( is_array( $days_raw ) ) {
					foreach ( $days_raw as $day ) {
						$day_int = (int) $day;
						if ( $day_int >= 0 && $day_int <= 6 ) {
							$days[ $day_int ] = $day_int;
						}
					}
				}
				sort( $days );
				if ( empty( $days ) ) {
					$days = range( 0, 6 );
				}
				$time_from = isset( $entry['time_from'] ) ? self::sanitize_time_value( $entry['time_from'] ) : self::sanitize_time_value( $entry['from'] ?? '' );
				$time_to   = isset( $entry['time_to'] ) ? self::sanitize_time_value( $entry['time_to'] ) : self::sanitize_time_value( $entry['to'] ?? '' );
				if ( '' === $time_from || '' === $time_to ) {
					$time_from = '';
					$time_to   = '';
				}
				return array(
					'type'      => 'days_of_week',
					'days'      => array_values( $days ),
					'time_from' => $time_from,
					'time_to'   => $time_to,
				);

			case 'time_range':
				$from = isset( $entry['time_from'] ) ? self::sanitize_time_value( $entry['time_from'] ) : self::sanitize_time_value( $entry['from'] ?? '' );
				$to   = isset( $entry['time_to'] ) ? self::sanitize_time_value( $entry['time_to'] ) : self::sanitize_time_value( $entry['to'] ?? '' );
				if ( '' === $from || '' === $to ) {
					return array();
				}
				return array(
					'type'      => 'time_range',
					'time_from' => $from,
					'time_to'   => $to,
				);

			case 'on_date':
			case 'before_date':
			case 'after_date':
				$date = isset( $entry['date'] ) ? self::sanitize_date_value( $entry['date'] ) : '';
				if ( '' === $date ) {
					return array();
				}
				return array(
					'type' => $type,
					'date' => $date,
				);

			case 'date_range':
				$start = isset( $entry['date_from'] ) ? self::sanitize_date_value( $entry['date_from'] ) : '';
				$end   = isset( $entry['date_to'] ) ? self::sanitize_date_value( $entry['date_to'] ) : '';
				if ( '' === $start || '' === $end ) {
					return array();
				}
				if ( $start > $end ) {
					$swap  = $start;
					$start = $end;
					$end   = $swap;
				}
				return array(
					'type'      => 'date_range',
					'date_from' => $start,
					'date_to'   => $end,
				);
		}

		return array();
	}

	/**
	 * Sanitize HH:MM string.
	 *
	 * @param mixed $value Raw value.
	 */
	private static function sanitize_time_value( $value ): string {
		if ( ! is_string( $value ) ) {
			return '';
		}

		$value = trim( $value );
		if ( preg_match( '/^\d{2}:\d{2}$/', $value ) ) {
			return $value;
		}

		return '';
	}

	/**
	 * Sanitize conditions structure.
	 *
	 * @param mixed $raw Raw value.
	 * @return array<int,array<string,mixed>>
	 */
	public static function sanitize_conditions( $raw ): array {
		$result = array();
		if ( ! is_array( $raw ) ) {
			return $result;
		}

		$raw_values = array_values( $raw );
		if ( isset( $raw_values[0] ) && is_array( $raw_values[0] ) && ! array_key_exists( 'rules', $raw_values[0] ) ) {
			$raw = array(
				array(
					'match' => 'any',
					'rules' => $raw_values,
				),
			);
		}

		foreach ( $raw as $group ) {
			if ( ! is_array( $group ) || empty( $group['rules'] ) || ! is_array( $group['rules'] ) ) {
				continue;
			}

			$match = isset( $group['match'] ) && 'any' === $group['match'] ? 'any' : 'all';
			$rules = array();

			foreach ( $group['rules'] as $rule ) {
				if ( ! is_array( $rule ) ) {
					continue;
				}

				$subject = isset( $rule['subject'] ) ? $rule['subject'] : '';
				$subject = in_array( $subject, array( 'product', 'category' ), true ) ? $subject : 'product';

				$operator = isset( $rule['operator'] ) ? $rule['operator'] : '';
				$operator = in_array( $operator, array( 'equals', 'not_equals' ), true ) ? $operator : 'equals';

				$ids_raw = $rule['ids'] ?? array();
				if ( ! is_array( $ids_raw ) ) {
					$ids_raw = array();
				}
				$ids = array();
				foreach ( $ids_raw as $id ) {
					$id_int = absint( $id );
					if ( $id_int > 0 ) {
						$ids[ $id_int ] = $id_int;
					}
				}
				$ids = array_values( $ids );

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

				$rules[] = array(
					'subject'  => $subject,
					'operator' => $operator,
					'ids'      => $ids,
				);
			}

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

			$result[] = array(
				'match' => $match,
				'rules' => $rules,
			);
		}

		return $result;
	}

	/**
	 * Determine if product should remain available.
	 *
	 * @param int $product_id Product ID.
	 */
	public static function product_is_allowed( int $product_id ): bool {
		if ( $product_id <= 0 ) {
			return true;
		}

		return self::product_is_allowed_in_context( $product_id, self::get_current_context() );
	}

	/**
	 * Determine if product is available for a specific order mode.
	 *
	 * @param int    $product_id Product ID.
	 * @param string $order_mode Order mode.
	 */
	public static function product_is_allowed_for_order_mode( int $product_id, string $order_mode ): bool {
		if ( $product_id <= 0 ) {
			return true;
		}

		$context               = self::get_current_context();
		$context['order_mode'] = self::sanitize_order_mode( $order_mode );
		if ( 'both' === $context['order_mode'] ) {
			$context['order_mode'] = 'delivery';
		}

		return self::product_is_allowed_in_context( $product_id, $context );
	}

	/**
	 * Determine if product is available in a prepared context.
	 *
	 * @param int                 $product_id Product ID.
	 * @param array<string,mixed> $context    Evaluation context.
	 */
	private static function product_is_allowed_in_context( int $product_id, array $context ): bool {
		$cache_key = $product_id . ':' . (string) $context['order_mode'];

		if ( isset( self::$product_cache[ $cache_key ] ) ) {
			return self::$product_cache[ $cache_key ];
		}

		self::$product_cache[ $cache_key ] = ( 'hide' !== self::evaluate_product_action( $product_id, $context ) );

		return self::$product_cache[ $cache_key ];
	}

	/**
	 * Evaluate action applied to product.
	 *
	 * @param int $product_id Product ID.
	 */
	public static function evaluate_product_action( int $product_id, ?array $context = null ): ?string {
		$rules = self::get_active_rules();
		if ( empty( $rules ) ) {
			return null;
		}

		$category_ids = self::get_product_category_ids( $product_id );
		$context      = is_array( $context ) ? $context : self::get_current_context();
		$hidden_by_default = false;

		foreach ( $rules as $rule ) {
			if ( ! self::rule_matches_product( $rule, $product_id, $category_ids ) ) {
				continue;
			}

			if ( self::rule_hides_by_default( $rule ) ) {
				$hidden_by_default = true;
			}

			if ( ! self::rule_matches_order_mode( $rule, $context ) ) {
				continue;
			}

			if ( ! self::rule_matches_datetime( $rule, $context ) ) {
				continue;
			}

			return $rule['action'];
		}

		return $hidden_by_default ? 'hide' : null;
	}

	/**
	 * Determine whether a rule should hide matching products outside its schedule.
	 *
	 * @param array<string,mixed> $rule Rule payload.
	 */
	private static function rule_hides_by_default( array $rule ): bool {
		return 'show' === ( $rule['action'] ?? '' ) && ! empty( $rule['hidden_by_default'] );
	}

	/**
	 * Determine if rule matches current time.
	 *
	 * @param array<string,mixed> $rule    Rule payload.
	 * @param array<string,mixed> $context Time context.
	 */
	private static function rule_matches_datetime( array $rule, array $context ): bool {
		$schedule = $rule['schedule'] ?? array();
		if ( empty( $schedule ) ) {
			return true;
		}

		foreach ( $schedule as $entry ) {
			if ( ! self::schedule_entry_matches( $entry, $context ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Determine if rule matches current order mode.
	 *
	 * @param array<string,mixed> $rule    Rule payload.
	 * @param array<string,mixed> $context Request context.
	 */
	private static function rule_matches_order_mode( array $rule, array $context ): bool {
		$rule_mode    = self::sanitize_order_mode( $rule['order_mode'] ?? 'both' );
		$current_mode = self::sanitize_order_mode( $context['order_mode'] ?? 'delivery' );

		if ( 'both' === $rule_mode ) {
			return true;
		}

		return $rule_mode === $current_mode;
	}

	/**
	 * Determine if a single schedule entry matches the current context.
	 *
	 * @param array<string,mixed> $entry   Schedule entry.
	 * @param array<string,mixed> $context Time context.
	 */
	private static function schedule_entry_matches( array $entry, array $context ): bool {
		$type         = isset( $entry['type'] ) ? $entry['type'] : '';
		$current_day  = (int) ( $context['weekday'] ?? 0 );
		$current_time = (string) ( $context['time'] ?? '' );
		$current_date = (string) ( $context['date'] ?? '' );

		switch ( $type ) {
			case 'days_of_week':
				$days = isset( $entry['days'] ) && is_array( $entry['days'] ) ? array_map( 'intval', $entry['days'] ) : array();
				if ( empty( $days ) || ! in_array( $current_day, $days, true ) ) {
					return false;
				}

				$time_from = isset( $entry['time_from'] ) ? $entry['time_from'] : '';
				$time_to   = isset( $entry['time_to'] ) ? $entry['time_to'] : '';
				if ( '' === $time_from || '' === $time_to || '' === $current_time ) {
					return true;
				}

				return self::time_matches_range( $time_from, $time_to, $current_time );

			case 'time_range':
				if ( '' === $current_time ) {
					return true;
				}

				$time_from = isset( $entry['time_from'] ) ? $entry['time_from'] : '';
				$time_to   = isset( $entry['time_to'] ) ? $entry['time_to'] : '';
				if ( '' === $time_from || '' === $time_to ) {
					return true;
				}

				return self::time_matches_range( $time_from, $time_to, $current_time );

			case 'on_date':
				return ( '' === $current_date || '' === ( $entry['date'] ?? '' ) ) ? true : ( $current_date === $entry['date'] );

			case 'before_date':
				return ( '' === $current_date || '' === ( $entry['date'] ?? '' ) ) ? true : ( $current_date < $entry['date'] );

			case 'after_date':
				return ( '' === $current_date || '' === ( $entry['date'] ?? '' ) ) ? true : ( $current_date > $entry['date'] );

			case 'date_range':
				$start = $entry['date_from'] ?? '';
				$end   = $entry['date_to'] ?? '';
				return ( '' === $current_date || '' === $start || '' === $end ) ? true : ( $current_date >= $start && $current_date <= $end );
		}

		return true;
	}

	/**
	 * Check if a HH:MM time is within a provided range.
	 *
	 * @param string $from    Range start.
	 * @param string $to      Range end.
	 * @param string $current Current time.
	 */
	private static function time_matches_range( string $from, string $to, string $current ): bool {
		$from_minutes    = self::time_to_minutes( $from );
		$to_minutes      = self::time_to_minutes( $to );
		$current_minutes = self::time_to_minutes( $current );

		if ( $from_minutes < 0 || $to_minutes < 0 || $current_minutes < 0 ) {
			return true;
		}

		if ( $from_minutes === $to_minutes ) {
			return false;
		}

		if ( $from_minutes < $to_minutes ) {
			return ( $current_minutes >= $from_minutes && $current_minutes <= $to_minutes );
		}

		return ( $current_minutes >= $from_minutes || $current_minutes <= $to_minutes );
	}

	/**
	 * Convert HH:MM string to minutes.
	 *
	 * @param string $value Value.
	 */
	private static function time_to_minutes( string $value ): int {
		if ( ! preg_match( '/^(\d{2}):(\d{2})$/', $value, $matches ) ) {
			return -1;
		}

		$hours   = (int) $matches[1];
		$minutes = (int) $matches[2];
		if ( $hours > 23 || $minutes > 59 ) {
			return -1;
		}

		return ( $hours * 60 ) + $minutes;
	}

	/**
	 * Determine if rule matches product + categories.
	 *
	 * @param array<string,mixed> $rule         Rule payload.
	 * @param int                 $product_id   Product ID.
	 * @param array<int>          $category_ids Category IDs.
	 */
	private static function rule_matches_product( array $rule, int $product_id, array $category_ids ): bool {
		$groups = $rule['conditions'] ?? array();
		if ( empty( $groups ) ) {
			return false;
		}

		foreach ( $groups as $group ) {
			if ( empty( $group['rules'] ) || ! is_array( $group['rules'] ) ) {
				continue;
			}

			foreach ( $group['rules'] as $single_rule ) {
				if ( self::single_condition_matches( $single_rule, $product_id, $category_ids ) ) {
					return true;
				}
			}
		}

		return false;
	}

	/**
	 * Evaluate single condition.
	 *
	 * @param array<string,mixed> $rule         Rule payload.
	 * @param int                 $product_id   Product ID.
	 * @param array<int>          $category_ids Category IDs.
	 */
	private static function single_condition_matches( array $rule, int $product_id, array $category_ids ): bool {
		$subject  = isset( $rule['subject'] ) ? $rule['subject'] : 'product';
		$operator = isset( $rule['operator'] ) ? $rule['operator'] : 'equals';
		$ids      = isset( $rule['ids'] ) && is_array( $rule['ids'] ) ? array_map( 'absint', $rule['ids'] ) : array();
		$ids      = array_filter( $ids );

		if ( empty( $ids ) ) {
			return false;
		}

		if ( 'product' === $subject ) {
			$in_list = in_array( $product_id, $ids, true );
		} else {
			$in_list = ! empty( array_intersect( $ids, $category_ids ) );
		}

		return 'not_equals' === $operator ? ! $in_list : $in_list;
	}

	/**
	 * Fetch category IDs for WooCommerce product.
	 *
	 * @param int $product_id Product ID.
	 * @return array<int>
	 */
	private static function get_product_category_ids( int $product_id ): array {
		$ids = array();

		if ( function_exists( 'wc_get_product' ) ) {
			$product = wc_get_product( $product_id );
			if ( $product instanceof WC_Product ) {
				$ids = array_map( 'intval', (array) $product->get_category_ids() );
			}
		}

		if ( empty( $ids ) ) {
			$ids = wp_get_post_terms( $product_id, 'product_cat', array( 'fields' => 'ids' ) );
			if ( is_wp_error( $ids ) ) {
				$ids = array();
			}
		}

		return array_values( array_unique( array_map( 'intval', $ids ) ) );
	}

	/**
	 * Build current time context.
	 *
	 * @return array<string,mixed>
	 */
	private static function get_current_context(): array {
		$timestamp = time();
		$timezone  = wp_timezone();
		$order_mode = 'delivery';

		if ( function_exists( 'foodord_get_order_mode' ) ) {
			$order_mode = foodord_get_order_mode();
		}

		$order_mode = self::sanitize_order_mode( $order_mode );
		if ( 'both' === $order_mode ) {
			$order_mode = 'delivery';
		}

		return array(
			'timestamp'  => $timestamp,
			'weekday'    => (int) wp_date( 'w', $timestamp, $timezone ),
			'time'       => wp_date( 'H:i', $timestamp, $timezone ),
			'date'       => wp_date( 'Y-m-d', $timestamp, $timezone ),
			'order_mode' => $order_mode,
		);
	}

	/**
	 * Sanitize YYYY-MM-DD date value.
	 *
	 * @param mixed $value Raw value.
	 */
	private static function sanitize_date_value( $value ): string {
		if ( ! is_string( $value ) ) {
			return '';
		}

		$value = trim( $value );
		if ( ! preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ) ) {
			return '';
		}

		return $value;
	}
}

Foodord_Timed_Products::init();
