<?php
/**
 * REST API: AI-powered recommendation rule generator.
 *
 * Built on WordPress 7.0's AI Client (wp_ai_client_prompt). Provider
 * credentials (Anthropic, Google, OpenAI, …) are managed by core under
 * Settings → Connectors; this class only inspects the registry so the UI
 * can guide users to the right screen when something is missing.
 *
 * @package PRECO
 */

namespace PRECO\Includes\Admin\Rest;

use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
use PRECO\Includes\Admin\Ai\AiSchema;
use PRECO\Includes\Frontend\Core\RecommendationsConfig;

defined( 'ABSPATH' ) || exit;

/**
 * Handles:
 *  GET  /preco/v1/ai/status     — AI availability + provider configuration
 *  POST /preco/v1/ai/generate   — generate rules from a prompt
 *  POST /preco/v1/ai/chat       — refine rules through conversation
 */
class AiRest {

	/**
	 * REST namespace.
	 */
	const PRECO_REST_NAMESPACE = 'preco/v1';

	/**
	 * HTTP timeout (seconds) for AI provider calls. The WP AI Client defaults
	 * to 30s, which large prompts (long messages + history) regularly exceed.
	 * Filterable via 'preco_ai_request_timeout'.
	 */
	const AI_REQUEST_TIMEOUT = 90;

	/**
	 * Payload budgets — keep prompts small so requests stay fast.
	 */
	const MAX_MESSAGE_CHARS      = 2000; // user message / prompt.
	const MAX_HISTORY_TURNS      = 8;    // conversation turns sent to the AI.
	const MAX_HISTORY_TURN_CHARS = 500;  // per-turn content cap.
	const MAX_HISTORY_RULES      = 2;    // newest assistant turns that keep their rules JSON.

	/**
	 * Constructor.
	 */
	public function __construct() {
		add_action( 'rest_api_init', array( $this, 'register_routes' ) );
	}

	/**
	 * Register REST routes.
	 */
	public function register_routes() {
		register_rest_route(
			self::PRECO_REST_NAMESPACE,
			'/ai/status',
			array(
				'methods'             => WP_REST_Server::READABLE,
				'callback'            => array( $this, 'get_status' ),
				'permission_callback' => array( $this, 'permission_check' ),
			)
		);

		register_rest_route(
			self::PRECO_REST_NAMESPACE,
			'/ai/generate',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'generate_rules' ),
				'permission_callback' => array( $this, 'permission_check' ),
				'args'                => array(
					'prompt'       => array(
						'required'          => true,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					),
					'current_page' => array(
						'required'          => false,
						'type'              => 'string',
						'default'           => 'product-page',
						'sanitize_callback' => 'sanitize_text_field',
					),
				),
			)
		);

		register_rest_route(
			self::PRECO_REST_NAMESPACE,
			'/ai/chat',
			array(
				'methods'             => WP_REST_Server::CREATABLE,
				'callback'            => array( $this, 'chat_rules' ),
				'permission_callback' => array( $this, 'permission_check' ),
				'args'                => array(
					'message'       => array(
						'required'          => true,
						'type'              => 'string',
						'sanitize_callback' => 'sanitize_text_field',
					),
					'current_page'  => array(
						'required'          => false,
						'type'              => 'string',
						'default'           => 'product-page',
						'sanitize_callback' => 'sanitize_text_field',
					),
					'history'       => array(
						'required' => false,
						'type'     => 'array',
						'default'  => array(),
					),
					'current_rules' => array(
						'required' => false,
						'type'     => 'object',
						'default'  => null,
					),
				),
			)
		);
	}

	/**
	 * Permission check.
	 */
	public function permission_check() {
		return current_user_can( 'manage_woocommerce' ) || current_user_can( 'manage_options' );
	}

	// -----------------------------------------------------------------------
	// Status
	// -----------------------------------------------------------------------

	/**
	 * GET /preco/v1/ai/status
	 *
	 * Returns:
	 * {
	 *   success: true,
	 *   data: {
	 *     supported:    bool   — wp_ai_client_prompt() exists and AI isn't disabled,
	 *     configured:   bool   — at least one provider has valid credentials,
	 *     providers:    [ { id, name, configured } ],
	 *     settings_url: string — core Connectors screen for API keys
	 *   }
	 * }
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response
	 */
	public function get_status( WP_REST_Request $request ) {
		return new WP_REST_Response(
			array(
				'success' => true,
				'data'    => $this->build_ai_status(),
			),
			200
		);
	}

	/**
	 * Inspect the WP AI Client provider registry.
	 *
	 * @return array{supported: bool, configured: bool, providers: array, settings_url: string}
	 */
	private function build_ai_status(): array {
		$supported = function_exists( 'wp_ai_client_prompt' )
			&& ( ! function_exists( 'wp_supports_ai' ) || wp_supports_ai() );

		$providers  = array();
		$configured = false;

		if ( $supported && class_exists( '\WordPress\AiClient\AiClient' ) ) {
			try {
				$registry = \WordPress\AiClient\AiClient::defaultRegistry();
				foreach ( $registry->getRegisteredProviderIds() as $provider_id ) {
					if ( empty( $provider_id ) ) {
						continue;
					}
					$is_configured = false;
					try {
						$is_configured = (bool) $registry->isProviderConfigured( $provider_id );
					} catch ( \Exception $e ) {
						$is_configured = false;
					}

					$name = ucwords( str_replace( array( '-', '_' ), ' ', $provider_id ) );
					if ( function_exists( 'wp_get_connector' ) ) {
						$connector = wp_get_connector( $provider_id );
						if ( is_array( $connector ) && ! empty( $connector['name'] ) ) {
							$name = (string) $connector['name'];
						}
					}

					$providers[] = array(
						'id'         => $provider_id,
						'name'       => $name,
						'configured' => $is_configured,
					);
					if ( $is_configured ) {
						$configured = true;
					}
				}
			} catch ( \Exception $e ) {
				// Registry unavailable — report as unconfigured, UI will guide the user.
				$providers = array();
			}
		}

		return array(
			'supported'    => $supported,
			'configured'   => $configured,
			'providers'    => $providers,
			'settings_url' => admin_url( 'options-connectors.php' ),
		);
	}

	// -----------------------------------------------------------------------
	// Generate
	// -----------------------------------------------------------------------

	/**
	 * POST /preco/v1/ai/generate
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response
	 */
	public function generate_rules( WP_REST_Request $request ) {
		if ( ! wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ) {
			return new WP_REST_Response(
				array(
					'code'    => 'forbidden',
					'message' => 'Invalid nonce.',
				),
				403
			);
		}

		$user_prompt  = $this->truncate_text( (string) $request->get_param( 'prompt' ), self::MAX_MESSAGE_CHARS );
		$current_page = $request->get_param( 'current_page' );

		$result = $this->call_wp_ai(
			AiSchema::system_prompt(),
			AiSchema::user_prompt( $user_prompt, $current_page )
		);

		if ( is_wp_error( $result ) ) {
			return $this->wp_error_response( $result );
		}

		$result    = $this->resolve_unmatched_filter_term( $result );
		$validated = $this->validate_output( $result, $current_page );
		if ( is_wp_error( $validated ) ) {
			return new WP_REST_Response(
				array(
					'code'    => $validated->get_error_code(),
					'message' => $validated->get_error_message(),
				),
				422
			);
		}

		return new WP_REST_Response(
			array(
				'success' => true,
				'data'    => $validated,
			),
			200
		);
	}

	/**
	 * POST /preco/v1/ai/chat
	 *
	 * Refine an existing set of rules through a conversation.
	 * The client sends:
	 *   - message      : the user's latest refinement request
	 *   - current_page : page context (for schema filtering)
	 *   - history      : [{role:"user"|"assistant", content:"..."}] of previous turns
	 *
	 * Returns:
	 *   { success: true, data: { name, filters, sorting, limit? }, explanation: "..." }
	 *
	 * @param WP_REST_Request $request Full details about the request.
	 * @return WP_REST_Response
	 */
	public function chat_rules( WP_REST_Request $request ) {
		if ( ! wp_verify_nonce( $request->get_header( 'X-WP-Nonce' ), 'wp_rest' ) ) {
			return new WP_REST_Response(
				array(
					'code'    => 'forbidden',
					'message' => 'Invalid nonce.',
				),
				403
			);
		}

		$message      = $this->truncate_text( (string) $request->get_param( 'message' ), self::MAX_MESSAGE_CHARS );
		$current_page = $request->get_param( 'current_page' );
		$raw_history  = $request->get_param( 'history' );
		$raw_rules    = $request->get_param( 'current_rules' );

		// Sanitise current_rules — accept only a plain array (JSON object).
		$current_rules = is_array( $raw_rules ) ? $raw_rules : null;

		// Sanitise history — allow role/content pairs, preserve rules for
		// assistant turns, and keep the payload small (see MAX_HISTORY_* consts)
		// so provider requests don't hit the HTTP timeout.
		$history = array();
		if ( is_array( $raw_history ) ) {
			foreach ( array_slice( $raw_history, -self::MAX_HISTORY_TURNS ) as $turn ) {
				if ( ! is_array( $turn ) ) {
					continue;
				}
				$role    = isset( $turn['role'] ) ? sanitize_key( $turn['role'] ) : '';
				$content = isset( $turn['content'] ) ? sanitize_textarea_field( $turn['content'] ) : '';
				if ( ! in_array( $role, array( 'user', 'assistant' ), true ) || '' === $content ) {
					continue;
				}
				$entry = array(
					'role'    => $role,
					'content' => $this->truncate_text( $content, self::MAX_HISTORY_TURN_CHARS ),
				);
				// Keep rules for assistant turns so the prompt can reference previous states.
				if ( 'assistant' === $role && isset( $turn['rules'] ) && is_array( $turn['rules'] ) ) {
					$entry['rules'] = $turn['rules'];
				}
				$history[] = $entry;
			}
		}

		// Rules JSON is the heaviest part of the history — keep it only on the
		// newest assistant turns. CURRENT_RULES still carries the live state.
		$rules_kept = 0;
		for ( $i = count( $history ) - 1; $i >= 0; $i-- ) {
			if ( isset( $history[ $i ]['rules'] ) ) {
				if ( $rules_kept >= self::MAX_HISTORY_RULES ) {
					unset( $history[ $i ]['rules'] );
				} else {
					++$rules_kept;
				}
			}
		}

		$result = $this->call_wp_ai(
			AiSchema::chat_system_prompt(),
			AiSchema::chat_user_prompt( $message, $current_page, $history, $current_rules )
		);

		if ( is_wp_error( $result ) ) {
			return $this->wp_error_response( $result );
		}

		// Chat response shape: { name, filters, sorting, limit?, explanation }
		// Same flat format as generate_rules() — explanation is a peer key, not a wrapper.
		$explanation = isset( $result['explanation'] ) && is_string( $result['explanation'] )
			? sanitize_text_field( $result['explanation'] )
			: '';

		$result    = $this->resolve_unmatched_filter_term( $result );
		$validated = $this->validate_output( $result, $current_page );

		if ( is_wp_error( $validated ) ) {
			return new WP_REST_Response(
				array(
					'code'    => $validated->get_error_code(),
					'message' => $validated->get_error_message(),
				),
				422
			);
		}

		return new WP_REST_Response(
			array(
				'success'     => true,
				'data'        => $validated,
				'explanation' => $explanation,
			),
			200
		);
	}

	// -----------------------------------------------------------------------
	// Private helpers
	// -----------------------------------------------------------------------

	/**
	 * Call wp_ai_client_prompt with system + user prompt, return a parsed JSON
	 * array on success or WP_Error (with a stable plugin error code) on failure.
	 *
	 * Pre-flight checks produce actionable codes before any network call:
	 *  - no_ai_support : WP < 7.0 (wp_ai_client_prompt missing)
	 *  - ai_disabled   : AI switched off via WP_AI_SUPPORT / wp_supports_ai filter
	 *  - no_api_key    : no provider has credentials (Settings → Connectors)
	 *
	 * @param string $system_prompt System instructions for the model.
	 * @param string $user_prompt   The end-user's message/prompt.
	 * @return array|\WP_Error
	 */
	private function call_wp_ai( string $system_prompt, string $user_prompt ) {
		if ( ! function_exists( 'wp_ai_client_prompt' ) ) {
			return new \WP_Error(
				'no_ai_support',
				__( 'AI features require WordPress 7.0 or newer. Please update WordPress to use the AI assistant.', 'wow-ai-product-recommendations' ),
				array( 'status' => 501 )
			);
		}

		if ( function_exists( 'wp_supports_ai' ) && ! wp_supports_ai() ) {
			return new \WP_Error(
				'ai_disabled',
				__( 'AI features are disabled on this site. Ask your administrator to enable WordPress AI support.', 'wow-ai-product-recommendations' ),
				array( 'status' => 503 )
			);
		}

		$status = $this->build_ai_status();
		if ( ! $status['configured'] ) {
			return new \WP_Error(
				'no_api_key',
				__( 'No AI provider is connected yet. Add an API key for Anthropic, Google, or OpenAI to start generating rules.', 'wow-ai-product-recommendations' ),
				array(
					'status'       => 400,
					'settings_url' => $status['settings_url'],
				)
			);
		}

		/**
		 * Filters the HTTP timeout (seconds) for AI provider requests.
		 *
		 * The WP AI Client defaults to ~30s, which large prompts can exceed
		 * (cURL error 28). Scoped to this call only via http_request_args.
		 *
		 * @param int $timeout Timeout in seconds. Default 90.
		 */
		$timeout = max( 30, (int) apply_filters( 'preco_ai_request_timeout', self::AI_REQUEST_TIMEOUT ) );

		$raise_timeout = static function ( $args ) use ( $timeout ) {
			if ( empty( $args['timeout'] ) || (int) $args['timeout'] < $timeout ) {
				$args['timeout'] = $timeout;
			}
			return $args;
		};

		// Give PHP headroom beyond the HTTP wait so the request isn't killed mid-call.
		if ( function_exists( 'set_time_limit' ) ) {
			// phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged, Squiz.PHP.DiscouragedFunctions.Discouraged -- scoped to this single AI request, guarded by function_exists(), some hosts disable this function.
			@set_time_limit( $timeout + 30 );
		}

		add_filter( 'http_request_args', $raise_timeout, 9999 );

		$text = wp_ai_client_prompt( $user_prompt )
			->using_system_instruction( $system_prompt )
			->generate_text();

		remove_filter( 'http_request_args', $raise_timeout, 9999 );

		if ( is_wp_error( $text ) ) {
			return $this->normalize_ai_error( $text );
		}

		return $this->decode_json( $text );
	}

	/**
	 * Multibyte-safe truncation for prompt payloads.
	 *
	 * @param string $text  Input text.
	 * @param int    $limit Maximum characters.
	 * @return string
	 */
	private function truncate_text( string $text, int $limit ): string {
		if ( function_exists( 'mb_substr' ) ) {
			return mb_substr( $text, 0, $limit );
		}
		return substr( $text, 0, $limit );
	}

	/**
	 * Map WP AI Client error codes (prompt_client_error, prompt_network_error, …)
	 * to the stable plugin codes the JS error map understands.
	 *
	 * @param \WP_Error $error Raw error from the prompt builder.
	 * @return \WP_Error
	 */
	private function normalize_ai_error( \WP_Error $error ): \WP_Error {
		$code    = $error->get_error_code();
		$data    = $error->get_error_data( $code );
		$status  = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 500;
		$raw_msg = $error->get_error_message();

		switch ( $code ) {
			case 'prompt_client_error':
				if ( in_array( $status, array( 401, 403 ), true ) ) {
					$mapped  = 'api_key_invalid';
					$message = __( 'The AI provider rejected the API key. Please check your credentials.', 'wow-ai-product-recommendations' );
				} elseif ( 429 === $status ) {
					$mapped  = 'rate_limit';
					$message = __( 'API rate limit reached. Please wait a moment and try again.', 'wow-ai-product-recommendations' );
				} elseif ( 402 === $status ) {
					$mapped  = 'insufficient_quota';
					$message = __( 'API billing quota exceeded. Please check your plan and billing details.', 'wow-ai-product-recommendations' );
				} else {
					$mapped  = 'api_error';
					$message = $raw_msg;
				}
				break;

			case 'prompt_upstream_server_error':
				if ( in_array( $status, array( 503, 529 ), true ) ) {
					$mapped  = 'high_demand';
					$message = __( 'The AI model is busy right now. Please try again in a few seconds.', 'wow-ai-product-recommendations' );
				} else {
					$mapped  = 'api_error';
					$message = $raw_msg;
				}
				break;

			case 'prompt_network_error':
				$mapped     = 'request_failed';
				$is_timeout = false !== stripos( $raw_msg, 'timed out' )
					|| false !== stripos( $raw_msg, 'curl error 28' );
				$message    = $is_timeout
					? __( 'The AI provider took too long to respond. This is usually temporary — please try again.', 'wow-ai-product-recommendations' )
					: __( 'Could not reach the AI provider. Please check your internet connection and try again.', 'wow-ai-product-recommendations' );
				break;

			case 'prompt_token_limit_reached':
				$mapped  = 'token_limit';
				$message = __( 'The request is too long for the AI model. Try a shorter description.', 'wow-ai-product-recommendations' );
				break;

			case 'prompt_prevented':
				$mapped  = 'ai_disabled';
				$message = __( 'AI features are disabled on this site. Ask your administrator to enable WordPress AI support.', 'wow-ai-product-recommendations' );
				break;

			default:
				$mapped  = 'api_error';
				$message = $raw_msg ? $raw_msg : __( 'The AI request failed. Please try again.', 'wow-ai-product-recommendations' );
				break;
		}

		$error_data = array(
			'status'     => $status,
			'api_detail' => array( 'api_message' => $raw_msg ),
		);
		if ( in_array( $mapped, array( 'api_key_invalid', 'insufficient_quota' ), true ) ) {
			$error_data['settings_url'] = admin_url( 'options-connectors.php' );
		}

		return new \WP_Error( $mapped, $message, $error_data );
	}

	/**
	 * Strip optional markdown fences and decode JSON from an AI text response.
	 *
	 * @param string $text Raw AI output.
	 * @return array|\WP_Error
	 */
	private function decode_json( string $text ) {
		$text   = preg_replace( '/^```(?:json)?\s*/i', '', trim( $text ) );
		$text   = preg_replace( '/\s*```$/', '', $text );
		$parsed = json_decode( $text, true );

		if ( JSON_ERROR_NONE === json_last_error() && is_array( $parsed ) ) {
			return $parsed;
		}

		// Smaller/free models sometimes drop the JSON envelope under the
		// CLARIFY conversational instructions and just reply in plain prose
		// (e.g. answering "how are you?" directly). That prose is exactly
		// what a CLARIFY turn would have said, so treat it as one instead of
		// hard-failing the whole exchange.
		if ( '' !== $text && ! preg_match( '/^[\[{]/', $text ) ) {
			return array(
				'clarify' => true,
				'message' => sanitize_text_field( $text ),
			);
		}

		$err = new \WP_Error( 'parse_error', __( 'Could not parse the AI response as JSON. Please try again.', 'wow-ai-product-recommendations' ) );
		$err->add_data( array( 'raw' => $text ), 'parse_error' );
		return $err;
	}

	/**
	 * Build a REST error response from a WP_Error.
	 * Uses the 'status' key in error data when present, and passes
	 * settings_url / api_detail through so the UI can link to the fix.
	 *
	 * @param \WP_Error $error The error to convert.
	 * @return WP_REST_Response
	 */
	private function wp_error_response( \WP_Error $error ): WP_REST_Response {
		$code   = $error->get_error_code();
		$data   = $error->get_error_data( $code );
		$status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 500;
		$body   = array(
			'code'    => $code,
			'message' => $error->get_error_message(),
		);

		if ( is_array( $data ) ) {
			if ( isset( $data['api_detail'] ) ) {
				$body['api_detail'] = $data['api_detail'];
			} elseif ( isset( $data['raw'] ) ) {
				$body['api_detail'] = array( 'raw_response' => $data['raw'] );
			}
			if ( isset( $data['settings_url'] ) ) {
				$body['settings_url'] = $data['settings_url'];
			}
		}

		return new WP_REST_Response( $body, $status );
	}

	/**
	 * Resolve an AI-flagged `unmatched_filter_term` against real store
	 * products. The AI is never given the product catalogue (it can run to
	 * thousands of items — far too large for a prompt), only category/tag/
	 * attribute taxonomy, so it cannot tell whether a term like "gaming"
	 * names an actual product. This runs the real WooCommerce search the AI
	 * has no access to and either:
	 *  - finds matching products → appends a `specific_products` filter so
	 *    the rest of the AI's rule (sorting/limit/location) still applies, or
	 *  - finds nothing → discards the draft rule and turns the response into
	 *    the same {clarify:true, message} shape the rest of the flow expects.
	 *
	 * A no-op when the AI didn't set `unmatched_filter_term` (the normal
	 * case) or when $parsed isn't a usable array.
	 *
	 * @param mixed $parsed Decoded AI response (array on success).
	 * @return mixed Unchanged input, or input with the unmatched-term keys
	 *               resolved into either a filter or a clarify response.
	 */
	private function resolve_unmatched_filter_term( $parsed ) {
		if ( ! is_array( $parsed ) || empty( $parsed['unmatched_filter_term'] ) || ! is_string( $parsed['unmatched_filter_term'] ) ) {
			return $parsed;
		}

		$term = trim( sanitize_text_field( $parsed['unmatched_filter_term'] ) );
		unset( $parsed['unmatched_filter_term'] );

		$no_match_message = isset( $parsed['no_match_message'] ) && is_string( $parsed['no_match_message'] )
			? sanitize_text_field( $parsed['no_match_message'] )
			: '';
		unset( $parsed['no_match_message'] );

		if ( '' === $term ) {
			return $parsed;
		}

		$product_ids = array();
		if ( function_exists( 'wc_get_products' ) ) {
			$found = wc_get_products(
				array(
					'status' => 'publish',
					'limit'  => 20,
					's'      => $term,
					'return' => 'ids',
				)
			);
			if ( is_array( $found ) ) {
				$product_ids = array_values( array_unique( array_filter( array_map( 'absint', $found ) ) ) );
			}
		}

		if ( empty( $product_ids ) ) {
			return array(
				'clarify' => true,
				'message' => '' !== $no_match_message
					? $no_match_message
					: sprintf(
						/* translators: %s: the search term the AI could not match to any category, tag, or product. */
						__( 'Your store doesn’t have a category or products matching "%s". Try a different term, or pick a real category.', 'wow-ai-product-recommendations' ),
						$term
					),
			);
		}

		$filters           = isset( $parsed['filters'] ) && is_array( $parsed['filters'] ) ? $parsed['filters'] : array();
		$filters[]         = array(
			'triggerType' => 'specific_products',
			'relation'    => 'in',
			'value'       => $product_ids,
		);
		$parsed['filters'] = $filters;

		return $parsed;
	}

	/**
	 * Verify a category/tag/attribute filter's value against real store
	 * taxonomy and strip anything that isn't real. The AI is instructed not
	 * to invent slugs, but instructions aren't a guarantee — an impossible
	 * slug in a tax_query silently returns zero products and can zero out
	 * an otherwise-valid rule (e.g. paired with a correct specific_products
	 * filter from resolve_unmatched_filter_term()).
	 *
	 * @param array $filter Sanitized filter (triggerType/relation/value already set).
	 * @return array|null The filter with only real slugs, or null to drop it entirely.
	 */
	private function sanitize_taxonomy_filter( array $filter ): ?array {
		$type     = $filter['triggerType'];
		$relation = $filter['relation'];
		$value    = $filter['value'];

		switch ( $type ) {
			case 'category':
			case 'current_category':
				if ( 'any' === $relation ) {
					$filter['value'] = array();
					return $filter;
				}
				$real = $this->real_term_slugs( (array) $value, 'product_cat' );
				if ( empty( $real ) ) {
					return null;
				}
				$filter['value'] = $real;
				return $filter;

			case 'tag':
			case 'current_tag':
				if ( 'any' === $relation ) {
					$filter['value'] = array();
					return $filter;
				}
				$real = $this->real_term_slugs( (array) $value, 'product_tag' );
				if ( empty( $real ) ) {
					return null;
				}
				$filter['value'] = $real;
				return $filter;

			case 'attribute':
				if ( ! is_array( $value ) || empty( $value['attrKey'] ) || ! function_exists( 'wc_attribute_taxonomy_name' ) ) {
					return null;
				}
				$attr_key = sanitize_key( $value['attrKey'] );
				$taxonomy = wc_attribute_taxonomy_name( $attr_key );
				if ( ! taxonomy_exists( $taxonomy ) ) {
					return null;
				}
				$real_values = $this->real_term_slugs( (array) ( $value['attrValue'] ?? array() ), $taxonomy );
				if ( empty( $real_values ) ) {
					return null;
				}
				$filter['value'] = array(
					'attrKey'   => $attr_key,
					'attrValue' => $real_values,
				);
				return $filter;

			case 'current_attribute':
				$attr_key = is_string( $value ) ? sanitize_key( $value ) : '';
				if ( '' === $attr_key || ! function_exists( 'wc_attribute_taxonomy_name' ) || ! taxonomy_exists( wc_attribute_taxonomy_name( $attr_key ) ) ) {
					return null;
				}
				$filter['value'] = $attr_key;
				return $filter;

			default:
				return $filter;
		}
	}

	/**
	 * Filter a list of candidate slugs down to ones that are real terms in
	 * the given taxonomy.
	 *
	 * @param array  $slugs    Candidate slugs to check.
	 * @param string $taxonomy Taxonomy to check them against.
	 * @return string[]
	 */
	private function real_term_slugs( array $slugs, string $taxonomy ): array {
		$real = array();
		foreach ( $slugs as $slug ) {
			$slug = sanitize_title( (string) $slug );
			if ( '' !== $slug && term_exists( $slug, $taxonomy ) ) {
				$real[] = $slug;
			}
		}
		return array_values( array_unique( $real ) );
	}

	/**
	 * Validate and sanitize the AI's parsed JSON output against the filter schema.
	 *
	 * Drops any filter whose triggerType/relation the AI invented outside the
	 * schema, and passes through page_mismatch / clarify responses as-is.
	 *
	 * @param mixed  $parsed       Decoded JSON from the AI response.
	 * @param string $current_page Page context, used to load the right schema.
	 * @return array|\WP_Error
	 */
	private function validate_output( $parsed, string $current_page = '' ) {
		if ( ! is_array( $parsed ) ) {
			return new \WP_Error( 'validation_error', 'AI output is not a valid object.' );
		}

		if ( ! empty( $parsed['page_mismatch'] ) ) {
			$suggested_page       = isset( $parsed['suggested_page'] ) && is_string( $parsed['suggested_page'] )
				? sanitize_key( $parsed['suggested_page'] )
				: '';
			$suggested_page_label = null;
			if ( in_array( $suggested_page, RecommendationsConfig::PAGE_KEYS, true ) ) {
				$suggested_page_label = isset( RecommendationsConfig::PAGE_LABELS[ $suggested_page ] )
					? RecommendationsConfig::PAGE_LABELS[ $suggested_page ]
					: $suggested_page;
			} else {
				$suggested_page = null;
			}
			$message = isset( $parsed['message'] ) && is_string( $parsed['message'] )
				? sanitize_text_field( $parsed['message'] )
				: __( 'This isn’t available from this page — try switching to the right page in the builder.', 'wow-ai-product-recommendations' );

			return array(
				'page_mismatch'        => true,
				'suggested_page'       => $suggested_page,
				'suggested_page_label' => $suggested_page_label,
				'message'              => $message,
			);
		}

		if ( ! empty( $parsed['clarify'] ) ) {
			$message = isset( $parsed['message'] ) && is_string( $parsed['message'] )
				? sanitize_text_field( $parsed['message'] )
				: __( 'Could you tell me a bit more about what you’d like? For example, a goal, a filter, or where it should appear.', 'wow-ai-product-recommendations' );

			return array(
				'clarify' => true,
				'message' => $message,
			);
		}

		$name = isset( $parsed['name'] ) && is_string( $parsed['name'] ) ? sanitize_text_field( $parsed['name'] ) : '';
		if ( empty( $name ) ) {
			$name = 'AI Generated Rules';
		}

		$schema = AiSchema::get_for_page( $current_page );

		$raw_filters = isset( $parsed['filters'] ) && is_array( $parsed['filters'] ) ? $parsed['filters'] : array();
		$filters     = array();
		foreach ( $raw_filters as $item ) {
			if ( ! is_array( $item ) || empty( $item['triggerType'] ) || empty( $item['relation'] ) ) {
				continue;
			}
			$filter = array(
				'triggerType' => sanitize_key( $item['triggerType'] ),
				'relation'    => sanitize_key( $item['relation'] ),
				'value'       => isset( $item['value'] ) ? $item['value'] : '',
			);

			// Reject any triggerType/relation the AI invented outside SCHEMA —
			// a hallucinated type otherwise silently no-ops in the query
			// engine, producing a rule that looks fine but matches nothing.
			if ( ! isset( $schema['filters'][ $filter['triggerType'] ] )
				|| ! in_array( $filter['relation'], $schema['filters'][ $filter['triggerType'] ]['relations'], true ) ) {
				continue;
			}

			if ( 'attribute' === $filter['triggerType'] && is_array( $filter['value'] ) ) {
				$filter['value'] = array(
					'attrKey'   => sanitize_key( $filter['value']['attrKey'] ?? '' ),
					'attrValue' => array_map( 'sanitize_text_field', (array) ( $filter['value']['attrValue'] ?? array() ) ),
				);
			}
			if ( in_array( $filter['triggerType'], array( 'product_price', 'relative_price', 'high_revenue', 'frequently_viewed' ), true ) ) {
				$filter['value'] = is_numeric( $filter['value'] ) ? (float) $filter['value'] : 0;
			}

			if ( in_array( $filter['triggerType'], array( 'category', 'tag', 'current_category', 'current_tag', 'attribute', 'current_attribute' ), true ) ) {
				$filter = $this->sanitize_taxonomy_filter( $filter );
				if ( null === $filter ) {
					// The AI named a category/tag/attribute that doesn't actually
					// exist in the store — drop it rather than let an impossible
					// tax_query silently zero out the whole recommendation.
					continue;
				}
			}

			$filters[] = $filter;
		}

		$raw_sorting = isset( $parsed['sorting'] ) && is_array( $parsed['sorting'] ) ? $parsed['sorting'] : array();
		$sorting     = array();
		foreach ( $raw_sorting as $idx => $item ) {
			if ( ! is_array( $item ) || empty( $item['triggerType'] ) || empty( $item['relation'] ) ) {
				continue;
			}
			$trigger_type = sanitize_key( $item['triggerType'] );
			$relation     = sanitize_key( $item['relation'] );

			// Same SCHEMA guard as filters above — sorting has its own,
			// separate set of valid triggerTypes (e.g. "best_selling" is a
			// filter type, not a sorting type; the AI sometimes confuses them).
			if ( ! isset( $schema['sorting'][ $trigger_type ] ) || ! in_array( $relation, $schema['sorting'][ $trigger_type ]['relations'], true ) ) {
				continue;
			}

			$priority  = isset( $item['priority'] ) && is_numeric( $item['priority'] )
				? max( 0, min( 5, (int) $item['priority'] ) )
				: $idx;
			$sorting[] = array(
				'triggerType' => $trigger_type,
				'relation'    => $relation,
				'value'       => isset( $item['value'] ) ? (string) $item['value'] : '',
				'priority'    => $priority,
			);
		}

		if ( empty( $filters ) && ! empty( $sorting ) ) {
			// Sorting only re-orders whatever's already selected — with no
			// filter it silently falls back to ordering the ENTIRE catalogue,
			// not the curated subset the user asked for. In-stock is a safe,
			// always-sensible default for a shopper-facing recommendation
			// block (never surface out-of-stock products) and guarantees the
			// response always restricts to something concrete.
			$filters[] = array(
				'triggerType' => 'stock_status',
				'relation'    => 'in',
				'value'       => 'in_stock',
			);
		}

		if ( empty( $filters ) && empty( $sorting ) ) {
			return new \WP_Error( 'validation_error', 'The AI did not return any filters or sorting rules. Please refine your prompt.' );
		}

		$limit = isset( $parsed['limit'] ) && is_numeric( $parsed['limit'] )
			? max( 2, min( 20, (int) $parsed['limit'] ) )
			: null;

		$output = array(
			'name'    => $name,
			'filters' => $filters,
			'sorting' => $sorting,
		);
		if ( null !== $limit ) {
			$output['limit'] = $limit;
		}

		// Only trust a location the AI names if it's a real one for this page —
		// otherwise the client falls back to its own default.
		if ( '' !== $current_page && isset( $parsed['location'] ) && is_string( $parsed['location'] ) ) {
			$location = sanitize_key( $parsed['location'] );
			if ( in_array( $location, RecommendationsConfig::get_location_keys( $current_page ), true ) ) {
				$output['location'] = $location;
			}
		}

		return $output;
	}
}
