<?php

namespace WPDRMS\ASP\Priorities;

use Exception;
use WP_Error;
use WP_REST_Request;
use WP_REST_Response;
use WPDRMS\ASP\Priorities\PrioritiesService;
use WPDRMS\ASP\Query\SearchQueryArgs;
use WPDRMS\PluginCore\Rest\AbstractRest;

class PrioritiesRoute extends AbstractRest {

	/**
	 * Valid logic values.
	 *
	 * @var string[]
	 */
	private const VALID_LOGICS = array( 'and', 'or' );

	/**
	 * Valid phrase_logic values for a gate condition row (no 'disabled' — a phrase row is always
	 * an active constraint).
	 *
	 * @var string[]
	 */
	private const VALID_CONDITION_PHRASE_LOGICS = array( 'any', 'exact', 'start', 'end' );

	/**
	 * Valid gate-condition types.
	 *
	 * @var string[]
	 */
	private const VALID_CONDITION_TYPES = array( 'instance', 'phrase', 'taxonomy', 'meta' );

	/**
	 * Valid operators for a custom-field boost rule. The UI only produces IN / NOT IN / LIKE; the
	 * comparison and BETWEEN operators exist in rules migrated from pre-4.29 priority groups and
	 * must survive an editor round-trip. Mirrors the SQL contract in
	 * {@see \WPDRMS\ASP\Search\SearchPostTypes::buildRulePredicate()}.
	 *
	 * @var string[]
	 */
	private const VALID_CF_RULE_OPERATORS = array( 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', '=', '<>', '>', '>=', '<', '<=', 'BETWEEN' );

	public function registerRoutes( string $route_namespace ): void {
		register_rest_route(
			$route_namespace,
			'priorities/groups',
			array(
				array(
					'methods'             => 'GET',
					'callback'            => array( $this, 'getGroups' ),
					'permission_callback' => array( $this, 'allowOnlyAdmins' ),
				),
				array(
					'methods'             => 'POST',
					'callback'            => array( $this, 'saveGroups' ),
					'permission_callback' => array( $this, 'allowOnlyAdmins' ),
				),
			)
		);

		register_rest_route(
			$route_namespace,
			'priorities/effective',
			array(
				'methods'             => 'GET',
				'callback'            => array( $this, 'getEffective' ),
				'permission_callback' => array( $this, 'allowOnlyAdmins' ),
			)
		);
	}

	/**
	 * GET priorities/groups
	 *
	 * Returns priority groups (in storage order) and search instances.
	 * The UI sorts groups by priority DESC for display.
	 *
	 * @return WP_Error|WP_REST_Response
	 */
	public function getGroups() {
		try {
			$groups = PrioritiesService::instance()->getForDisplay();
			// Inject a stable index-based id so React can reconcile edit/delete operations.
			// PHP stores groups as a sequential array; the index is the natural identity.
			foreach ( $groups as $idx => &$group ) {
				$group['id'] = (string) $idx;
			}
			unset( $group );

			return new WP_REST_Response(
				array(
					'groups'    => array_values( $groups ),
					'instances' => array_values( wd_asp()->instances->getWithoutData() ),
				),
				200
			);
		} catch ( Exception $e ) {
			return new WP_Error( 'asp_priorities_error', $e->getMessage(), array( 'status' => 500 ) );
		}
	}

	/**
	 * POST priorities/groups
	 *
	 * Validates and saves the submitted priority groups.
	 *
	 * @param WP_REST_Request $request
	 * @return WP_Error|WP_REST_Response
	 */
	public function saveGroups( WP_REST_Request $request ) {
		try {
			$params = $request->get_json_params();
			$raw    = isset( $params['groups'] ) && is_array( $params['groups'] ) ? $params['groups'] : array();

			$validated = array();
			foreach ( $raw as $group ) {
				if ( ! is_array( $group ) ) {
					continue;
				}

				// Group-level priority is legacy/display-only (each boost carries its own); an
				// out-of-range value is clamped rather than silently dropping the whole group.
				$priority = isset( $group['priority'] ) ? intval( $group['priority'] ) : 100;
				$priority = max( 1, min( 5000, $priority ) );

				// condition_logic: combines the gate condition types (and/or)
				$condition_logic = isset( $group['condition_logic'] ) ? (string) $group['condition_logic'] : 'and';
				if ( ! in_array( $condition_logic, self::VALID_LOGICS, true ) ) {
					$condition_logic = 'and';
				}

				// The editor's `conditions` + `boosts` are authoritative; the legacy single
				// instance/phrase gate and group-level rules are write-neutralised. Round-tripping
				// them would resurrect a gate the user deleted (the evaluator falls back to the
				// legacy fields when `conditions` is empty) and would let the next upgrade's
				// migration re-seed deleted conditions/boosts from the stale legacy values.
				$validated[] = array(
					'priority'        => $priority,
					'phrase_logic'    => 'disabled',
					'logic'           => 'and',
					'name'            => isset( $group['name'] ) ? sanitize_text_field( (string) $group['name'] ) : '',
					'phrase'          => '',
					'instance'        => 0,
					'blog_id'         => isset( $group['blog_id'] ) ? intval( $group['blog_id'] ) : 0,
					'enabled'         => isset( $group['enabled'] ) ? (bool) $group['enabled'] : true,
					'rules'           => array(),
					'condition_logic' => $condition_logic,
					'conditions'      => self::sanitizeConditions( isset( $group['conditions'] ) && is_array( $group['conditions'] ) ? $group['conditions'] : array() ),
					'boosts'          => self::sanitizeBoosts( isset( $group['boosts'] ) && is_array( $group['boosts'] ) ? $group['boosts'] : array() ),
				);
			}

			PrioritiesService::instance()->set( $validated );

			// Priority changes affect ranking, so any cached results are now stale. Fire the
			// global cache-purge action rather than calling the cache service directly, to avoid
			// a dependency from Priorities onto the Cache domain. See Cache\CacheHooks.
			do_action( 'asp/cache/purge' );

			return new WP_REST_Response( array( 'success' => true ), 200 );
		} catch ( Exception $e ) {
			return new WP_Error( 'asp_priorities_error', $e->getMessage(), array( 'status' => 500 ) );
		}
	}

	/**
	 * Validates and sanitises the flexible "Applies when" gate conditions. Invalid or empty rows
	 * are dropped; only the whitelisted per-type params survive (display-only `_values` are
	 * stripped). Mirrors the search-time contract in {@see PriorityGateEvaluator}.
	 *
	 * @param array<int, mixed> $raw
	 * @return array<int, array<string, mixed>>
	 */
	private static function sanitizeConditions( array $raw ): array {
		$clean = array();
		foreach ( $raw as $condition ) {
			if ( ! is_array( $condition ) ) {
				continue;
			}
			$type = isset( $condition['type'] ) ? (string) $condition['type'] : '';
			if ( ! in_array( $type, self::VALID_CONDITION_TYPES, true ) ) {
				continue;
			}

			switch ( $type ) {
				case 'instance':
					$ids     = array_values(
						array_filter(
							array_map(
								'intval',
								isset( $condition['instance_ids'] ) && is_array( $condition['instance_ids'] ) ? $condition['instance_ids'] : array()
							) 
						) 
					);
					$clean[] = array(
						'type'         => 'instance',
						'instance_ids' => $ids,
					);
					break;

				case 'phrase':
					$phrase       = isset( $condition['phrase'] ) ? sanitize_text_field( (string) $condition['phrase'] ) : '';
					$phrase_logic = isset( $condition['phrase_logic'] ) ? (string) $condition['phrase_logic'] : 'any';
					if ( $phrase === '' || ! in_array( $phrase_logic, self::VALID_CONDITION_PHRASE_LOGICS, true ) ) {
						continue 2;
					}
					$clean[] = array(
						'type'         => 'phrase',
						'phrase'       => $phrase,
						'phrase_logic' => $phrase_logic,
					);
					break;

				case 'taxonomy':
					$taxonomy = isset( $condition['taxonomy'] ) ? sanitize_text_field( (string) $condition['taxonomy'] ) : '';
					$term_ids = array_values(
						array_filter(
							array_map(
								'intval',
								isset( $condition['term_ids'] ) && is_array( $condition['term_ids'] ) ? $condition['term_ids'] : array()
							) 
						) 
					);
					$operator = strtoupper( isset( $condition['operator'] ) ? (string) $condition['operator'] : 'IN' ) === 'NOT IN' ? 'NOT IN' : 'IN';
					if ( $taxonomy === '' || empty( $term_ids ) ) {
						continue 2;
					}
					$clean[] = array(
						'type'     => 'taxonomy',
						'taxonomy' => $taxonomy,
						'term_ids' => $term_ids,
						'operator' => $operator,
					);
					break;

				case 'meta':
					$meta_key = isset( $condition['meta_key'] ) ? sanitize_text_field( (string) $condition['meta_key'] ) : '';
					$operator = strtoupper( isset( $condition['operator'] ) ? (string) $condition['operator'] : 'IN' );
					if ( ! in_array( $operator, array( 'IN', 'NOT IN', '=', '>', '>=', '<', '<=' ), true ) ) {
						$operator = 'IN';
					}
					if ( is_array( $condition['value'] ?? null ) ) {
						$value = array_values( array_map( static fn( $v ) => sanitize_text_field( (string) $v ), $condition['value'] ) );
						// Only IN / NOT IN take a value list — the comparisons take a single value.
						if ( ! in_array( $operator, array( 'IN', 'NOT IN' ), true ) ) {
							$value = (string) ( $value[0] ?? '' );
						}
					} else {
						$value = isset( $condition['value'] ) ? sanitize_text_field( (string) $condition['value'] ) : '';
					}
					if ( $meta_key === '' ) {
						continue 2;
					}
					$clean[] = array(
						'type'     => 'meta',
						'meta_key' => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- data-structure key, not a query arg.
						'value'    => $value,
						'operator' => $operator,
					);
					break;
			}
		}
		return $clean;
	}

	/**
	 * Validates and sanitises the `boosts` list. Each boost carries its own priority (-5000–5000,
	 * negative = demote) and is either a `rule` boost (tax/cf/post matchers + rule_logic) or an
	 * `ordered_posts` boost (an ordered post-id list). Empty boosts are dropped.
	 *
	 * @param array<int, mixed> $raw
	 * @return array<int, array<string, mixed>>
	 */
	private static function sanitizeBoosts( array $raw ): array {
		$clean = array();
		foreach ( $raw as $boost ) {
			if ( ! is_array( $boost ) ) {
				continue;
			}
			// A priority below the search-time default (CASE ... ELSE 1) DEMOTES the matched posts.
			// Negative values exist in data migrated from pre-4.29 individual priorities and must
			// survive a round-trip through the editor.
			$priority = isset( $boost['priority'] ) ? intval( $boost['priority'] ) : 100;
			$priority = max( -5000, min( 5000, $priority ) );

			if ( ( $boost['kind'] ?? 'rule' ) === 'ordered_posts' ) {
				$post_ids = array_values(
					array_filter(
						array_map(
							'intval',
							isset( $boost['post_ids'] ) && is_array( $boost['post_ids'] ) ? $boost['post_ids'] : array()
						) 
					) 
				);
				if ( empty( $post_ids ) ) {
					continue;
				}
				$clean[] = array(
					'kind'     => 'ordered_posts',
					'priority' => $priority,
					'post_ids' => $post_ids,
				);
				continue;
			}

			$rules = self::sanitizeBoostRules(
				isset( $boost['rules'] ) && is_array( $boost['rules'] ) ? $boost['rules'] : array()
			);
			if ( empty( $rules ) ) {
				continue;
			}
			$rule_logic = ( ( $boost['rule_logic'] ?? 'and' ) === 'or' ) ? 'or' : 'and';
			$clean[]    = array(
				'kind'       => 'rule',
				'priority'   => $priority,
				'rules'      => $rules,
				'rule_logic' => $rule_logic,
			);
		}
		return $clean;
	}

	/**
	 * Validates and sanitises a rule boost's matcher rules. Only the known field types survive,
	 * each with a whitelisted operator and typed values; the display-only `_values` are reset (the
	 * service re-resolves them on read). Invalid or empty rows are dropped. The stored values are
	 * interpolated into SQL by {@see \WPDRMS\ASP\Search\SearchPostTypes::buildRulePredicate()}, so
	 * nothing may pass through unvalidated.
	 *
	 * @param array<int, mixed> $raw
	 * @return array<int, array<string, mixed>>
	 */
	private static function sanitizeBoostRules( array $raw ): array {
		$clean = array();
		foreach ( $raw as $rule ) {
			if ( ! is_array( $rule ) ) {
				continue;
			}
			$name     = isset( $rule['name'] ) ? sanitize_text_field( (string) $rule['name'] ) : '';
			$operator = strtoupper( isset( $rule['operator'] ) ? (string) $rule['operator'] : 'IN' );
			$values   = isset( $rule['values'] ) && is_array( $rule['values'] ) ? $rule['values'] : array();

			switch ( $rule['field'] ?? '' ) {
				case 'tax':
					$taxonomy = isset( $values['taxonomy'] ) ? sanitize_text_field( (string) $values['taxonomy'] ) : '';
					$term_ids = array_values(
						array_filter(
							array_map(
								'intval',
								isset( $values['term_ids'] ) && is_array( $values['term_ids'] ) ? $values['term_ids'] : array()
							)
						)
					);
					if ( $taxonomy === '' || empty( $term_ids ) ) {
						continue 2;
					}
					$clean[] = array(
						'name'     => $name,
						'field'    => 'tax',
						'operator' => $operator === 'NOT IN' ? 'NOT IN' : 'IN',
						'values'   => array(
							'taxonomy' => $taxonomy,
							'term_ids' => $term_ids,
						),
						'_values'  => array(),
					);
					break;

				case 'cf':
					$meta_key = isset( $values['meta_key'] ) ? sanitize_text_field( (string) $values['meta_key'] ) : '';
					if ( $meta_key === '' ) {
						continue 2;
					}
					if ( ! in_array( $operator, self::VALID_CF_RULE_OPERATORS, true ) ) {
						$operator = 'IN';
					}
					if ( is_array( $values['value'] ?? null ) ) {
						$value = array_values(
							array_filter(
								array_map( static fn( $v ) => sanitize_text_field( (string) $v ), $values['value'] ),
								static fn( $v ) => $v !== ''
							)
						);
						// Only IN / NOT IN take a value list — every other operator takes one value.
						if ( ! in_array( $operator, array( 'IN', 'NOT IN' ), true ) ) {
							$value = (string) ( $value[0] ?? '' );
						}
					} else {
						$value = isset( $values['value'] ) ? sanitize_text_field( (string) $values['value'] ) : '';
					}
					$cf_values = array(
						'meta_key' => $meta_key, // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- data-structure key, not a query arg.
						'value'    => $value,
					);
					if ( $operator === 'BETWEEN' ) {
						$cf_values['value2'] = isset( $values['value2'] ) ? sanitize_text_field( (string) $values['value2'] ) : '';
					}
					$clean[] = array(
						'name'     => $name,
						'field'    => 'cf',
						'operator' => $operator,
						'values'   => $cf_values,
						'_values'  => array(),
					);
					break;

				case 'post':
					$post_ids = array_values(
						array_filter(
							array_map(
								'intval',
								isset( $values['post_ids'] ) && is_array( $values['post_ids'] ) ? $values['post_ids'] : array()
							)
						)
					);
					if ( empty( $post_ids ) ) {
						continue 2;
					}
					$clean[] = array(
						'name'     => $name,
						'field'    => 'post',
						'operator' => $operator === 'NOT IN' ? 'NOT IN' : 'IN',
						'values'   => array( 'post_ids' => $post_ids ),
						'_values'  => array(),
					);
					break;
			}
		}
		return $clean;
	}

	/**
	 * Returns a group's boosts for the effective preview, falling back to a single rule boost
	 * synthesised from the legacy group-level priority/rules/logic for unmigrated data.
	 *
	 * @param array<string, mixed> $group
	 * @return array<int, array<string, mixed>>
	 */
	private static function effectiveBoosts( array $group ): array {
		if ( isset( $group['boosts'] ) && is_array( $group['boosts'] ) && ! empty( $group['boosts'] ) ) {
			return $group['boosts'];
		}
		$rules = isset( $group['rules'] ) && is_array( $group['rules'] ) ? $group['rules'] : array();
		if ( empty( $rules ) ) {
			return array();
		}
		return array(
			array(
				'kind'       => 'rule',
				'priority'   => (int) ( $group['priority'] ?? 0 ),
				'rules'      => $rules,
				'rule_logic' => ( ( $group['logic'] ?? 'and' ) === 'or' ) ? 'or' : 'and',
			),
		);
	}

	/**
	 * Records a post's effective priority, keeping the highest when several boosts target it.
	 *
	 * @param array<int, array{effective_priority:int, group_name:string}> $post_map
	 * @param int                                                          $post_id    The post id.
	 * @param int                                                          $priority   The effective priority for this post.
	 * @param string                                                       $group_name The owning group's name (for display).
	 */
	private static function recordEffectivePost( array &$post_map, int $post_id, int $priority, string $group_name ): void {
		if ( ! isset( $post_map[ $post_id ] ) || $priority > $post_map[ $post_id ]['effective_priority'] ) {
			$post_map[ $post_id ] = array(
				'effective_priority' => $priority,
				'group_name'         => $group_name,
			);
		}
	}

	/**
	 * GET priorities/effective
	 *
	 * Evaluates which posts are actively boosted and which attribute-based groups match.
	 *
	 * Query parameters:
	 *   instance_id  (int, default 0 — 0 means "all instances", instance axis not gated)
	 *   phrase       (string, default '')
	 *   tax_filters  (array of {taxonomy: string, terms: int[]}) — simulated active taxonomy filters
	 *   meta_filters (array of {key: string, value: string}) — simulated active custom-field filters
	 *   post_type    (string|string[], default 'any')
	 *   search       (string, default '')
	 *   limit        (int 1–200, default 50)
	 *
	 * @param WP_REST_Request $request
	 * @return WP_Error|WP_REST_Response
	 */
	public function getEffective( WP_REST_Request $request ) {
		try {
			// --- Read and sanitize query params ---
			$instance_id = intval( $request->get_param( 'instance_id' ) );
			$blog_id     = intval( $request->get_param( 'blog_id' ) );
			$phrase      = (string) ( $request->get_param( 'phrase' ) ?? '' );
			$search      = (string) ( $request->get_param( 'search' ) ?? '' );
			$limit       = intval( $request->get_param( 'limit' ) );
			if ( $limit < 1 || $limit > 200 ) {
				$limit = 50;
			}

			// post_type may arrive as a single string or an array (multi-select). Normalise to a
			// list of sanitized slugs; an empty list means "any post type".
			$post_type_param = $request->get_param( 'post_type' );
			$post_types      = array();
			if ( is_array( $post_type_param ) ) {
				$post_types = array_values( array_filter( array_map( 'sanitize_key', $post_type_param ) ) );
			} elseif ( is_string( $post_type_param ) && $post_type_param !== '' && $post_type_param !== 'any' ) {
				$post_types = array( sanitize_key( $post_type_param ) );
			}

			// Simulated active search filters, so taxonomy/custom-field GATE conditions can be
			// previewed. Mirror the SearchQueryArgs shapes the real search produces.
			$post_tax_filter = array();
			foreach ( (array) $request->get_param( 'tax_filters' ) as $tf ) {
				if ( ! is_array( $tf ) ) {
					continue;
				}
				$taxonomy = sanitize_text_field( (string) ( $tf['taxonomy'] ?? '' ) );
				$terms    = array_values( array_filter( array_map( 'intval', (array) ( $tf['terms'] ?? array() ) ) ) );
				if ( $taxonomy !== '' && ! empty( $terms ) ) {
					$post_tax_filter[] = array(
						'taxonomy'    => $taxonomy,
						'include'     => $terms,
						'exclude'     => array(),
						'allow_empty' => false,
					);
				}
			}
			$post_meta_filter = array();
			foreach ( (array) $request->get_param( 'meta_filters' ) as $mf ) {
				if ( ! is_array( $mf ) ) {
					continue;
				}
				$key = sanitize_text_field( (string) ( $mf['key'] ?? '' ) );
				if ( $key !== '' ) {
					$post_meta_filter[] = array(
						'key'      => $key,
						'value'    => sanitize_text_field( (string) ( $mf['value'] ?? '' ) ),
						'operator' => 'IN',
					);
				}
			}

			$args = new SearchQueryArgs(
				array(
					'_id'              => $instance_id,
					'post_tax_filter'  => $post_tax_filter,
					'post_meta_filter' => $post_meta_filter,
				) 
			);

			// --- Load all groups sorted by priority DESC ---
			$all_groups = PrioritiesService::instance()->getSorted( 'priority', 'desc' );

			// --- Gate groups via the same evaluator the search uses ---
			$matching_groups = array();
			foreach ( $all_groups as $group ) {
				if ( ! ( $group['enabled'] ?? true ) ) {
					continue;
				}
				$group_blog = (int) ( $group['blog_id'] ?? 0 );
				if ( $blog_id !== 0 && $group_blog !== 0 && $group_blog !== $blog_id ) {
					continue;
				}

				// instance_id === 0 means "all instances": neutralise the instance axis so a group
				// isn't hidden just because no specific instance is being simulated.
				$gate_group = $group;
				if ( $instance_id === 0 ) {
					$gate_group['instance'] = 0;
					if ( isset( $gate_group['conditions'] ) && is_array( $gate_group['conditions'] ) ) {
						$gate_group['conditions'] = array_values(
							array_filter(
								$gate_group['conditions'],
								static fn( $c ) => ( $c['type'] ?? '' ) !== 'instance'
							) 
						);
					}
				}

				if ( PriorityGateEvaluator::matches( $gate_group, $args, $phrase ) ) {
					$matching_groups[] = $group;
				}
			}

			// --- Collect post IDs from all matching groups ---
			// post_id => ['effective_priority' => int, 'group_name' => string]
			$post_map         = array();
			$attribute_groups = array();

			foreach ( $matching_groups as $group ) {
				$group_name = (string) ( $group['name'] ?? '' );

				foreach ( self::effectiveBoosts( $group ) as $boost ) {
					$boost_priority = (int) ( $boost['priority'] ?? 0 );

					// Ordered-posts boost: each post takes a descending priority by position.
					if ( ( $boost['kind'] ?? 'rule' ) === 'ordered_posts' ) {
						$i = 0;
						foreach ( $boost['post_ids'] ?? array() as $post_id ) {
							$post_id = (int) $post_id;
							if ( $post_id > 0 ) {
								self::recordEffectivePost( $post_map, $post_id, $boost_priority - $i, $group_name );
							}
							++$i;
						}
						continue;
					}

					$rules        = isset( $boost['rules'] ) && is_array( $boost['rules'] ) ? $boost['rules'] : array();
					$rule_logic   = strtoupper( (string) ( $boost['rule_logic'] ?? 'and' ) ) === 'OR' ? 'OR' : 'AND';
					$has_attr     = false;
					$tax_queries  = array();
					$meta_queries = array();

					foreach ( $rules as $rule ) {
						$field    = (string) ( $rule['field'] ?? '' );
						$operator = strtoupper( (string) ( $rule['operator'] ?? 'IN' ) );

						if ( $field === 'post' ) {
							foreach ( $rule['values']['post_ids'] ?? array() as $post_id ) {
								$post_id = (int) $post_id;
								if ( $post_id > 0 ) {
									self::recordEffectivePost( $post_map, $post_id, $boost_priority, $group_name );
								}
							}
						} elseif ( $field === 'tax' ) {
							$has_attr = true;
							$taxonomy = (string) ( $rule['values']['taxonomy'] ?? '' );
							$term_ids = isset( $rule['values']['term_ids'] ) && is_array( $rule['values']['term_ids'] )
								? array_filter( array_map( 'intval', $rule['values']['term_ids'] ) )
								: array();
							if ( $taxonomy !== '' && ! empty( $term_ids ) ) {
								$tax_queries[] = array(
									'taxonomy' => $taxonomy,
									'field'    => 'term_id',
									'terms'    => array_values( $term_ids ),
									'operator' => in_array( $operator, array( 'IN', 'NOT IN' ), true ) ? $operator : 'IN',
								);
							}
						} elseif ( $field === 'cf' ) {
							$has_attr = true;
							$meta_key = (string) ( $rule['values']['meta_key'] ?? '' );
							if ( $meta_key !== '' ) {
								// WP_Meta_Query knows '!=' but not '<>' (legacy-migrated rules
								// carry the latter, plus numeric comparisons and BETWEEN).
								$compare = $operator === '<>' ? '!=' : $operator;
								if ( ! in_array( $compare, array( 'IN', 'NOT IN', 'LIKE', 'NOT LIKE', '=', '!=', '>', '>=', '<', '<=', 'BETWEEN' ), true ) ) {
									$compare = 'IN';
								}
								$raw_value = $rule['values']['value'] ?? '';
								if ( $compare === 'BETWEEN' ) {
									$value = array(
										is_array( $raw_value ) ? (string) ( $raw_value[0] ?? '' ) : (string) $raw_value,
										(string) ( $rule['values']['value2'] ?? '' ),
									);
								} elseif ( is_array( $raw_value ) ) {
									// IN / NOT IN value lists pass through as arrays; any other
									// operator takes the first value.
									$values_list = array_values( array_map( 'strval', $raw_value ) );
									$value       = in_array( $compare, array( 'IN', 'NOT IN' ), true )
										? $values_list
										: (string) ( $values_list[0] ?? '' );
								} else {
									$value = (string) $raw_value;
								}
								$meta_query_row = array(
									'key'     => $meta_key,
									'value'   => $value,
									'compare' => $compare,
								);
								if ( in_array( $compare, array( '>', '>=', '<', '<=', 'BETWEEN' ), true ) ) {
									$meta_query_row['type'] = 'NUMERIC';
								}
								$meta_queries[] = $meta_query_row;
							}
						}
					}

					// For attribute-based rules, run a WP_Query to resolve matching posts.
					if ( ! empty( $tax_queries ) || ! empty( $meta_queries ) ) {
						$query_args = array(
							'post_type'      => ! empty( $post_types ) ? $post_types : 'any',
							'posts_per_page' => $limit,
							'post_status'    => 'publish',
							'fields'         => 'ids',
						);
						if ( $search !== '' ) {
							$query_args['s'] = $search;
						}
						if ( ! empty( $tax_queries ) ) {
							$tax_query = $tax_queries;
							if ( count( $tax_queries ) > 1 ) {
								array_unshift( $tax_query, array( 'relation' => $rule_logic ) );
							}
							$query_args['tax_query'] = $tax_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query
						}
						if ( ! empty( $meta_queries ) ) {
							$meta_query = $meta_queries;
							if ( count( $meta_queries ) > 1 ) {
								array_unshift( $meta_query, array( 'relation' => $rule_logic ) );
							}
							$query_args['meta_query'] = $meta_query; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
						}

						$wp_query = new \WP_Query( $query_args );
						foreach ( (array) $wp_query->posts as $post_id ) {
							$post_id = (int) $post_id;
							if ( $post_id > 0 ) {
								self::recordEffectivePost( $post_map, $post_id, $boost_priority, $group_name );
							}
						}
					}

					// Surface attribute-based boosts in the sidebar summary.
					if ( $has_attr ) {
						$attribute_groups[] = array(
							'name'        => $group_name,
							'priority'    => $boost_priority,
							'rules_count' => count( $rules ),
						);
					}
				}
			}

			// --- Build posts list from collected post IDs ---
			$posts = array();
			foreach ( $post_map as $post_id => $info ) {
				// For post-rule entries, apply post_type and title filters here
				// (attribute-rule entries were already filtered in WP_Query above).
				if ( ! empty( $post_types ) && ! in_array( get_post_type( $post_id ), $post_types, true ) ) {
					continue;
				}
				if ( $search !== '' && stripos( get_the_title( $post_id ), $search ) === false ) {
					continue;
				}

				$posts[] = array(
					'id'                 => $post_id,
					'title'              => get_the_title( $post_id ),
					'post_type'          => get_post_type( $post_id ),
					'effective_priority' => $info['effective_priority'],
					'group_name'         => $info['group_name'],
				);
			}

			// Sort posts by effective_priority DESC for consistent output
			usort(
				$posts,
				static function ( $a, $b ) {
					return $b['effective_priority'] - $a['effective_priority'];
				}
			);

			// Apply limit
			$posts = array_slice( $posts, 0, $limit );

			return new WP_REST_Response(
				array(
					'posts'            => $posts,
					'attribute_groups' => $attribute_groups,
				),
				200
			);
		} catch ( Exception $e ) {
			return new WP_Error( 'asp_priorities_error', $e->getMessage(), array( 'status' => 500 ) );
		}
	}
}
