<?php
/**
 * AI Schema and prompt builder service.
 *
 * Centralises the rule schema definition, page-aware schema filtering
 * (so the AI only sees rules that are valid for the active page context),
 * store context fetching, and the two-part prompt construction used by all
 * providers.
 *
 * Token-reduction strategy
 * ------------------------
 * 1. Page filtering   – strip filters whose `pages` constraint does not
 *    include the current page before serialising the schema.  This alone
 *    removes 5-10 filter types on cart/checkout pages.
 * 2. Strip `pages` key – after filtering the entry is no longer needed in
 *    the JSON sent to the API.
 * 3. System / user split – static instructions go in `system_prompt` once;
 *    per-request data (schema + context + user query) go in `user_prompt`.
 *    OpenAI can cache the system message; Gemini uses `systemInstruction`.
 * 4. Reduced max_tokens – output rarely exceeds 400 tokens; cap at 800.
 *
 * @package PRECO
 */

namespace PRECO\Includes\Admin\Ai;

use PRECO\Includes\Frontend\Core\RecommendationsConfig;

defined( 'ABSPATH' ) || exit;

/**
 * AI schema and prompt builder service.
 */
class AiSchema {

	// -----------------------------------------------------------------------
	// Schema definition
	// -----------------------------------------------------------------------

	/**
	 * Full rule schema.  Each filter entry may carry an optional `pages` key
	 * listing the page contexts where it is valid.  Entries without `pages`
	 * are available on every page.
	 *
	 * @return array
	 */
	private static function schema(): array {
		return array(
			'filters' => array(
				'attribute'                    => array(
					'relations' => array( 'in', 'not_in' ),
					'value'     => array(
						'attrKey'   => '<attribute_key>',
						'attrValue' => array( '<term_slug>' ),
					),
				),
				'category'                     => array(
					'relations' => array( 'in', 'not_in' ),
					'value'     => array( '<category_slug>' ),
				),
				'tag'                          => array(
					'relations' => array( 'in', 'not_in' ),
					'value'     => array( '<tag_slug>' ),
				),
				'current_category'             => array(
					'pages'     => array( 'product-page' ),
					'relations' => array( 'any', 'in', 'not_in' ),
					'value'     => '[] for any | [<slug>] for in/not_in',
				),
				'current_tag'                  => array(
					'pages'     => array( 'product-page' ),
					'relations' => array( 'any', 'in', 'not_in' ),
					'value'     => '[] for any | [<slug>] for in/not_in',
				),
				'current_attribute'            => array(
					'pages'     => array( 'product-page' ),
					'relations' => array( 'in', 'not_in' ),
					'value'     => '<attribute_key>',
				),
				'featured'                     => array(
					'relations' => array( 'is', 'is_not' ),
					'value'     => '',
				),
				'on_sale'                      => array(
					'relations' => array( 'is', 'is_not' ),
					'value'     => '',
				),
				'recently_viewed'              => array(
					'relations' => array( 'is', 'is_not' ),
					'value'     => '',
				),
				'related'                      => array(
					'pages'     => array( 'product-page' ),
					'relations' => array( 'is' ),
					'value'     => '',
				),
				'upsells'                      => array(
					'pages'     => array( 'product-page' ),
					'relations' => array( 'is' ),
					'value'     => '',
				),
				'cross_sells'                  => array(
					'pages'     => array( 'cart', 'checkout' ),
					'relations' => array( 'is' ),
					'value'     => '',
				),
				'bought_together'              => array(
					'pages'         => array( 'product-page', 'cart' ),
					'relations'     => array( 'in' ),
					'value_allowed' => array( 'last_7d', 'last_14d', 'last_30d' ),
				),
				'product_price'                => array(
					'relations' => array( 'greater_equal', 'less' ),
					'value'     => '<number>',
				),
				'stock_status'                 => array(
					'relations'     => array( 'in', 'not_in' ),
					'value_allowed' => array( 'in_stock', 'out_of_stock', 'backorder' ),
				),
				'relative_price'               => array(
					'relations' => array( 'greater_equal', 'less' ),
					'value'     => '<number>',
				),
				'created'                      => array(
					'relations'     => array( 'in' ),
					'value_allowed' => array( 'last_24h', 'last_7d', 'last_30d', 'last_3m' ),
				),
				'best_selling'                 => array(
					'relations'     => array( 'in', 'not_in' ),
					'value_allowed' => array( 'last_24h', 'last_7d', 'last_30d', 'last_60d' ),
				),
				'high_revenue'                 => array(
					'relations' => array( 'greater', 'less' ),
					'value'     => '<number>',
				),
				'low_selling'                  => array(
					'relations'     => array( 'in', 'not_in' ),
					'value_allowed' => array( 'last_24h', 'last_7d', 'last_30d', 'last_60d' ),
				),
				'high_conversion'              => array(
					'relations'     => array( 'in', 'not_in' ),
					'value_allowed' => array( 'last_24h', 'last_7d', 'last_30d', 'last_60d' ),
				),
				'high_click_through'           => array(
					'relations'     => array( 'in', 'not_in' ),
					'value_allowed' => array( 'last_24h', 'last_7d', 'last_30d', 'last_60d' ),
				),
				'frequently_viewed'            => array(
					'relations'     => array( 'min_views', 'max_views' ),
					'value_allowed' => array( 2, 5, 10, 15, 20 ),
				),
				'added_to_cart'                => array(
					'relations'     => array( 'in', 'not_in' ),
					'value_allowed' => array( 'last_7d', 'last_14d', 'last_30d' ),
				),
				'removed_from_cart'            => array(
					'relations'     => array( 'in', 'not_in' ),
					'value_allowed' => array( 'last_7d', 'last_14d', 'last_30d' ),
				),
				'previously_purchased'         => array(
					'relations'     => array( 'in', 'not_in' ),
					'value_allowed' => array( 'last_7d', 'last_14d', 'last_30d' ),
				),
				'products_viewed'              => array(
					'relations'     => array( 'in' ),
					'value_allowed' => array( 'last_7d', 'last_14d', 'last_30d' ),
				),
				'customer_interest_categories' => array(
					'relations'     => array( 'in', 'not_in' ),
					'value_allowed' => array( 'most_view_categories', 'frequently_viewed_categories', 'recently_viewed_categories', 'repeat_viewed_categories', 'purchased_before_categories' ),
				),
				'customer_interest_tags'       => array(
					'relations'     => array( 'in', 'not_in' ),
					'value_allowed' => array( 'most_view_tags', 'frequently_viewed_tags', 'recently_viewed_tags', 'repeat_viewed_tags', 'purchased_before_tags' ),
				),
				'customer_interest_attributes' => array(
					'relations'     => array( 'in', 'not_in' ),
					'value_allowed' => array( 'most_view_attributes', 'frequently_viewed_attributes', 'recently_viewed_attributes', 'repeat_viewed_attributes', 'purchased_before_attributes' ),
				),
			),
			'sorting' => array(
				'popularity'                => array(
					'relations'     => array( 'high_to_low', 'low_to_high' ),
					'value_allowed' => array( 'last_24h', 'last_7d', 'last_30d', 'last_3m' ),
				),
				'conversion'                => array(
					'relations' => array( 'high_to_low', 'low_to_high' ),
					'value'     => '',
				),
				'freshness'                 => array(
					'relations' => array( 'new_to_old', 'old_to_new' ),
					'value'     => '',
				),
				'price'                     => array(
					'relations' => array( 'high_to_low', 'low_to_high' ),
					'value'     => '',
				),
				'rating'                    => array(
					'relations' => array( 'high_to_low', 'low_to_high' ),
					'value'     => '',
				),
				'random'                    => array(
					'relations' => array( 'none' ),
					'value'     => '',
				),
				'bought_together_frequency' => array(
					'relations'     => array( 'none' ),
					'value_allowed' => array( 'last_24h', 'last_7d', 'last_30d', 'last_3m' ),
				),
			),
		);
	}

	// -----------------------------------------------------------------------
	// Public API
	// -----------------------------------------------------------------------

	/**
	 * Return the schema filtered to only the rules valid for $current_page.
	 * The `pages` key is stripped from each entry — the AI does not need it
	 * once the irrelevant entries have been removed.
	 *
	 * @param string $current_page  e.g. 'product-page', 'cart', 'checkout'.
	 * @return array
	 */
	public static function get_for_page( string $current_page ): array {
		$full    = self::schema();
		$filters = array();

		foreach ( $full['filters'] as $type => $cfg ) {
			if ( isset( $cfg['pages'] ) && ! in_array( $current_page, $cfg['pages'], true ) ) {
				continue; // Not valid for this page.
			}
			$entry = $cfg;
			unset( $entry['pages'] ); // AI doesn't need the constraint after filtering.
			$filters[ $type ] = $entry;
		}

		return array(
			'filters' => $filters,
			'sorting' => $full['sorting'],
		);
	}

	/**
	 * Collect store taxonomy data for the prompt context.
	 *
	 * @param string $current_page e.g. 'product-page', 'cart', 'checkout'.
	 * @return array
	 */
	public static function get_store_context( string $current_page ): array {
		$categories = array();
		$tags       = array();
		$attributes = array();

		$cat_terms = get_terms(
			array(
				'taxonomy'   => 'product_cat',
				'hide_empty' => false,
				'number'     => 50,
				'fields'     => 'slugs',
			)
		);
		if ( ! is_wp_error( $cat_terms ) ) {
			$categories = array_values( $cat_terms );
		}

		$tag_terms = get_terms(
			array(
				'taxonomy'   => 'product_tag',
				'hide_empty' => false,
				'number'     => 50,
				'fields'     => 'slugs',
			)
		);
		if ( ! is_wp_error( $tag_terms ) ) {
			$tags = array_values( $tag_terms );
		}

		if ( function_exists( 'wc_get_attribute_taxonomies' ) ) {
			foreach ( wc_get_attribute_taxonomies() as $attr ) {
				$terms = get_terms(
					array(
						'taxonomy'   => wc_attribute_taxonomy_name( $attr->attribute_name ),
						'hide_empty' => false,
						'number'     => 20,
						'fields'     => 'slugs',
					)
				);
				if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) {
					$attributes[ $attr->attribute_name ] = array_values( $terms );
				}
			}
		}

		return array(
			'current_page' => $current_page,
			'categories'   => $categories,
			'tags'         => $tags,
			'attributes'   => $attributes,
			'user_name'    => self::get_user_display_name(),
		);
	}

	/**
	 * First name (falling back to display name) of the logged-in admin/shop
	 * manager using the chat, so greetings can be personalised.
	 *
	 * @return string Empty string when unavailable.
	 */
	private static function get_user_display_name(): string {
		if ( ! function_exists( 'wp_get_current_user' ) ) {
			return '';
		}
		$user = wp_get_current_user();
		if ( ! $user || ! $user->exists() ) {
			return '';
		}
		return ! empty( $user->first_name ) ? $user->first_name : $user->display_name;
	}

	/**
	 * Locations available on a page, for the AI to match placement hints
	 * against (e.g. "after add to cart"). Same ids the builder itself uses,
	 * via RecommendationsConfig — never invented by the model.
	 *
	 * @param string $current_page e.g. 'product-page', 'cart', 'checkout'.
	 * @return array [{id, label}]
	 */
	public static function get_locations_for_page( string $current_page ): array {
		$locations = array();
		foreach ( RecommendationsConfig::get_location_keys( $current_page ) as $location_key ) {
			$locations[] = array(
				'id'    => $location_key,
				'label' => RecommendationsConfig::humanize_key( $location_key ),
			);
		}
		return $locations;
	}

	/**
	 * All builder pages the AI chat can target, for detecting requests that
	 * belong on a different page than the current one.
	 *
	 * @return array [{id, label}]
	 */
	public static function get_supported_pages(): array {
		$pages = array();
		foreach ( RecommendationsConfig::PAGE_KEYS as $page_key ) {
			$pages[] = array(
				'id'    => $page_key,
				'label' => isset( RecommendationsConfig::PAGE_LABELS[ $page_key ] ) ? RecommendationsConfig::PAGE_LABELS[ $page_key ] : $page_key,
			);
		}
		return $pages;
	}

	// -----------------------------------------------------------------------
	// Prompt construction
	// -----------------------------------------------------------------------

	/**
	 * Return the static system prompt shared across all requests.
	 *
	 * Keeping instructions in a separate system message allows OpenAI to
	 * cache them across requests and reduces per-call token costs.
	 * Gemini uses this as `systemInstruction`.
	 *
	 * @return string
	 */
	public static function system_prompt(): string {
		return 'You are a WooCommerce product recommendation rule generator.'
			. ' Generate rules from the user request using ONLY types/relations/values defined in SCHEMA.'
			. ' Respect "pages" constraints — only use triggerTypes whose pages array includes the current_page.'
			. "\n"
			. self::language_instructions()
			. "\n"
			. 'Rules:'
			. "\n- Use only triggerTypes, relations, and values defined in SCHEMA."
			. "\n- value: \"\" for empty string, [] for empty array, a real number for numeric fields, pick from value_allowed when listed, real store slugs for categories/tags/attributes."
			. "\n- name: 2–5 word shopper-facing marketing headline describing the BENEFIT to the shopper."
			. "\n  GOOD: \"Fresh Picks for You\", \"Best Sellers This Week\", \"Complete the Look\", \"Trending Under \$50\", \"Just Arrived\", \"Customers Also Loved\"."
			. "\n  BAD: \"Upsells under \$100 newest first\", \"Products price less than 100\", \"Filtered by category newest\"."
			. "\n- sorting and filters are DIFFERENT triggerType sets — never reuse a filter triggerType (e.g. \"best_selling\", \"high_revenue\") inside sorting. For ordering by sales/popularity use sorting triggerType \"popularity\"."
			. "\n- filters is NEVER empty. Sorting only re-orders whatever's already selected — it does not restrict which products appear, so a sorting-only rule silently orders the ENTIRE catalogue instead of the subset the user meant. Whenever you use one of these sorting triggerTypes, also add its matching filter (same period value, default last_30d): \"popularity\" → filter \"best_selling\"; \"freshness\" → filter \"created\"; \"conversion\" → filter \"high_conversion\"; \"bought_together_frequency\" → filter \"bought_together\". If truly nothing else applies (e.g. sorting only by \"price\" or \"rating\" with no other signal), default to filter {\"triggerType\":\"stock_status\",\"relation\":\"in\",\"value\":\"in_stock\"} — never leave filters empty."
			. "\n- sorting priority starts at 0."
			. "\n- limit: how many products to display, a whole number between 2 and 20 (default 4). Only change it when the user's request implies a product count (e.g. \"show 8 products\", \"8 products\", \"display 8 products\", \"only display 3 items\", \"show more/fewer products\")."
			. "\n- location: if the request names or clearly implies one of the LOCATIONS ids for this page (e.g. \"after add to cart\", \"before the tabs\"), include \"location\":\"<id>\" using that exact id. Otherwise omit it — do not guess."
			. "\n"
			. self::page_mismatch_instructions()
			. "\n"
			. self::filter_intent_instructions()
			. "\n"
			. self::clarify_instructions()
			. "\n"
			. 'Return ONLY a JSON object — no markdown, no explanation.'
			. ' Format: {"name":"...","filters":[{"triggerType":"...","relation":"...","value":...}],"sorting":[{"triggerType":"...","relation":"...","value":"...","priority":0}],"limit":4,"location":"...","unmatched_filter_term":"...","no_match_message":"..."}'
			. ' OR, when PAGE_MISMATCH applies: {"page_mismatch":true,"suggested_page":"...","message":"..."}'
			. ' OR, when CLARIFY applies: {"clarify":true,"message":"..."}';
	}

	/**
	 * Shared instructions (system + chat_system) for understanding requests
	 * in any language/script and replying in kind. The rest of the contract
	 * (JSON keys, triggerType/relation enums, SCHEMA structure) always stays
	 * in English exactly as defined — only the free-text fields shown to a
	 * human (message, no_match_message, explanation, name) follow the
	 * user's language.
	 *
	 * @return string
	 */
	private static function language_instructions(): string {
		return 'LANGUAGE:'
			. "\n- REQUEST may be written in any language or script, including English, Bangla (Bengali script, e.g. \"হাই\"), Banglish (Bangla meaning typed with Latin letters, e.g. \"ami beshi bikri products dekhate chai\" = \"I want to show best-selling products\"), or a mix of English and Bangla in the same sentence. Understand the intent regardless of language or script — never treat it as gibberish or reply with CLARIFY just because it isn't English."
			. "\n- MANDATORY, not a preference: every free-text field you write (message, no_match_message, explanation, and the shopper-facing \"name\") MUST be written in the SAME language and script as the user's most recent message. Bangla script in → your entire reply written in Bangla script. Banglish in → your entire reply written in Banglish (Latin letters). English in → English. A mixed sentence in → mirror that mix. Defaulting to English for a non-English message is a failure, even if you only quote or acknowledge their words in English — the whole reply text must actually be in their language, not English text that mentions or describes it."
			. "\n- This only affects free text meant for a human. JSON keys, triggerType, relation, and every enum-style value defined in SCHEMA (e.g. \"in\", \"low_to_high\") always stay exactly as defined — never translated or transliterated."
			. "\n- category/tag/attribute filter values must be copied verbatim from CONTEXT exactly as given, regardless of what script they're in — never translate or transliterate a real slug.";
	}

	/**
	 * Shared instructions (system + chat_system) for detecting a request that
	 * belongs on a different builder page than the current one.
	 *
	 * @return string
	 */
	private static function page_mismatch_instructions(): string {
		return 'PAGE_MISMATCH:'
			. "\n- If the request is clearly about a different page/location context than current_page (e.g. a cart cross-sell request while current_page is product-page), do NOT force-fit filters from the current page's SCHEMA as a substitute."
			. "\n- Instead return ONLY {\"page_mismatch\":true,\"suggested_page\":\"<id from PAGES>\",\"message\":\"<one short plain sentence telling the shopper what to do, in the user's own language — see LANGUAGE>\"}."
			. "\n- If the request names a feature this chat does not cover at all (e.g. Popup Upsell, Order Bump), set suggested_page to null and explain in message that it needs a different builder from the sidebar."
			. "\n- Only use PAGE_MISMATCH when the mismatch is clear and unambiguous. When in doubt, generate normal rules for current_page instead.";
	}

	/**
	 * Shared instructions (system + chat_system) making sure a filter the
	 * user clearly asked for (a category/tag/attribute/product type) is
	 * either matched against real store taxonomy, handed off for a real
	 * product-name search, or explicitly flagged as unmatched — never
	 * silently dropped in favour of a sorting-only result.
	 *
	 * CONTEXT never includes individual product names (the catalogue can be
	 * thousands of items — far too large for a prompt), only taxonomy. So a
	 * term that names an actual product rather than a category/tag cannot be
	 * verified by the model itself; `unmatched_filter_term` hands it to the
	 * server, which runs a real product search the model has no access to.
	 *
	 * @return string
	 */
	private static function filter_intent_instructions(): string {
		return 'FILTER MATCHING:'
			. "\n- If the request names or implies a product category/tag/attribute/type (e.g. \"gaming items\", \"red shoes\", \"electronics\"), check it against CONTEXT.categories / CONTEXT.tags / CONTEXT.attributes for a genuine match: the same word, a singular/plural variant, or a direct synonym (e.g. \"shirts\" matches \"tshirts\", \"hoodie\" matches \"hoodies\")."
			. "\n- Do NOT stretch an unrelated concept into a match by loose association or product-fantasy (e.g. \"gaming\" is NOT a match for \"tshirts\" just because a t-shirt could theoretically have a gaming design; \"electronics\" is NOT a match for \"accessories\"). When in doubt, it is NOT a match."
			. "\n- When a genuine match exists, include it as a filter (category/tag/attribute, relation \"in\") alongside any sorting or other filters the request also implies. Never drop a named filter silently and fall back to sorting-only rules."
			. "\n- When NO genuine taxonomy match exists, remember CONTEXT never lists individual product names — the term might still name a real product even with no matching category/tag. Never guess or fabricate a match. Instead, if the rest of the request is still actionable (sorting, limit, location, or other real filters), build that rule as normal (omit a filter entry for the unmatched term) and add two extra keys: \"unmatched_filter_term\":\"<the concept the user meant, e.g. \\\"gaming\\\", TRANSLATED into whatever language CONTEXT.categories/tags are written in — the server runs a literal text search against real product titles, so it must be in the store's own language, not the language REQUEST was written in>\" so the server can search real product names for it, and \"no_match_message\":\"<one short plain sentence, in the user's own language per LANGUAGE, to use ONLY if that search also finds nothing — tell the shopper the store has no matching category or products for that term, optionally naming 2-3 of the closest real CONTEXT.categories, never the full list>\"."
			. "\n- If the request gives you nothing else to act on beyond the unmatched term (no sorting, no count, no location, no other filters), use CLARIFY instead — do not emit unmatched_filter_term with an otherwise-empty rule.";
	}

	/**
	 * Shared instructions (system + chat_system) for asking a clarifying
	 * question instead of forcing a rules JSON out of an insufficient prompt.
	 *
	 * @return string
	 */
	private static function clarify_instructions(): string {
		return 'CLARIFY:'
			. "\n- If the request is too vague, generic, or missing the information needed to produce meaningful rules (greetings like \"hi\", small talk, \"help\", \"make something good\", or anything you'd otherwise have to guess at), do NOT invent generic or placeholder filters/sorting just to satisfy the output format."
			. "\n- Still return ONLY the JSON object {\"clarify\":true,\"message\":\"...\"} — never reply with plain text outside JSON, even for small talk. The natural reply goes INSIDE the message string, JSON stays the outer shape no matter what."
			. "\n- message is plain conversational prose, shown as-is in a chat bubble with no markdown rendering: NEVER use **bold**, headers, numbered/bulleted lists, or emoji. Write it as 1-2 short flowing sentences, the way a person would text a quick reply — not a form, not a checklist."
			. "\n- Briefly acknowledge what the user actually said (a greeting gets a greeting, \"how are you?\" gets a short in-character answer), then ask ONE concrete follow-up question grounded in PAGE, LOCATIONS, or CONTEXT — not a menu of every possible option. Check CONVERSATION and don't reuse phrasing from an earlier turn."
			. "\n- When CONTEXT.user_name is non-empty and the request is a greeting or small talk (\"hi\", \"hello\", \"hey\"), open the message by addressing them by that name, e.g. \"Hi {user_name}! ...\". Never invent a name when CONTEXT.user_name is empty."
			. "\n- Only use CLARIFY when you genuinely cannot produce useful rules yet. If the request gives you enough to act on — even a rough goal like \"show best sellers\" or \"cross-sell on cart\" — generate rules instead of asking.";
	}

	/**
	 * Build the per-request user prompt from filtered schema, store context,
	 * and the raw user query.
	 *
	 * @param string $user_query   Raw user input.
	 * @param string $current_page Active page context.
	 * @return string
	 */
	public static function user_prompt( string $user_query, string $current_page ): string {
		$schema    = self::get_for_page( $current_page );
		$context   = self::get_store_context( $current_page );
		$locations = self::get_locations_for_page( $current_page );
		$pages     = self::get_supported_pages();

		return 'PAGE: ' . $current_page
			. "\nLOCATIONS: " . wp_json_encode( $locations )
			. "\nPAGES: " . wp_json_encode( $pages )
			. "\nSCHEMA: " . wp_json_encode( $schema )
			. "\nCONTEXT: " . wp_json_encode( $context )
			. "\nREQUEST: " . $user_query;
	}

	// -----------------------------------------------------------------------
	// Chat prompts
	// -----------------------------------------------------------------------

	/**
	 * System prompt for the chat/refinement mode.
	 *
	 * Extends the base instructions with two extra requirements:
	 * - return a short natural-language explanation alongside the rules
	 * - use previous turns to understand what was already generated
	 *
	 * @return string
	 */
	public static function chat_system_prompt(): string {
		return 'You are a WooCommerce product recommendation rule generator.'
			. ' Generate or refine rules through conversation using ONLY types/relations/values defined in SCHEMA.'
			. ' Respect "pages" constraints — only use triggerTypes whose pages array includes the current_page.'
			. "\n"
			. self::language_instructions()
			. "\n"
			. 'Conversation rules:'
			. "\n- CURRENT_RULES (if provided) is the exact live state. Treat it as your working document."
			. "\n- CONVERSATION shows all previous turns. Each assistant turn includes the rules JSON it returned — use this to recall any previous value the user references (\"the name from before\", \"the filter you used in previous reply\", etc.)."
			. "\n- Partial updates: if the user asks to change only the name → keep filters, sorting, and limit exactly from CURRENT_RULES. If they ask to change only one filter/sorting item → keep everything else exactly from CURRENT_RULES, modify only what was asked. Same for limit: if they only ask to change the product count, keep name/filters/sorting exactly from CURRENT_RULES."
			. "\n- If the user asks to restore a previous value → find it in CONVERSATION history and restore it exactly."
			. "\n- Never silently change fields the user did not mention."
			. "\n"
			. 'Output rules:'
			. "\n- Use only triggerTypes, relations, and values defined in SCHEMA."
			. "\n- value: \"\" for empty string, [] for empty array, a real number for numeric fields, pick from value_allowed when listed, real store slugs for categories/tags/attributes."
			. "\n- name: 2–5 word shopper-facing marketing headline describing the BENEFIT to the shopper."
			. "\n  GOOD: \"Fresh Picks for You\", \"Best Sellers This Week\", \"Complete the Look\", \"Trending Under \$50\", \"Just Arrived\", \"Customers Also Loved\"."
			. "\n  BAD: \"Upsells under \$100 newest first\", \"Products price less than 100\", \"Filtered by category newest\"."
			. "\n- sorting and filters are DIFFERENT triggerType sets — never reuse a filter triggerType (e.g. \"best_selling\", \"high_revenue\") inside sorting. For ordering by sales/popularity use sorting triggerType \"popularity\"."
			. "\n- filters is NEVER empty. Sorting only re-orders whatever's already selected — it does not restrict which products appear, so a sorting-only rule silently orders the ENTIRE catalogue instead of the subset the user meant. Whenever you use one of these sorting triggerTypes, also add its matching filter (same period value, default last_30d): \"popularity\" → filter \"best_selling\"; \"freshness\" → filter \"created\"; \"conversion\" → filter \"high_conversion\"; \"bought_together_frequency\" → filter \"bought_together\". If truly nothing else applies (e.g. sorting only by \"price\" or \"rating\" with no other signal), default to filter {\"triggerType\":\"stock_status\",\"relation\":\"in\",\"value\":\"in_stock\"} — never leave filters empty."
			. "\n- sorting priority starts at 0."
			. "\n- limit: how many products to display, a whole number between 2 and 20 (default 4, or CURRENT_RULES.limit if provided). Only change it when the user asks for a product count (e.g. \"show 8 products\", \"only display 3 items\", \"show more/fewer products\")."
			. "\n- location: if the request names or clearly implies one of the LOCATIONS ids for this page (e.g. \"after add to cart\", \"move it before the tabs\"), include \"location\":\"<id>\" using that exact id. Otherwise omit it — do not guess. When refining CURRENT_RULES and the user does not mention placement, omit location so the existing placement is left alone."
			. "\n"
			. self::page_mismatch_instructions()
			. "\n"
			. self::filter_intent_instructions()
			. "\n"
			. self::clarify_instructions()
			. "\n"
			. 'Return ONLY a JSON object — no markdown, no explanation outside the JSON.'
			. ' Format: {"name":"...","filters":[{"triggerType":"...","relation":"...","value":...}],"sorting":[{"triggerType":"...","relation":"...","value":"...","priority":0}],"limit":4,"location":"...","unmatched_filter_term":"...","no_match_message":"...","explanation":"1-2 short plain-prose sentences, no markdown/emoji, summarising what these rules do"}'
			. ' OR, when PAGE_MISMATCH applies: {"page_mismatch":true,"suggested_page":"...","message":"..."}'
			. ' OR, when CLARIFY applies: {"clarify":true,"message":"..."}';
	}

	/**
	 * Build the per-turn user prompt for chat mode.
	 *
	 * @param string     $message       Latest user message.
	 * @param string     $current_page  Active page context.
	 * @param array      $history       [{role, content, rules?}] — previous turns.
	 *                                  Assistant turns may include a `rules` key with
	 *                                  the exact JSON they returned so the AI can recall
	 *                                  any previous value.
	 * @param array|null $current_rules The live rules object at the time of this request.
	 * @return string
	 */
	public static function chat_user_prompt( string $message, string $current_page, array $history, ?array $current_rules = null ): string {
		$schema    = self::get_for_page( $current_page );
		$context   = self::get_store_context( $current_page );
		$locations = self::get_locations_for_page( $current_page );
		$pages     = self::get_supported_pages();

		$parts   = array();
		$parts[] = 'PAGE: ' . $current_page;
		$parts[] = 'LOCATIONS: ' . wp_json_encode( $locations );
		$parts[] = 'PAGES: ' . wp_json_encode( $pages );
		$parts[] = 'SCHEMA: ' . wp_json_encode( $schema );
		$parts[] = 'CONTEXT: ' . wp_json_encode( $context );

		// Embed current live rules so AI can do precise partial updates.
		if ( ! empty( $current_rules ) ) {
			$parts[] = 'CURRENT_RULES: ' . wp_json_encode( $current_rules );
		}

		if ( ! empty( $history ) ) {
			$history_lines = array();
			foreach ( $history as $idx => $turn ) {
				$label = 'user' === $turn['role'] ? 'User' : 'Assistant';
				$line  = ( $idx + 1 ) . '. ' . $label . ': ' . $turn['content'];
				// Append the rules JSON so the AI can recall exact previous values.
				if ( 'assistant' === $turn['role'] && ! empty( $turn['rules'] ) ) {
					$line .= "\n   [rules]: " . wp_json_encode( $turn['rules'] );
				}
				$history_lines[] = $line;
			}
			$parts[] = 'CONVERSATION:' . "\n" . implode( "\n", $history_lines );
		}

		$parts[] = 'NEW REQUEST: ' . $message;

		return implode( "\n", $parts );
	}
}
