<?php
/**
 * Query Builder — constructs WP/WC queries from UI filter definitions.
 *
 * @package RawTypeOE
 */

namespace RawTypeOE\Free;

// Prevent direct access.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

/**
 * Builds WooCommerce/WordPress query args from filter arrays.
 */
class Query_Builder {

	/** Supported entities. */
	const ENTITIES = array( 'orders', 'products', 'customers' );

	/**
	 * Valid date fields per entity.
	 *
	 * @var array<string, string[]>
	 */
	private static $date_fields = array(
		'orders'    => array( 'date_created', 'date_modified', 'date_paid', 'date_completed' ),
		'products'  => array( 'date_created', 'date_modified' ),
		'customers' => array( 'date_registered', 'last_order_date' ),
	);

	/** Transient key for cached entity counts. */
	const ENTITY_COUNTS_CACHE_KEY = 'rawtype_oe_entity_counts';

	/** Cache TTL for entity counts (seconds). */
	const ENTITY_COUNTS_CACHE_TTL = 300;

	/**
	 * Get the count for each entity type.
	 *
	 * Cached for {@see ENTITY_COUNTS_CACHE_TTL} seconds to avoid repeating
	 * expensive aggregate queries on every admin page load. On stores with
	 * 500k+ rows count_users() / unbounded order fetches can take 30s+.
	 *
	 * @return array Associative array with orders, products, customers counts.
	 */
	public static function get_entity_counts() {
		$cached = get_transient( self::ENTITY_COUNTS_CACHE_KEY );
		if ( is_array( $cached )
			&& isset( $cached['orders'], $cached['products'], $cached['customers'] )
		) {
			return $cached;
		}

		$counts = array(
			'orders'    => self::count_orders_efficiently(),
			'products'  => self::count_products_efficiently(),
			'customers' => self::count_customers_efficiently(),
		);

		set_transient( self::ENTITY_COUNTS_CACHE_KEY, $counts, self::ENTITY_COUNTS_CACHE_TTL );

		return $counts;
	}

	/**
	 * Invalidate the cached entity counts.
	 *
	 * @return void
	 */
	public static function flush_entity_counts_cache() {
		delete_transient( self::ENTITY_COUNTS_CACHE_KEY );
	}

	/**
	 * Count orders without ever loading the full ID set.
	 *
	 * @return int
	 */
	private static function count_orders_efficiently() {
		$orders = wc_get_orders(
			array(
				'limit'    => 1,
				'return'   => 'ids',
				'paginate' => true,
				'type'     => 'shop_order',
			)
		);

		if ( is_object( $orders ) && isset( $orders->total ) ) {
			return absint( $orders->total );
		}

		// Direct count fallback so we never materialize hundreds of thousands of IDs.
		global $wpdb;

		if ( self::is_hpos_orders_table_enabled() ) {
			$table = $wpdb->prefix . 'wc_orders';

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			return absint(
				$wpdb->get_var(
					$wpdb->prepare(
						"SELECT COUNT(*) FROM {$table} WHERE type = %s",
						'shop_order'
					)
				)
			);
		}

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		return absint(
			$wpdb->get_var(
				$wpdb->prepare(
					"SELECT COUNT(*) FROM {$wpdb->posts} WHERE post_type = %s AND post_status NOT IN ( 'trash', 'auto-draft' )",
					'shop_order'
				)
			)
		);
	}

	/**
	 * Count products using wp_count_posts (uses the post status cache).
	 *
	 * @return int
	 */
	private static function count_products_efficiently() {
		$product_counts = wp_count_posts( 'product' );
		$total          = 0;

		if ( is_object( $product_counts ) ) {
			foreach ( array( 'publish', 'draft', 'pending', 'private' ) as $status ) {
				if ( isset( $product_counts->$status ) ) {
					$total += (int) $product_counts->$status;
				}
			}
		}

		return $total;
	}

	/**
	 * Count customers without scanning the entire usermeta table.
	 *
	 * count_users() walks every row of usermeta to recompute role aggregates,
	 * which on stores with 500k+ users is unusable. We query usermeta directly
	 * for the wp_capabilities meta and count rows mentioning the customer role.
	 *
	 * @return int
	 */
	private static function count_customers_efficiently() {
		global $wpdb;

		$capabilities_key = $wpdb->prefix . 'capabilities';

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$count = $wpdb->get_var(
			$wpdb->prepare(
				"SELECT COUNT(DISTINCT user_id) FROM {$wpdb->usermeta} WHERE meta_key = %s AND meta_value LIKE %s",
				$capabilities_key,
				'%' . $wpdb->esc_like( '"customer"' ) . '%'
			)
		);

		return absint( $count );
	}

	/**
	 * Build query args from an array of filters.
	 *
	 * @param string $entity  Entity slug (orders, products, customers).
	 * @param array  $filters Array of filter definitions.
	 * @param string $logic   Filter logic: 'and' or 'or'. Default 'and'.
	 * @return array Query args suitable for wc_get_orders / wc_get_products / WP_User_Query.
	 */
	public static function build_query_args( $entity, $filters = array(), $logic = 'and' ) {
		$args    = array();
		$logic   = 'or' === sanitize_key( $logic ) ? 'or' : 'and';
		$filters = is_array( $filters ) ? array_values( $filters ) : array();

		if ( ! in_array( $entity, self::ENTITIES, true ) ) {
			return $args;
		}

		if ( 'or' === $logic && count( $filters ) > 1 ) {
			$args = self::build_or_query_args( $entity, $filters );
		} else {
			foreach ( $filters as $filter ) {
				$args = self::apply_filter_to_args( $args, $filter, $entity );
			}
		}

		if ( 'customers' === $entity ) {
			$args['rawtype_oe_customer_filters'] = $filters;
			$args['rawtype_oe_customer_logic']   = $logic;
		}

		return $args;
	}

	/**
	 * Apply one filter definition to a query arg array.
	 *
	 * @param array  $args   Current query args.
	 * @param array  $filter Filter definition.
	 * @param string $entity Entity slug.
	 * @return array
	 */
	private static function apply_filter_to_args( $args, $filter, $entity ) {
		if ( ! isset( $filter['type'] ) ) {
			return $args;
		}

		switch ( $filter['type'] ) {
			case 'date':
				return self::apply_date_filter( $args, $filter, $entity );

			case 'status':
				return self::apply_status_filter( $args, $filter, $entity );

			case 'category':
			case 'tag':
			case 'product':
			case 'sku':
				return self::apply_product_filter( $args, $filter, $entity );

			case 'order_total':
			case 'customer_total_spend':
			case 'customer_orders_count':
			case 'product_price':
				return self::apply_numeric_filter( $args, $filter, $entity );
		}

		return $args;
	}

	/**
	 * Resolve OR logic by unioning the IDs matched by each effective filter.
	 *
	 * @param string $entity  Entity slug.
	 * @param array  $filters Filter definitions.
	 * @return array
	 */
	private static function build_or_query_args( $entity, $filters ) {
		$matched_ids           = array();
		$has_effective_filters = false;

		foreach ( $filters as $filter ) {
			$filter_args = self::apply_filter_to_args( array(), $filter, $entity );
			if ( empty( $filter_args ) ) {
				continue;
			}

			$has_effective_filters = true;
			$filter_ids            = self::resolve_entity_ids_from_args( $entity, $filter_args );

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

			$matched_ids = array_merge( $matched_ids, $filter_ids );
		}

		if ( ! $has_effective_filters ) {
			return array();
		}

		return self::build_include_query_args( $entity, $matched_ids );
	}

	/**
	 * Resolve entity IDs from prepared query args.
	 *
	 * @param string $entity Entity slug.
	 * @param array  $args   Query args.
	 * @return array
	 */
	private static function resolve_entity_ids_from_args( $entity, $args ) {
		switch ( $entity ) {
			case 'orders':
				return self::query_order_ids( $args );

			case 'products':
				return self::query_product_ids( $args );

			case 'customers':
				return self::query_customer_ids( $args );
		}

		return array();
	}

	/**
	 * Resolve order IDs from query args using the same post-filters as preview.
	 *
	 * Capped at {@see get_id_resolution_query_limit()}; legacy behaviour was
	 * `limit => 0` (unlimited) which loaded every matching order ID into PHP
	 * memory and was unworkable on stores with hundreds of thousands of orders.
	 *
	 * @param array $args Query args.
	 * @return array
	 */
	private static function query_order_ids( $args ) {
		list( $args, $candidate_ids ) = self::prepare_order_resolution_query_args( $args );

		$order_ids = wc_get_orders(
			array_merge(
				$args,
				array(
					'limit'  => self::get_id_resolution_query_limit(),
					'return' => 'ids',
					'type'   => 'shop_order',
				)
			)
		);

		if ( ! is_array( $order_ids ) ) {
			$order_ids = array();
		}

		return self::normalize_ids( $order_ids );
	}

	/**
	 * Count orders for a prepared set of query args without loading full datasets
	 * when wc_get_orders() can paginate the result.
	 *
	 * @param array $args Query args.
	 * @return int
	 */
	public static function count_orders_for_args( $args ) {
		list( $count_args, $candidate_ids ) = self::prepare_order_resolution_query_args( $args );

		if ( is_array( $candidate_ids ) && empty( $candidate_ids ) ) {
			return 0;
		}

		$count_result = wc_get_orders(
			array_merge(
				$count_args,
				array(
					'limit'    => 1,
					'return'   => 'ids',
					'paginate' => true,
					'type'     => 'shop_order',
				)
			)
		);

		if ( is_object( $count_result ) && isset( $count_result->total ) ) {
			return absint( $count_result->total );
		}

		$fallback_ids = self::query_order_ids( $args );

		return count( $fallback_ids );
	}

	/**
	 * Fetch a page of resolved order IDs without loading the entire matched set.
	 *
	 * @param array  $args   Query args.
	 * @param int    $limit  Max number of IDs to fetch.
	 * @param int    $offset Result offset.
	 * @param string $order  Sort direction.
	 * @return array
	 */
	public static function get_order_page_ids_for_args( $args, $limit = 10, $offset = 0, $order = 'DESC' ) {
		$limit  = (int) $limit;
		$offset = max( 0, absint( $offset ) );
		$order  = 'ASC' === strtoupper( (string) $order ) ? 'ASC' : 'DESC';

		if ( 0 === $limit ) {
			return array();
		}

		list( $query_args, $candidate_ids ) = self::prepare_order_resolution_query_args( $args );

		if ( is_array( $candidate_ids ) && empty( $candidate_ids ) ) {
			return array();
		}

		$query_args = array_merge(
			$query_args,
			array(
				'return'  => 'ids',
				'type'    => 'shop_order',
				// Sort by ID (unique, monotonic) so pagination stays stable when
				// multiple orders share the same date_created timestamp.
				'orderby' => 'ID',
				'order'   => $order,
				'offset'  => $offset,
			)
		);

		if ( $limit > 0 ) {
			$query_args['limit'] = $limit;
		} else {
			$query_args['limit'] = 0;
		}

		$order_ids = wc_get_orders( $query_args );
		if ( is_array( $order_ids ) ) {
			return self::normalize_ids( $order_ids );
		}

		$resolved_ids = self::query_order_ids( $args );
		if ( empty( $resolved_ids ) ) {
			return array();
		}

		if ( $limit < 0 ) {
			return array_slice( $resolved_ids, $offset );
		}

		return array_slice( $resolved_ids, $offset, $limit );
	}

	/**
	 * Normalize order resolution args and constrain them with direct-filter
	 * candidate IDs when total/product filters are present.
	 *
	 * @param array $args Query args.
	 * @return array
	 */
	private static function prepare_order_resolution_query_args( $args ) {
		$args = is_array( $args ) ? $args : array();

		if ( isset( $args['post__in'] ) && is_array( $args['post__in'] ) && ! isset( $args['include'] ) ) {
			$args['include'] = $args['post__in'];
			unset( $args['post__in'] );
		}

		$total_filter       = isset( $args['rawtype_oe_order_total'] ) ? $args['rawtype_oe_order_total'] : null;
		$filter_product_ids = isset( $args['rawtype_oe_product_ids'] ) ? $args['rawtype_oe_product_ids'] : null;
		unset( $args['rawtype_oe_order_total'], $args['rawtype_oe_product_ids'] );

		$candidate_ids = self::build_order_candidate_ids( $total_filter, $filter_product_ids );
		if ( is_array( $candidate_ids ) ) {
			$existing_include = isset( $args['include'] ) && is_array( $args['include'] )
				? self::normalize_ids( $args['include'] )
				: null;

			if ( is_array( $existing_include ) && ! empty( $existing_include ) ) {
				$candidate_ids = array_values( array_intersect( $existing_include, $candidate_ids ) );
			}

			$args['include'] = $candidate_ids;
		}

		return array( $args, $candidate_ids );
	}

	/**
	 * Resolve order candidate IDs from unsupported direct filters.
	 *
	 * @param array|null $total_filter       Total filter config.
	 * @param array|null $filter_product_ids Product IDs matched by product filters.
	 * @return array|null
	 */
	private static function build_order_candidate_ids( $total_filter, $filter_product_ids ) {
		$candidate_ids = null;

		if ( ! empty( $total_filter ) ) {
			$candidate_ids = self::query_order_ids_by_total( $total_filter );
		}

		if ( ! empty( $filter_product_ids ) ) {
			$product_order_ids = self::query_order_ids_by_product_ids( $filter_product_ids );
			$candidate_ids     = is_array( $candidate_ids )
				? array_values( array_intersect( $candidate_ids, $product_order_ids ) )
				: $product_order_ids;
		}

		if ( null === $candidate_ids ) {
			return null;
		}

		return self::normalize_ids( $candidate_ids );
	}

	/**
	 * Resolve order IDs for a prepared set of query args with request-local caching.
	 *
	 * This is used by the export pipeline when order filters require post-processing
	 * beyond what wc_get_orders() supports natively.
	 *
	 * @param array $args Query args.
	 * @return array
	 */
	public static function resolve_order_ids_for_args( $args ) {
		static $cache = array();

		$args = is_array( $args ) ? $args : array();

		$encoded = wp_json_encode( $args );
		if ( false === $encoded ) {
			$encoded = '';
		}

		$cache_key = md5( $encoded );
		if ( isset( $cache[ $cache_key ] ) ) {
			return $cache[ $cache_key ];
		}

		$cache[ $cache_key ] = self::query_order_ids( $args );

		return $cache[ $cache_key ];
	}

	/**
	 * Resolve product IDs from prepared query args.
	 *
	 * Capped at {@see get_product_id_resolution_limit()} to keep memory bounded
	 * when an OR-logic resolution materializes IDs across very large stores.
	 *
	 * @param array $args Query args.
	 * @return array
	 */
	private static function query_product_ids( $args ) {
		$product_ids = wc_get_products(
			array_merge(
				$args,
				array(
					'limit'  => self::get_product_id_resolution_limit(),
					'return' => 'ids',
				)
			)
		);

		return self::normalize_ids( is_array( $product_ids ) ? $product_ids : array() );
	}

	/**
	 * Resolve customer IDs from prepared query args.
	 *
	 * @param array $args Query args.
	 * @return array
	 */
	private static function query_customer_ids( $args ) {
		$args    = self::with_default_customer_role( $args );
		$include = self::get_customer_include_shortcut_ids( $args, array( 'rawtype_oe_customer_filters', 'rawtype_oe_customer_logic' ) );

		if ( null !== $include ) {
			return self::normalize_ids( $include );
		}

		if ( ! class_exists( 'WP_User_Query' ) ) {
			return array();
		}

		$query_args = array_merge(
			$args,
			array(
				'fields'      => 'ID',
				'count_total' => false,
			)
		);

		unset( $query_args['number'], $query_args['limit'], $query_args['paged'], $query_args['offset'] );

		$query = new \WP_User_Query( $query_args );

		return self::normalize_ids( $query->get_results() );
	}

	/**
	 * Build include-based query args for a resolved ID set.
	 *
	 * @param string $entity Entity slug.
	 * @param array  $ids    Resolved IDs.
	 * @return array
	 */
	private static function build_include_query_args( $entity, $ids ) {
		$ids = self::normalize_ids( $ids );

		if ( empty( $ids ) ) {
			return self::get_empty_query_args( $entity );
		}

		return array(
			'include' => $ids,
		);
	}

	/**
	 * Build a guaranteed-empty query for an entity.
	 *
	 * @param string $entity Entity slug.
	 * @return array
	 */
	private static function get_empty_query_args( $entity ) {
		if ( in_array( $entity, self::ENTITIES, true ) ) {
			return array(
				'include' => array( 0 ),
			);
		}

		return array();
	}

	/**
	 * Normalize a mixed ID list.
	 *
	 * @param array $ids Raw IDs.
	 * @return array
	 */
	private static function normalize_ids( $ids ) {
		return array_values(
			array_unique(
				array_filter(
					array_map( 'absint', (array) $ids )
				)
			)
		);
	}

	/**
	 * Execute a preview query returning count and optional sample rows.
	 *
	 * @param string $entity     Entity slug.
	 * @param array  $filters    Filter definitions.
	 * @param string $logic      Filter logic (and/or).
	 * @param bool   $count_only Whether to return only the count.
	 * @param int    $limit      Number of sample records to return.
	 * @return array With 'total' and optionally 'records' keys.
	 */
	public static function preview( $entity, $filters = array(), $logic = 'and', $count_only = false, $limit = 10 ) {
		$args   = self::build_query_args( $entity, $filters, $logic );
		$result = array(
			'total'   => 0,
			'records' => array(),
		);

		switch ( $entity ) {
			case 'orders':
				$result = self::preview_orders( $args, $count_only, $limit );
				break;
			case 'products':
				$result = self::preview_products( $args, $count_only, $limit );
				break;
			case 'customers':
				$result = self::preview_customers( $args, $count_only, $limit );
				break;
		}

		return $result;
	}

	/**
	 * Apply a date filter to query args.
	 *
	 * @param array  $args   Current query args.
	 * @param array  $filter Filter definition.
	 * @param string $entity Entity slug.
	 * @return array Modified query args.
	 */
	public static function apply_date_filter( $args, $filter, $entity ) {
		$field    = isset( $filter['field'] ) ? sanitize_key( $filter['field'] ) : '';
		$operator = isset( $filter['operator'] ) ? sanitize_key( $filter['operator'] ) : 'between';
		$value    = isset( $filter['value'] ) ? $filter['value'] : '';

		$valid_fields = isset( self::$date_fields[ $entity ] ) ? self::$date_fields[ $entity ] : array();

		if ( empty( $field ) || ! in_array( $field, $valid_fields, true ) ) {
			return $args;
		}

		$date_pattern = '/^\d{4}-\d{2}-\d{2}$/';

		switch ( $entity ) {
			case 'orders':
				$args = self::apply_date_filter_orders( $args, $field, $operator, $value, $date_pattern );
				break;
			case 'products':
				$args = self::apply_date_filter_products( $args, $field, $operator, $value, $date_pattern );
				break;
			case 'customers':
				$args = self::apply_date_filter_customers( $args, $field, $operator, $value, $date_pattern );
				break;
		}

		return $args;
	}

	/**
	 * Resolve a filter date string into canonical Y-m-d format.
	 *
	 * Supports absolute dates plus a small set of relative tokens used by the
	 * template library and public integrations.
	 *
	 * @param mixed  $value        Raw date value.
	 * @param string $date_pattern Regex pattern for absolute dates.
	 * @return string
	 */
	private static function resolve_filter_date_value( $value, $date_pattern ) {
		if ( ! is_string( $value ) ) {
			return '';
		}

		$value = trim( sanitize_text_field( $value ) );
		if ( '' === $value ) {
			return '';
		}

		if ( preg_match( $date_pattern, $value ) ) {
			return $value;
		}

		$now = self::get_filter_reference_datetime();
		switch ( strtolower( $value ) ) {
			case 'today':
				return $now->format( 'Y-m-d' );

			case 'yesterday':
				return $now->modify( '-1 day' )->format( 'Y-m-d' );

			case 'tomorrow':
				return $now->modify( '+1 day' )->format( 'Y-m-d' );

			case 'this_quarter':
				$month       = (int) $now->format( 'n' );
				$start_month = ( (int) floor( ( $month - 1 ) / 3 ) * 3 ) + 1;

				return $now->setDate(
					(int) $now->format( 'Y' ),
					$start_month,
					1
				)->format( 'Y-m-d' );
		}

		$resolved = $now->modify( $value );
		if ( false === $resolved ) {
			return '';
		}

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

	/**
	 * Resolve the site's current reference datetime for relative date filters.
	 *
	 * @return \DateTimeImmutable
	 */
	private static function get_filter_reference_datetime() {
		$timezone = wp_timezone();

		return new \DateTimeImmutable( 'now', $timezone );
	}

	/**
	 * Apply a date filter for orders (HPOS-compatible).
	 *
	 * @param array  $args         Current query args.
	 * @param string $field        Date field name.
	 * @param string $operator     Operator (is, after, before, between).
	 * @param mixed  $value        Filter value (string or array).
	 * @param string $date_pattern Regex pattern for date validation.
	 * @return array Modified query args.
	 */
	private static function apply_date_filter_orders( $args, $field, $operator, $value, $date_pattern ) {
		switch ( $operator ) {
			case 'is':
				$date = self::resolve_filter_date_value( $value, $date_pattern );
				if ( '' !== $date ) {
					$args[ $field ] = $date . '...' . $date;
				}
				break;

			case 'after':
				$date = self::resolve_filter_date_value( $value, $date_pattern );
				if ( '' !== $date ) {
					$args[ $field ] = '>' . $date;
				}
				break;

			case 'before':
				$date = self::resolve_filter_date_value( $value, $date_pattern );
				if ( '' !== $date ) {
					$args[ $field ] = '<' . $date;
				}
				break;

			case 'between':
				if ( is_array( $value ) && 2 === count( $value ) ) {
					$start = self::resolve_filter_date_value( $value[0], $date_pattern );
					$end   = self::resolve_filter_date_value( $value[1], $date_pattern );
					if ( '' !== $start && '' !== $end ) {
						$args[ $field ] = $start . '...' . $end;
					}
				}
				break;
		}

		return $args;
	}

	/**
	 * Apply a date filter for products using date_query.
	 *
	 * @param array  $args         Current query args.
	 * @param string $field        Date field name.
	 * @param string $operator     Operator.
	 * @param mixed  $value        Filter value.
	 * @param string $date_pattern Regex pattern.
	 * @return array Modified query args.
	 */
	private static function apply_date_filter_products( $args, $field, $operator, $value, $date_pattern ) {
		$column = 'date_modified' === $field ? 'post_modified' : 'post_date';

		return self::append_date_query_clause( $args, $operator, $value, $date_pattern, $column );
	}

	/**
	 * Apply a date filter for customers using date_query.
	 *
	 * @param array  $args         Current query args.
	 * @param string $field        Date field name.
	 * @param string $operator     Operator.
	 * @param mixed  $value        Filter value.
	 * @param string $date_pattern Regex pattern.
	 * @return array Modified query args.
	 */
	private static function apply_date_filter_customers( $args, $field, $operator, $value, $date_pattern ) {
		if ( 'last_order_date' === $field ) {
			return self::apply_customer_last_order_date_filter( $args, $operator, $value, $date_pattern );
		}

		return self::append_date_query_clause( $args, $operator, $value, $date_pattern );
	}

	/**
	 * Build a reusable WP date_query clause.
	 *
	 * @param string $operator     Operator.
	 * @param mixed  $value        Filter value.
	 * @param string $date_pattern Regex pattern.
	 * @return array|null
	 */
	private static function build_date_query_clause( $operator, $value, $date_pattern ) {
		switch ( $operator ) {
			case 'is':
				$date = self::resolve_filter_date_value( $value, $date_pattern );
				if ( '' !== $date ) {
					return array(
						'after'     => $date,
						'before'    => $date . ' 23:59:59',
						'inclusive' => true,
					);
				}
				break;

			case 'after':
				$date = self::resolve_filter_date_value( $value, $date_pattern );
				if ( '' !== $date ) {
					return array(
						'after' => $date,
					);
				}
				break;

			case 'before':
				$date = self::resolve_filter_date_value( $value, $date_pattern );
				if ( '' !== $date ) {
					return array(
						'before' => $date,
					);
				}
				break;

			case 'between':
				if ( is_array( $value ) && 2 === count( $value ) ) {
					$start = self::resolve_filter_date_value( $value[0], $date_pattern );
					$end   = self::resolve_filter_date_value( $value[1], $date_pattern );
					if ( '' !== $start && '' !== $end ) {
						return array(
							'after'     => $start,
							'before'    => $end . ' 23:59:59',
							'inclusive' => true,
						);
					}
				}
				break;
		}

		return null;
	}

	/**
	 * Append a date_query clause to the query args.
	 *
	 * @param array  $args         Current query args.
	 * @param string $operator     Operator.
	 * @param mixed  $value        Filter value.
	 * @param string $date_pattern Regex pattern.
	 * @param string $column       Optional DB column.
	 * @return array
	 */
	private static function append_date_query_clause( $args, $operator, $value, $date_pattern, $column = '' ) {
		$clause = self::build_date_query_clause( $operator, $value, $date_pattern );

		if ( null === $clause ) {
			return $args;
		}

		if ( '' !== $column ) {
			$clause['column'] = $column;
		}

		if ( ! isset( $args['date_query'] ) ) {
			$args['date_query'] = array();
		}

		$args['date_query'][] = $clause;

		return $args;
	}

	/**
	 * Apply a status filter to query args.
	 *
	 * Supports classic status filters and product-specific stock/on-sale presets
	 * used by the template library.
	 *
	 * @param array  $args   Current query args.
	 * @param array  $filter Filter definition with scalar or array values.
	 * @param string $entity Entity slug.
	 * @return array Modified query args.
	 */
	public static function apply_status_filter( $args, $filter, $entity ) {
		$field      = isset( $filter['field'] ) ? sanitize_key( $filter['field'] ) : 'status';
		$raw_value  = isset( $filter['value'] ) ? $filter['value'] : array();
		$statuses   = self::normalize_status_values( $raw_value );
		$is_on_sale = self::normalize_boolean_filter_value(
			is_array( $raw_value ) ? reset( $raw_value ) : $raw_value
		);

		if ( 'products' === $entity && 'on_sale' === $field ) {
			if ( null === $is_on_sale ) {
				return $args;
			}

			$product_ids = wc_get_product_ids_on_sale();

			if ( $is_on_sale ) {
				return self::constrain_product_query_to_ids( $args, $product_ids );
			}

			return self::exclude_product_query_ids( $args, $product_ids );
		}

		if ( empty( $statuses ) ) {
			return $args;
		}

		if ( 'products' === $entity && 'stock_status' === $field ) {
			$args['stock_status'] = $statuses[0];
			return $args;
		}

		switch ( $entity ) {
			case 'orders':
				$args['status'] = $statuses;
				break;

			case 'products':
				$args['status'] = $statuses;
				break;

			case 'customers':
				$args['role__in'] = $statuses;
				break;
		}

		return $args;
	}

	/**
	 * Normalize status values coming from the UI or template library.
	 *
	 * @param mixed $value Raw status value.
	 * @return array
	 */
	private static function normalize_status_values( $value ) {
		if ( is_array( $value ) ) {
			$values = $value;
		} elseif ( is_scalar( $value ) ) {
			$values = array( $value );
		} else {
			return array();
		}

		$values = array_map(
			function ( $item ) {
				return sanitize_key( (string) $item );
			},
			$values
		);

		return array_values( array_filter( array_unique( $values ) ) );
	}

	/**
	 * Normalize a boolean-like filter value.
	 *
	 * @param mixed $value Raw value.
	 * @return bool|null
	 */
	private static function normalize_boolean_filter_value( $value ) {
		if ( is_bool( $value ) ) {
			return $value;
		}

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

		if ( ! is_string( $value ) ) {
			return null;
		}

		switch ( strtolower( trim( $value ) ) ) {
			case '1':
			case 'true':
			case 'yes':
				return true;

			case '0':
			case 'false':
			case 'no':
				return false;
		}

		return null;
	}

	/**
	 * Restrict a product query to a known list of IDs.
	 *
	 * @param array $args        Current query args.
	 * @param array $product_ids Product IDs.
	 * @return array
	 */
	private static function constrain_product_query_to_ids( $args, $product_ids ) {
		$product_ids = array_values( array_filter( array_map( 'absint', (array) $product_ids ) ) );

		if ( empty( $product_ids ) ) {
			$args['include'] = array( 0 );
			return $args;
		}

		if ( isset( $args['include'] ) && is_array( $args['include'] ) ) {
			$product_ids = array_values(
				array_intersect(
					array_map( 'absint', $args['include'] ),
					$product_ids
				)
			);
		}

		$args['include'] = ! empty( $product_ids ) ? $product_ids : array( 0 );

		return $args;
	}

	/**
	 * Exclude product IDs from a query while preserving existing includes.
	 *
	 * @param array $args        Current query args.
	 * @param array $product_ids Product IDs to exclude.
	 * @return array
	 */
	private static function exclude_product_query_ids( $args, $product_ids ) {
		$product_ids = array_values( array_filter( array_map( 'absint', (array) $product_ids ) ) );

		if ( empty( $product_ids ) ) {
			return $args;
		}

		if ( isset( $args['include'] ) && is_array( $args['include'] ) ) {
			$remaining       = array_values( array_diff( array_map( 'absint', $args['include'] ), $product_ids ) );
			$args['include'] = ! empty( $remaining ) ? $remaining : array( 0 );
			return $args;
		}

		$existing        = isset( $args['exclude'] ) && is_array( $args['exclude'] )
			? array_map( 'absint', $args['exclude'] )
			: array();
		$args['exclude'] = array_values( array_unique( array_merge( $existing, $product_ids ) ) ); // phpcs:ignore WordPressVIPMinimum.Performance.WPQueryParams.PostNotIn_exclude -- Limited product exclusion for category filters.

		return $args;
	}

	/**
	 * Get available statuses for an entity.
	 *
	 * @param string $entity Entity slug.
	 * @return array Array of status objects with 'value' and 'label' keys.
	 */
	public static function get_entity_statuses( $entity ) {
		$statuses = array();

		switch ( $entity ) {
			case 'orders':
			case 'cross-entity':
				$wc_statuses = wc_get_order_statuses();
				foreach ( $wc_statuses as $key => $label ) {
					$statuses[] = array(
						'value' => str_replace( 'wc-', '', $key ),
						'label' => $label,
					);
				}
				break;

			case 'products':
				$statuses = array(
					array(
						'value' => 'publish',
						'label' => __( 'Published', 'rawtype-order-exporter-woocommerce' ),
					),
					array(
						'value' => 'draft',
						'label' => __( 'Draft', 'rawtype-order-exporter-woocommerce' ),
					),
					array(
						'value' => 'pending',
						'label' => __( 'Pending review', 'rawtype-order-exporter-woocommerce' ),
					),
					array(
						'value' => 'private',
						'label' => __( 'Private', 'rawtype-order-exporter-woocommerce' ),
					),
				);
				break;

			case 'customers':
				$statuses = array(
					array(
						'value' => 'customer',
						'label' => __( 'Customer', 'rawtype-order-exporter-woocommerce' ),
					),
					array(
						'value' => 'subscriber',
						'label' => __( 'Subscriber', 'rawtype-order-exporter-woocommerce' ),
					),
					array(
						'value' => 'administrator',
						'label' => __( 'Administrator', 'rawtype-order-exporter-woocommerce' ),
					),
				);
				break;
		}

		return $statuses;
	}

	/**
	 * Get valid date fields for an entity.
	 *
	 * @param string $entity Entity slug.
	 * @return array
	 */
	public static function get_date_fields( $entity ) {
		return isset( self::$date_fields[ $entity ] ) ? self::$date_fields[ $entity ] : array();
	}

	/**
	 * Preview orders with filters.
	 *
	 * @param array $args       Query args.
	 * @param bool  $count_only Whether to return only the count.
	 * @param int   $limit      Number of records.
	 * @return array
	 */
	private static function preview_orders( $args, $count_only, $limit ) {
		$result = array(
			'total'   => 0,
			'records' => array(),
		);

		$result['total'] = self::count_orders_for_args( $args );

		if ( $count_only ) {
			return $result;
		}

		$sample_ids = self::get_order_page_ids_for_args( $args, $limit, 0, 'DESC' );
		if ( ! empty( $sample_ids ) ) {
			foreach ( $sample_ids as $order_id ) {
				$order = wc_get_order( $order_id );
				if ( $order ) {
					$result['records'][] = array(
						'id'     => $order->get_id(),
						'number' => $order->get_order_number(),
						'status' => $order->get_status(),
						'date'   => $order->get_date_created() ? $order->get_date_created()->format( 'Y-m-d H:i:s' ) : '',
						'total'  => $order->get_total(),
					);
				}
			}
		}

		return $result;
	}

	/**
	 * Preview products with filters.
	 *
	 * @param array $args       Query args.
	 * @param bool  $count_only Whether to return only the count.
	 * @param int   $limit      Number of records.
	 * @return array
	 */
	private static function preview_products( $args, $count_only, $limit ) {
		$result = array(
			'total'   => 0,
			'records' => array(),
		);

		$data_source     = new Data_Source_Products();
		$result['total'] = $data_source->count( $args );

		if ( $count_only ) {
			return $result;
		}

		$sample_args = array_merge(
			$args,
			array(
				'limit'   => $limit,
				'orderby' => 'date',
				'order'   => 'DESC',
			)
		);

		$products = $data_source->fetch( $sample_args );
		if ( is_array( $products ) ) {
			foreach ( $products as $product ) {
				$result['records'][] = array(
					'id'     => $product->get_id(),
					'name'   => $product->get_name(),
					'sku'    => $product->get_sku(),
					'status' => $product->get_status(),
					'price'  => $product->get_price(),
				);
			}
		}

		return $result;
	}

	/**
	 * Apply a product-related filter (category, tag, product, SKU) to query args.
	 *
	 * @param array  $args   Current query args.
	 * @param array  $filter Filter definition with 'type' and 'value'.
	 * @param string $entity Entity slug.
	 * @return array Modified query args.
	 */
	public static function apply_product_filter( $args, $filter, $entity ) {
		$type  = isset( $filter['type'] ) ? sanitize_key( $filter['type'] ) : '';
		$value = isset( $filter['value'] ) ? $filter['value'] : '';

		if ( empty( $type ) || empty( $value ) ) {
			return $args;
		}

		switch ( $type ) {
			case 'category':
				$args = self::apply_category_filter( $args, $value, $entity );
				break;

			case 'tag':
				$args = self::apply_tag_filter( $args, $value, $entity );
				break;

			case 'product':
				$args = self::apply_specific_product_filter( $args, $value, $entity );
				break;

			case 'sku':
				$mode = isset( $filter['mode'] ) ? sanitize_key( $filter['mode'] ) : 'exact';
				$args = self::apply_sku_filter( $args, $value, $mode, $entity );
				break;
		}

		return $args;
	}

	/**
	 * Apply a category filter.
	 *
	 * @param array        $args   Query args.
	 * @param array|string $value  Category IDs.
	 * @param string       $entity Entity slug.
	 * @return array
	 */
	private static function apply_category_filter( $args, $value, $entity ) {
		$category_ids = is_array( $value ) ? array_map( 'absint', $value ) : array( absint( $value ) );
		$category_ids = array_filter( $category_ids );

		if ( empty( $category_ids ) ) {
			return $args;
		}

		// Include child categories.
		$all_ids = $category_ids;
		foreach ( $category_ids as $cat_id ) {
			$children = get_term_children( $cat_id, 'product_cat' );
			if ( ! is_wp_error( $children ) && is_array( $children ) ) {
				$all_ids = array_merge( $all_ids, $children );
			}
		}
		$all_ids = array_unique( array_map( 'absint', $all_ids ) );

		switch ( $entity ) {
			case 'products':
				// get_posts + tax_query goes directly through WP_Query,
				// avoiding wc_get_products limitations with tax_query.
				$product_ids = self::get_product_ids_in_categories( $all_ids );
				if ( empty( $product_ids ) ) {
					$args['include'] = array( 0 );
				} else {
					$args['include'] = $product_ids;
				}
				break;

			case 'orders':
				$product_ids = self::get_product_ids_in_categories( $all_ids );
				if ( ! empty( $product_ids ) ) {
					$args['rawtype_oe_product_ids'] = $product_ids;
				} else {
					// No products found in these categories — force empty result.
					$args['include'] = array( 0 );
				}
				break;
		}

		return $args;
	}

	/**
	 * Apply a tag filter.
	 *
	 * @param array        $args   Query args.
	 * @param array|string $value  Tag IDs.
	 * @param string       $entity Entity slug.
	 * @return array
	 */
	private static function apply_tag_filter( $args, $value, $entity ) {
		$tag_ids = is_array( $value ) ? array_map( 'absint', $value ) : array( absint( $value ) );
		$tag_ids = array_filter( $tag_ids );

		if ( empty( $tag_ids ) ) {
			return $args;
		}

		switch ( $entity ) {
			case 'products':
				$args['tag'] = $tag_ids;
				break;

			case 'orders':
				$product_ids = self::get_product_ids_in_tags( $tag_ids );
				if ( ! empty( $product_ids ) ) {
					$args['rawtype_oe_product_ids'] = $product_ids;
				} else {
					$args['include'] = array( 0 );
				}
				break;
		}

		return $args;
	}

	/**
	 * Apply a specific product filter.
	 *
	 * @param array        $args   Query args.
	 * @param array|string $value  Product IDs.
	 * @param string       $entity Entity slug.
	 * @return array
	 */
	private static function apply_specific_product_filter( $args, $value, $entity ) {
		$product_ids = is_array( $value ) ? array_map( 'absint', $value ) : array( absint( $value ) );
		$product_ids = array_filter( $product_ids );

		if ( empty( $product_ids ) ) {
			return $args;
		}

		switch ( $entity ) {
			case 'products':
				$args['include'] = $product_ids;
				break;

			case 'orders':
				$args['rawtype_oe_product_ids'] = $product_ids;
				break;
		}

		return $args;
	}

	/**
	 * Apply a SKU filter.
	 *
	 * @param array  $args   Query args.
	 * @param string $value  SKU value.
	 * @param string $mode   Match mode: 'exact' or 'contains'.
	 * @param string $entity Entity slug.
	 * @return array
	 */
	private static function apply_sku_filter( $args, $value, $mode, $entity ) {
		$sku = sanitize_text_field( is_string( $value ) ? $value : '' );

		if ( '' === $sku ) {
			return $args;
		}

		$compare = 'contains' === $mode ? 'LIKE' : '=';

		switch ( $entity ) {
			case 'products':
				$args['sku'] = $sku;
				if ( 'contains' === $mode ) {
					global $wpdb;

					// For contains mode we need meta_query.
					if ( ! isset( $args['meta_query'] ) ) {
						// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
						$args['meta_query'] = array();
					}
					$args['meta_query'][] = array(
						'key'     => '_sku',
						// Escape LIKE wildcards so a literal "%" or "_" in the
						// SKU matches itself instead of acting as a wildcard.
						'value'   => $wpdb->esc_like( $sku ),
						'compare' => 'LIKE',
					);
					// Remove the exact sku arg to avoid conflict.
					unset( $args['sku'] );
				}
				break;

			case 'orders':
				$product_ids = self::get_product_ids_by_sku( $sku, $compare );
				if ( ! empty( $product_ids ) ) {
					$args['rawtype_oe_product_ids'] = $product_ids;
				} else {
					$args['include'] = array( 0 );
				}
				break;
		}

		return $args;
	}

	/**
	 * Resolve the configured cap for product-ID resolution helpers.
	 *
	 * Filterable so very large stores can either reduce the cap (memory) or
	 * raise it intentionally for full coverage.
	 *
	 * @return int
	 */
	private static function get_product_id_resolution_limit() {
		$limit = (int) apply_filters( 'rawtype_oe_product_id_resolution_limit', self::PRODUCT_ID_RESOLUTION_LIMIT );

		if ( $limit < 1 ) {
			return self::PRODUCT_ID_RESOLUTION_LIMIT;
		}

		return $limit;
	}

	/**
	 * Get product IDs in specific categories.
	 *
	 * Capped at {@see get_product_id_resolution_limit()} to keep memory usage
	 * bounded on stores with hundreds of thousands of products. Filterable.
	 *
	 * @param array $category_ids Category term IDs.
	 * @return array Product IDs.
	 */
	private static function get_product_ids_in_categories( $category_ids ) {
		$post_ids = \get_posts(
			array(
				'post_type'      => 'product',
				'posts_per_page' => self::get_product_id_resolution_limit(),
				'fields'         => 'ids',
				'no_found_rows'  => true,
				// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
				'tax_query'      => array(
					array(
						'taxonomy' => 'product_cat',
						'field'    => 'term_id',
						'terms'    => $category_ids,
						'operator' => 'IN',
					),
				),
			)
		);
		return is_array( $post_ids ) ? array_map( 'absint', $post_ids ) : array();
	}

	/**
	 * Get product IDs in specific tags.
	 *
	 * Capped at {@see get_product_id_resolution_limit()} to bound memory usage.
	 *
	 * @param array $tag_ids Tag term IDs.
	 * @return array Product IDs.
	 */
	private static function get_product_ids_in_tags( $tag_ids ) {
		$products = wc_get_products(
			array(
				'tag'    => $tag_ids,
				'limit'  => self::get_product_id_resolution_limit(),
				'return' => 'ids',
			)
		);

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

	/**
	 * Get product IDs by SKU.
	 *
	 * The LIKE branch is bounded by {@see get_product_id_resolution_limit()}
	 * to avoid loading every match on stores with millions of postmeta rows.
	 *
	 * @param string $sku     SKU value.
	 * @param string $compare Comparison operator ('=' or 'LIKE').
	 * @return array Product IDs.
	 */
	private static function get_product_ids_by_sku( $sku, $compare = '=' ) {
		if ( 'LIKE' === $compare ) {
			$products = wc_get_products(
				array(
					'limit'      => self::get_product_id_resolution_limit(),
					'return'     => 'ids',
					// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
					'meta_query' => array(
						array(
							'key'     => '_sku',
							'value'   => $sku,
							'compare' => 'LIKE',
						),
					),
				)
			);
		} else {
			$product_id = wc_get_product_id_by_sku( $sku );
			$products   = $product_id ? array( $product_id ) : array();
		}

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

	/**
	 * Apply a numeric filter (order total, customer spend, product price, etc.) to query args.
	 *
	 * @param array  $args   Current query args.
	 * @param array  $filter Filter definition with 'type', 'operator', 'value'.
	 * @param string $entity Entity slug.
	 * @return array Modified query args.
	 */
	public static function apply_numeric_filter( $args, $filter, $entity ) {
		$type     = isset( $filter['type'] ) ? sanitize_key( $filter['type'] ) : '';
		$operator = isset( $filter['operator'] ) ? $filter['operator'] : '>';
		$value    = isset( $filter['value'] ) ? $filter['value'] : '';

		if ( empty( $type ) ) {
			return $args;
		}

		$valid_operators = array( '>', '<', '=', '>=', '<=', 'between' );
		if ( ! in_array( $operator, $valid_operators, true ) ) {
			return $args;
		}

		if ( 'between' === $operator ) {
			if ( ! is_array( $value ) || count( $value ) < 2 ) {
				return $args;
			}
		} elseif ( '' === $value || null === $value ) {
			return $args;
		}

		switch ( $type ) {
			case 'order_total':
				if ( 'orders' !== $entity ) {
					return $args;
				}
				$args = self::apply_order_total_filter( $args, $operator, $value );
				break;

			case 'customer_total_spend':
				if ( 'customers' !== $entity ) {
					return $args;
				}
				$args = self::apply_customer_spend_filter( $args, $operator, $value );
				break;

			case 'customer_orders_count':
				if ( 'customers' !== $entity ) {
					return $args;
				}
				$args = self::apply_customer_orders_count_filter( $args, $operator, $value );
				break;

			case 'product_price':
				if ( 'products' !== $entity ) {
					return $args;
				}
				$args = self::apply_product_price_filter( $args, $operator, $value );
				break;
		}

		return $args;
	}

	/**
	 * Apply order total filter.
	 *
	 * @param array  $args     Query args.
	 * @param string $operator Comparison operator.
	 * @param mixed  $value    Numeric value or array for 'between'.
	 * @return array
	 */
	private static function apply_order_total_filter( $args, $operator, $value ) {
		if ( 'between' === $operator ) {
			if ( ! is_array( $value ) || 2 !== count( $value ) ) {
				return $args;
			}
			$min = floatval( $value[0] );
			$max = floatval( $value[1] );
			if ( $min > $max ) {
				return $args;
			}
			$args['rawtype_oe_order_total'] = array(
				'operator' => 'between',
				'min'      => $min,
				'max'      => $max,
			);
		} else {
			$amount                         = floatval( $value );
			$args['rawtype_oe_order_total'] = array(
				'operator' => $operator,
				'value'    => $amount,
			);
		}

		return $args;
	}

	/**
	 * Get order IDs that contain at least one of the given product IDs.
	 *
	 * @param array $product_ids Product post IDs.
	 * @return array Order IDs.
	 */
	public static function query_order_ids_by_product_ids( $product_ids ) {
		global $wpdb;

		if ( empty( $product_ids ) ) {
			return array();
		}

		$product_ids = array_values( array_filter( array_map( 'absint', $product_ids ) ) );

		if ( empty( $product_ids ) ) {
			return array();
		}

		$placeholders = implode( ',', array_fill( 0, count( $product_ids ), '%d' ) );

		$args   = $product_ids;
		$args[] = self::get_id_resolution_query_limit();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
		$results = $wpdb->get_col(
			$wpdb->prepare(
				// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				"SELECT DISTINCT order_id FROM {$wpdb->prefix}woocommerce_order_items oi
				INNER JOIN {$wpdb->prefix}woocommerce_order_itemmeta oim
					ON oi.order_item_id = oim.order_item_id
				WHERE oi.order_item_type = 'line_item'
					AND oim.meta_key = '_product_id'
					AND oim.meta_value IN ($placeholders)
				ORDER BY order_id DESC
				LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare
				...$args
			)
		);

		return is_array( $results ) ? array_map( 'absint', $results ) : array();
	}

	/**
	 * Query order IDs matching a total amount condition via direct DB query.
	 *
	 * WooCommerce's wc_get_orders() does not support filtering by total natively,
	 * so we query the DB directly. Supports both HPOS and legacy CPT storage.
	 *
	 * @param array $total_filter Filter config with 'operator' and 'value'/'min'/'max'.
	 * @return array Array of order IDs matching the condition.
	 */
	public static function query_order_ids_by_total( $total_filter ) {
		global $wpdb;

		$operator = isset( $total_filter['operator'] ) ? $total_filter['operator'] : '>';
		$limit    = self::get_id_resolution_query_limit();
		$is_hpos  = self::is_hpos_orders_table_enabled();

		if ( 'between' === $operator ) {
			$min = floatval( $total_filter['min'] );
			$max = floatval( $total_filter['max'] );

			if ( $is_hpos ) {
				$table = $wpdb->prefix . 'wc_orders';
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
				$results = $wpdb->get_col(
					$wpdb->prepare(
						"SELECT id FROM {$table} WHERE total_amount >= %f AND total_amount <= %f ORDER BY id DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
						$min,
						$max,
						$limit
					)
				);
			} else {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
				$results = $wpdb->get_col(
					$wpdb->prepare(
						"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_order_total' AND CAST(meta_value AS DECIMAL(10,2)) >= %f AND CAST(meta_value AS DECIMAL(10,2)) <= %f ORDER BY post_id DESC LIMIT %d",
						$min,
						$max,
						$limit
					)
				);
			}
		} else {
			$amount     = floatval( $total_filter['value'] );
			$sql_op_map = array(
				'>'  => '>',
				'<'  => '<',
				'='  => '=',
				'>=' => '>=',
				'<=' => '<=',
			);
			$sql_op     = isset( $sql_op_map[ $operator ] ) ? $sql_op_map[ $operator ] : '>';

			if ( $is_hpos ) {
				$table = $wpdb->prefix . 'wc_orders';
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
				$results = $wpdb->get_col(
					$wpdb->prepare(
						"SELECT id FROM {$table} WHERE total_amount {$sql_op} %f ORDER BY id DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
						$amount,
						$limit
					)
				);
			} else {
				// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter
				$results = $wpdb->get_col(
					$wpdb->prepare(
						"SELECT post_id FROM {$wpdb->postmeta} WHERE meta_key = '_order_total' AND CAST(meta_value AS DECIMAL(10,2)) {$sql_op} %f ORDER BY post_id DESC LIMIT %d", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
						$amount,
						$limit
					)
				);
			}
		}

		return is_array( $results ) ? array_map( 'absint', $results ) : array();
	}

	/**
	 * Cap raw ID-resolution helpers (totals, customer aggregates) so a single
	 * filter never returns an unbounded list of IDs.
	 *
	 * @return int
	 */
	private static function get_id_resolution_query_limit() {
		$limit = (int) apply_filters( 'rawtype_oe_id_resolution_query_limit', self::PRODUCT_ID_RESOLUTION_LIMIT );

		if ( $limit < 1 ) {
			return self::PRODUCT_ID_RESOLUTION_LIMIT;
		}

		return $limit;
	}

	/**
	 * Apply customer total spend filter against WooCommerce order data.
	 *
	 * @param array  $args     Query args.
	 * @param string $operator Comparison operator.
	 * @param mixed  $value    Numeric value or array for 'between'.
	 * @return array
	 */
	private static function apply_customer_spend_filter( $args, $operator, $value ) {
		$user_ids = self::query_customer_order_metric( 'total_spent', $operator, $value );

		if ( false === $user_ids ) {
			return $args;
		}

		return self::apply_customer_include_filter( $args, $user_ids );
	}

	/**
	 * Apply customer orders count filter against WooCommerce order data.
	 *
	 * @param array  $args     Query args.
	 * @param string $operator Comparison operator.
	 * @param mixed  $value    Numeric value or array for 'between'.
	 * @return array
	 */
	private static function apply_customer_orders_count_filter( $args, $operator, $value ) {
		$user_ids = self::query_customer_order_metric( 'order_count', $operator, $value );

		if ( false === $user_ids ) {
			return $args;
		}

		return self::apply_customer_include_filter( $args, $user_ids );
	}

	/**
	 * Query customer order metrics for registered user IDs matching a condition.
	 *
	 * WooCommerce 10.5 no longer stores `total_spend` / `order_count` in
	 * `wc_customer_lookup`, so these filters must be resolved from the order
	 * storage itself (HPOS or legacy posts/meta).
	 *
	 * @param string $metric   Metric name (total_spent or order_count).
	 * @param string $operator Comparison operator.
	 * @param mixed  $value    Numeric value or array for 'between'.
	 * @return array|false Array of user IDs or false on validation failure.
	 */
	private static function query_customer_order_metric( $metric, $operator, $value ) {
		global $wpdb;

		$valid_metrics = array( 'total_spent', 'order_count' );
		if ( ! in_array( $metric, $valid_metrics, true ) ) {
			return false;
		}

		$statuses = self::get_customer_metric_statuses( $metric );
		if ( empty( $statuses ) ) {
			$statuses = array( 'wc-processing', 'wc-completed' );
		}

		$statuses = array_values( array_filter( array_map( 'sanitize_key', $statuses ) ) );
		if ( empty( $statuses ) ) {
			return false;
		}

		$status_placeholders = implode( ', ', array_fill( 0, count( $statuses ), '%s' ) );
		$params              = array();

		if ( self::is_hpos_orders_table_enabled() ) {
			$table    = $wpdb->prefix . 'wc_orders';
			$query    = 'SELECT customer_id FROM ' . $table . '
				WHERE type = %s
				AND customer_id > 0
				AND status IN (' . $status_placeholders . ')
				GROUP BY customer_id';
			$params[] = 'shop_order';
			$params   = array_merge( $params, $statuses );
			$query    = self::append_customer_metric_having_clause( $query, $params, true, $metric, $operator, $value );
			if ( false === $query ) {
				return false;
			}

			$query   .= ' LIMIT %d';
			$params[] = self::get_id_resolution_query_limit();

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Aggregate customer metric query; $table from $wpdb->prefix.
			$results = $wpdb->get_col(
				$wpdb->prepare(
					$query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
					...$params
				)
			);
		} else {
			$query = "SELECT CAST(customer_meta.meta_value AS UNSIGNED) AS user_id
				FROM {$wpdb->posts} posts
				INNER JOIN {$wpdb->postmeta} customer_meta
					ON posts.ID = customer_meta.post_id
					AND customer_meta.meta_key = '_customer_user'"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared

			if ( 'total_spent' === $metric ) {
				$query .= " INNER JOIN {$wpdb->postmeta} total_meta
					ON posts.ID = total_meta.post_id
					AND total_meta.meta_key = '_order_total'"; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
			}

			$query   .= ' WHERE posts.post_type = %s
				AND posts.post_status IN (' . $status_placeholders . ')
				AND CAST(customer_meta.meta_value AS UNSIGNED) > 0
				GROUP BY customer_meta.meta_value';
			$params[] = 'shop_order';
			$params   = array_merge( $params, $statuses );
			$query    = self::append_customer_metric_having_clause( $query, $params, false, $metric, $operator, $value );
			if ( false === $query ) {
				return false;
			}

			$query   .= ' LIMIT %d';
			$params[] = self::get_id_resolution_query_limit();

			// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Customer metric filters require aggregated order queries.
			$results = $wpdb->get_col(
				$wpdb->prepare(
					$query, // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared
					...$params
				)
			);
		}

		return is_array( $results ) ? array_map( 'absint', $results ) : array();
	}

	/**
	 * Append the numeric HAVING clause for customer metrics.
	 *
	 * @param string $query      Base query.
	 * @param array  $params     Prepared-statement params.
	 * @param bool   $is_hpos    Whether the query targets HPOS tables.
	 * @param string $metric     Metric name (total_spent or order_count).
	 * @param string $operator   Comparison operator.
	 * @param mixed  $value      Numeric value or array for 'between'.
	 * @return string|false
	 */
	private static function append_customer_metric_having_clause( $query, &$params, $is_hpos, $metric, $operator, $value ) {
		$aggregate = self::get_customer_metric_aggregate_sql( $is_hpos, $metric );
		if ( '' === $aggregate ) {
			return false;
		}
		if ( 'between' === $operator ) {
			if ( ! is_array( $value ) || 2 !== count( $value ) ) {
				return false;
			}

			$min = self::normalize_customer_metric_value( $metric, $value[0] );
			$max = self::normalize_customer_metric_value( $metric, $value[1] );
			if ( $min > $max ) {
				return false;
			}

			$params[] = $min;
			$params[] = $max;

			return 'total_spent' === $metric
				? $query . ' HAVING ' . $aggregate . ' BETWEEN %f AND %f'
				: $query . ' HAVING ' . $aggregate . ' BETWEEN %d AND %d';
		}

		$sql_op_map = array(
			'>'  => '>',
			'<'  => '<',
			'='  => '=',
			'>=' => '>=',
			'<=' => '<=',
		);

		if ( ! isset( $sql_op_map[ $operator ] ) ) {
			return false;
		}

		$params[] = self::normalize_customer_metric_value( $metric, $value );

		return 'total_spent' === $metric
			? $query . ' HAVING ' . $aggregate . ' ' . $sql_op_map[ $operator ] . ' %f'
			: $query . ' HAVING ' . $aggregate . ' ' . $sql_op_map[ $operator ] . ' %d';
	}

	/**
	 * Get the aggregate SQL fragment used for a customer metric.
	 *
	 * @param bool   $is_hpos Whether the query targets HPOS tables.
	 * @param string $metric  Metric name.
	 * @return string
	 */
	private static function get_customer_metric_aggregate_sql( $is_hpos, $metric ) {
		if ( $is_hpos ) {
			return 'total_spent' === $metric ? 'SUM(total_amount)' : 'COUNT(id)';
		}

		return 'total_spent' === $metric
			? 'SUM(CAST(total_meta.meta_value AS DECIMAL(20,6)))'
			: 'COUNT(posts.ID)';
	}

	/**
	 * Get the normalized order statuses used for a customer metric.
	 *
	 * @param string $metric Metric name.
	 * @return string[]
	 */
	public static function get_customer_metric_statuses( $metric ) {
		return 'total_spent' === $metric
			? self::get_paid_customer_order_statuses()
			: self::get_countable_customer_order_statuses();
	}

	/**
	 * Get order statuses used when counting a customer's orders.
	 *
	 * @return string[]
	 */
	private static function get_countable_customer_order_statuses() {
		return array_values(
			array_filter(
				array_map( 'sanitize_key', array_keys( wc_get_order_statuses() ) )
			)
		);
	}

	/**
	 * Get paid order statuses used when calculating customer spend.
	 *
	 * @return string[]
	 */
	private static function get_paid_customer_order_statuses() {
		$statuses = array_map( 'sanitize_key', (array) wc_get_is_paid_statuses() );

		return array_values(
			array_filter(
				array_map(
					function ( $status ) {
						return 0 === strpos( $status, 'wc-' ) ? $status : 'wc-' . $status;
					},
					$statuses
				)
			)
		);
	}

	/**
	 * Normalize a metric comparison value.
	 *
	 * @param string $metric Metric name.
	 * @param mixed  $value  Raw value.
	 * @return float|int
	 */
	private static function normalize_customer_metric_value( $metric, $value ) {
		return 'total_spent' === $metric ? floatval( $value ) : absint( $value );
	}

	/**
	 * Determine whether the HPOS custom order tables are in use.
	 *
	 * Single source of truth for HPOS detection — direct-SQL helpers across the
	 * plugin branch on this to target wc_orders vs. legacy postmeta.
	 *
	 * @return bool
	 */
	public static function is_hpos_orders_table_enabled() {
		return class_exists( '\Automattic\WooCommerce\Utilities\OrderUtil' )
			&& method_exists( '\Automattic\WooCommerce\Utilities\OrderUtil', 'custom_orders_table_usage_is_enabled' )
			&& \Automattic\WooCommerce\Utilities\OrderUtil::custom_orders_table_usage_is_enabled();
	}

	/**
	 * Apply a customer last order date filter using order aggregates.
	 *
	 * @param array  $args         Query args.
	 * @param string $operator     Comparison operator.
	 * @param mixed  $value        Date value.
	 * @param string $date_pattern Regex pattern.
	 * @return array
	 */
	private static function apply_customer_last_order_date_filter( $args, $operator, $value, $date_pattern ) {
		$user_ids = self::query_customer_ids_by_last_order_date( $operator, $value, $date_pattern );

		if ( false === $user_ids ) {
			return $args;
		}

		return self::apply_customer_include_filter( $args, $user_ids );
	}

	/**
	 * Intersect customer include filters so multiple filters compose correctly.
	 *
	 * @param array $args     Query args.
	 * @param array $user_ids User IDs matched by the latest filter.
	 * @return array
	 */
	private static function apply_customer_include_filter( $args, $user_ids ) {
		$user_ids = array_values( array_unique( array_filter( array_map( 'absint', $user_ids ) ) ) );

		if ( isset( $args['include'] ) && is_array( $args['include'] ) ) {
			$current_ids = array_values( array_unique( array_filter( array_map( 'absint', $args['include'] ) ) ) );
			$user_ids    = array_values( array_intersect( $current_ids, $user_ids ) );
		}

		$args['include'] = empty( $user_ids ) ? array( 0 ) : $user_ids;

		return $args;
	}

	/**
	 * Query customer IDs by their latest order date.
	 *
	 * @param string $operator     Comparison operator.
	 * @param mixed  $value        Date value.
	 * @param string $date_pattern Regex pattern.
	 * @return array|false
	 */
	private static function query_customer_ids_by_last_order_date( $operator, $value, $date_pattern ) {
		global $wpdb;

		$is_hpos = self::is_hpos_orders_table_enabled();

		if ( $is_hpos ) {
			$date_column = 'date_created_gmt';
			$query       = "SELECT customer_id FROM {$wpdb->prefix}wc_orders
				WHERE type = 'shop_order'
				AND customer_id > 0
				GROUP BY customer_id
				HAVING "; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		} else {
			$date_column = 'p.post_date_gmt';
			$query       = "SELECT CAST(pm.meta_value AS UNSIGNED) AS customer_id
				FROM {$wpdb->posts} p
				INNER JOIN {$wpdb->postmeta} pm
					ON p.ID = pm.post_id
					AND pm.meta_key = '_customer_user'
				WHERE p.post_type = 'shop_order'
				AND CAST(pm.meta_value AS UNSIGNED) > 0
				GROUP BY pm.meta_value
				HAVING "; // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
		}

		$having = self::build_last_order_date_having_clause( $date_column, $operator, $value, $date_pattern );
		if ( false === $having ) {
			return false;
		}

		list( $having_sql, $params ) = $having;

		$params[] = self::get_id_resolution_query_limit();

		// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, PluginCheck.Security.DirectDB.UnescapedDBParameter -- Aggregate customer date filters require direct order-table access.
		$results = $wpdb->get_col(
			$wpdb->prepare(
				$query . $having_sql . ' LIMIT %d', // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.PreparedSQL.NotPrepared
				...$params
			)
		);

		return is_array( $results ) ? array_map( 'absint', $results ) : array();
	}

	/**
	 * Build a HAVING clause for a last-order-date aggregate filter.
	 *
	 * @param string $date_column  Date column expression.
	 * @param string $operator     Comparison operator.
	 * @param mixed  $value        Date value.
	 * @param string $date_pattern Regex pattern.
	 * @return array|false
	 */
	private static function build_last_order_date_having_clause( $date_column, $operator, $value, $date_pattern ) {
		switch ( $operator ) {
			case 'is':
				$date = self::resolve_filter_date_value( $value, $date_pattern );
				if ( '' === $date ) {
					return false;
				}

				return array(
					"MAX({$date_column}) >= %s AND MAX({$date_column}) <= %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					array( $date . ' 00:00:00', $date . ' 23:59:59' ),
				);

			case 'after':
				$date = self::resolve_filter_date_value( $value, $date_pattern );
				if ( '' === $date ) {
					return false;
				}

				return array(
					"MAX({$date_column}) > %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					array( $date . ' 23:59:59' ),
				);

			case 'before':
				$date = self::resolve_filter_date_value( $value, $date_pattern );
				if ( '' === $date ) {
					return false;
				}

				return array(
					"MAX({$date_column}) < %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					array( $date . ' 00:00:00' ),
				);

			case 'between':
				if ( ! is_array( $value ) || 2 !== count( $value ) ) {
					return false;
				}

				$start = self::resolve_filter_date_value( $value[0], $date_pattern );
				$end   = self::resolve_filter_date_value( $value[1], $date_pattern );
				if ( '' === $start || '' === $end ) {
					return false;
				}

				return array(
					"MAX({$date_column}) >= %s AND MAX({$date_column}) <= %s", // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared
					array( $start . ' 00:00:00', $end . ' 23:59:59' ),
				);
		}

		return false;
	}

	/**
	 * Add the default customer role only when no explicit role filter exists.
	 *
	 * @param array $args Query args.
	 * @return array
	 */
	private static function with_default_customer_role( $args ) {
		if ( isset( $args['include'] ) && is_array( $args['include'] ) ) {
			return $args;
		}

		if ( isset( $args['role'] ) || isset( $args['role__in'] ) || isset( $args['role__not_in'] ) ) {
			return $args;
		}

		$args['role'] = 'customer';

		return $args;
	}

	/**
	 * Apply product price filter using meta_query.
	 *
	 * @param array  $args     Query args.
	 * @param string $operator Comparison operator.
	 * @param mixed  $value    Numeric value or array for 'between'.
	 * @return array
	 */
	private static function apply_product_price_filter( $args, $operator, $value ) {
		if ( ! isset( $args['meta_query'] ) ) {
			// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
			$args['meta_query'] = array();
		}

		if ( 'between' === $operator ) {
			if ( ! is_array( $value ) || 2 !== count( $value ) ) {
				return $args;
			}
			$min = floatval( $value[0] );
			$max = floatval( $value[1] );
			if ( $min > $max ) {
				return $args;
			}
			$args['meta_query'][] = array(
				'key'     => '_price',
				'value'   => array( $min, $max ),
				'compare' => 'BETWEEN',
				'type'    => 'DECIMAL(10,2)',
			);
		} else {
			$amount               = floatval( $value );
			$args['meta_query'][] = array(
				'key'     => '_price',
				'value'   => $amount,
				'compare' => $operator,
				'type'    => 'DECIMAL(10,2)',
			);
		}

		return $args;
	}

	/** Transient TTL for taxonomy listings. */
	const TAXONOMY_CACHE_TTL = 1800;

	/** Maximum IDs returned by product-resolution helpers. */
	const PRODUCT_ID_RESOLUTION_LIMIT = 50000;

	/**
	 * Get a hierarchical tree of product categories.
	 *
	 * Cached for 30 minutes; invalidated by Woo when categories change via the
	 * `created_product_cat` / `edited_product_cat` / `delete_product_cat` hooks
	 * registered in the Plugin bootstrap.
	 *
	 * @return array Tree structure with { id, name, slug, parent, children }.
	 */
	public static function get_categories_tree() {
		$cache_key = 'rawtype_oe_categories_tree';
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		$terms = get_terms(
			array(
				'taxonomy'   => 'product_cat',
				'hide_empty' => false,
			)
		);

		if ( ! is_array( $terms ) || is_wp_error( $terms ) ) {
			return array();
		}

		$tree = self::build_category_tree( $terms, 0 );
		set_transient( $cache_key, $tree, self::TAXONOMY_CACHE_TTL );

		return $tree;
	}

	/**
	 * Recursively build a category tree from flat terms.
	 *
	 * @param array $terms     All terms.
	 * @param int   $parent_id Parent term ID.
	 * @return array
	 */
	private static function build_category_tree( $terms, $parent_id ) {
		$tree = array();

		foreach ( $terms as $term ) {
			$term_parent = is_object( $term ) && isset( $term->parent ) ? (int) $term->parent : 0;
			$term_id     = is_object( $term ) && isset( $term->term_id ) ? (int) $term->term_id : 0;

			if ( $term_parent === $parent_id ) {
				$node   = array(
					'id'       => $term_id,
					'name'     => is_object( $term ) && isset( $term->name ) ? $term->name : '',
					'slug'     => is_object( $term ) && isset( $term->slug ) ? $term->slug : '',
					'parent'   => $term_parent,
					'count'    => is_object( $term ) && isset( $term->count ) ? (int) $term->count : 0,
					'children' => self::build_category_tree( $terms, $term_id ),
				);
				$tree[] = $node;
			}
		}

		return $tree;
	}

	/**
	 * Get a flat list of product tags.
	 *
	 * Cached for 30 minutes (see TAXONOMY_CACHE_TTL).
	 *
	 * @return array Array of { id, name, slug }.
	 */
	public static function get_product_tags() {
		$cache_key = 'rawtype_oe_product_tags';
		$cached    = get_transient( $cache_key );
		if ( is_array( $cached ) ) {
			return $cached;
		}

		$terms = get_terms(
			array(
				'taxonomy'   => 'product_tag',
				'hide_empty' => false,
			)
		);

		if ( ! is_array( $terms ) || is_wp_error( $terms ) ) {
			return array();
		}

		$tags = array();
		foreach ( $terms as $term ) {
			if ( is_object( $term ) ) {
				$tags[] = array(
					'id'   => (int) $term->term_id,
					'name' => $term->name,
					'slug' => $term->slug,
				);
			}
		}

		set_transient( $cache_key, $tags, self::TAXONOMY_CACHE_TTL );

		return $tags;
	}

	/**
	 * Invalidate cached taxonomy lookups (categories tree + tag list).
	 *
	 * @return void
	 */
	public static function flush_taxonomy_cache() {
		delete_transient( 'rawtype_oe_categories_tree' );
		delete_transient( 'rawtype_oe_product_tags' );
	}

	/**
	 * Search products by name or SKU.
	 *
	 * @param string $term  Search term (min 3 characters).
	 * @param int    $limit Max results. Default 20.
	 * @return array Array of { id, name, sku, price }.
	 */
	public static function search_products( $term, $limit = 20 ) {
		$term = sanitize_text_field( $term );

		if ( strlen( $term ) < 3 ) {
			return array();
		}

		$limit = min( absint( $limit ), 50 );

		// Search by name.
		$by_name = wc_get_products(
			array(
				's'      => $term,
				'limit'  => $limit,
				'return' => 'objects',
			)
		);

		// Search by SKU.
		$by_sku = wc_get_products(
			array(
				'sku'    => $term,
				'limit'  => $limit,
				'return' => 'objects',
			)
		);

		// Merge and deduplicate.
		$seen    = array();
		$results = array();

		$all_products = array_merge(
			is_array( $by_name ) ? $by_name : array(),
			is_array( $by_sku ) ? $by_sku : array()
		);

		foreach ( $all_products as $product ) {
			$pid = $product->get_id();
			if ( isset( $seen[ $pid ] ) ) {
				continue;
			}
			$seen[ $pid ] = true;

			$results[] = array(
				'id'    => $pid,
				'name'  => $product->get_name(),
				'sku'   => $product->get_sku(),
				'price' => $product->get_price(),
			);

			if ( count( $results ) >= $limit ) {
				break;
			}
		}

		return $results;
	}

	/**
	 * Preview customers with filters.
	 *
	 * @param array $args       Query args.
	 * @param bool  $count_only Whether to return only the count.
	 * @param int   $limit      Number of records.
	 * @return array
	 */
	private static function preview_customers( $args, $count_only, $limit ) {
		$result = array(
			'total'   => 0,
			'records' => array(),
		);

		$base_args = self::with_default_customer_role( $args );
		$include   = self::get_customer_include_shortcut_ids( $base_args, array( 'rawtype_oe_customer_filters', 'rawtype_oe_customer_logic' ) );

		if ( null !== $include ) {
			$result['total'] = count( $include );

			if ( $count_only || empty( $include ) || ! class_exists( 'WP_User_Query' ) ) {
				return $result;
			}

			$base_args['include'] = array_slice( $include, 0, $limit );
		}

		// Count query.
		if ( null === $include ) {
			$count_args = array_merge(
				$base_args,
				array(
					'count_total' => true,
					'number'      => 0,
					'fields'      => 'ID',
				)
			);

			if ( class_exists( 'WP_User_Query' ) ) {
				$query           = new \WP_User_Query( $count_args );
				$result['total'] = (int) $query->get_total();
			}
		}

		if ( $count_only ) {
			return $result;
		}

		// Sample records.
		$sample_args = array_merge(
			$base_args,
			array(
				'number'  => $limit,
				'orderby' => 'registered',
				'order'   => 'DESC',
			)
		);

		if ( class_exists( 'WP_User_Query' ) ) {
			$query = new \WP_User_Query( $sample_args );
			$users = $query->get_results();
			if ( is_array( $users ) ) {
				foreach ( $users as $user ) {
					$result['records'][] = array(
						'id'         => $user->ID,
						'email'      => $user->user_email,
						'first_name' => $user->first_name,
						'last_name'  => $user->last_name,
						'registered' => $user->user_registered,
					);
				}
			}
		}

		return $result;
	}

	/**
	 * Return normalized customer include IDs when the query can be resolved
	 * without issuing another full WP_User_Query count across a very large
	 * IN clause.
	 *
	 * Only applies to plain include-based customer filters.
	 *
	 * @param array $args         Customer query args.
	 * @param array $allowed_keys Optional extra keys allowed in the shortcut.
	 * @return array|null
	 */
	public static function get_customer_include_shortcut_ids( $args, $allowed_keys = array() ) {
		if ( ! is_array( $args ) || ! isset( $args['include'] ) || ! is_array( $args['include'] ) ) {
			return null;
		}

		if ( ! is_array( $allowed_keys ) ) {
			$allowed_keys = array();
		}

		$allowed_keys = array_merge( array( 'include', 'role' ), $allowed_keys );
		$keys         = array_keys( $args );
		$extra_keys   = array_diff( $keys, $allowed_keys );

		if ( ! empty( $extra_keys ) ) {
			return null;
		}

		if ( isset( $args['role'] ) && 'customer' !== $args['role'] ) {
			return null;
		}

		return array_values(
			array_unique(
				array_filter(
					array_map( 'absint', $args['include'] )
				)
			)
		);
	}
}
