<?php

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

class Niwa_AI_RLM_Engine {
    private const DEFAULT_HISTORY_CONTEXT_LIMIT = 50;
    private const TOOL_ROUND_TIMEOUT = 25;
    private const FINAL_ROUND_TIMEOUT = 25;
    private const MAX_FINAL_TOOL_ROUNDS = 3;
	private const MAX_FRONTEND_CONTEXT_RESULTS = 6;

    /** @var Niwa_AI_Auth_Provider_Interface */
    private $auth_manager;
    private $model_router;
    /** @var Niwa_AI_AI_Client_Interface */
    private $openai_client;
    private $humanizer;
    private $rag_fallback;
    /** @var Niwa_AI_Content_Store_Interface|null */
    private $content_store;
    private $store_overview;
    private $memory_searcher;
    private $conversation_context_builder;
	private $response_postprocessor;
	private $product_card_selector;
	private $selected_product_option_resolver;
	private $selected_product_response_composer;
	private $product_response_composer;
	private $latency_tracker = null;
	private $active_trace_request_id = '';

    /** @var Niwa_AI_Admin_Autonomy_Skills|null */
    private $skills_manager;

    /** @var Niwa_AI_Spark_Adapter|null */
    private $spark_adapter;

    /**
     * Set to true when the agent calls `activate_lead_form` during the turn.
     * Surfaced in the final response so the frontend can mount the lead
     * form card/chat adapter.
     *
     * @var bool
     */
    private $lead_form_triggered = false;

    /**
     * Optional reason string the agent attached when calling the tool.
     * Echoed back to the frontend purely for logging/debug; not rendered.
     *
     * @var string
     */
    private $lead_form_trigger_reason = '';

    /** @var bool Whether activate_scheduler was called in this turn. */
    private $scheduler_triggered = false;

    /** @var string Optional internal activation reason. */
    private $scheduler_trigger_reason = '';

    /** @var string Preferred service id supplied by the agent. */
    private $scheduler_trigger_service_id = '';

    /** @var string Preferred team member id supplied by the agent. */
    private $scheduler_trigger_team_member_id = '';

    public function __construct( Niwa_AI_Auth_Provider_Interface $auth_manager, $model_router, Niwa_AI_AI_Client_Interface $openai_client, $humanizer, $rag_fallback, $card_builder = null, ?Niwa_AI_Content_Store_Interface $content_store = null, $store_overview = null, $memory_searcher = null ) {
        $this->auth_manager      = $auth_manager;
        $this->model_router      = $model_router;
        $this->openai_client     = $openai_client;
        $this->humanizer         = $humanizer;
        $this->rag_fallback      = $rag_fallback;
        $this->content_store     = $content_store;
        $this->store_overview    = $store_overview;
        $this->memory_searcher   = $memory_searcher;
        $this->conversation_context_builder = new Niwa_AI_Frontend_Conversation_Context();
		$this->response_postprocessor       = new Niwa_AI_Frontend_Response_Postprocessor();
		$this->product_card_selector        = new Niwa_AI_Frontend_Product_Card_Selector( $card_builder, $content_store );
		$this->selected_product_option_resolver = new Niwa_AI_Frontend_Selected_Product_Option_Resolver( $openai_client );
		$this->selected_product_response_composer = new Niwa_AI_Frontend_Selected_Product_Response_Composer( $openai_client );
		$this->product_response_composer    = new Niwa_AI_Frontend_Product_Response_Composer( $openai_client );
    }

    public function set_skills_manager( $skills_manager ) {
        $this->skills_manager = $skills_manager;
    }

    public function set_spark_adapter( Niwa_AI_Spark_Adapter $adapter ) {
        $this->spark_adapter = $adapter;
    }

	public function set_latency_tracker( $latency_tracker ) {
		$this->latency_tracker = $latency_tracker;
		if ( $this->memory_searcher && method_exists( $this->memory_searcher, 'set_latency_tracker' ) ) {
			$this->memory_searcher->set_latency_tracker( $latency_tracker );
		}
	}

	/**
	 * Build the catalog-specific semantic vocabulary with the same configured
	 * frontend model used for customer intent. This runs from cron/manual warmup,
	 * never as a required part of a customer response.
	 */
	public function refresh_frontend_semantic_lexicon() {
		if (
			! $this->openai_client
			|| ! $this->content_store
			|| ! class_exists( 'Niwa_AI_Frontend_Semantic_Lexicon' )
			|| ! method_exists( $this->content_store, 'get_product_semantic_lexicon_source' )
		) {
			return array( 'success' => false, 'error' => 'Semantic lexicon dependencies are unavailable.' );
		}

		$source = $this->content_store->get_product_semantic_lexicon_source();
		if ( empty( $source['source_hash'] ) || empty( $source['categories'] ) ) {
			return array( 'success' => false, 'error' => 'The indexed product catalog has no usable semantic facets.' );
		}

		$model     = $this->get_frontend_model();
		$auth_mode = $this->auth_manager->get_mode();
		$creds     = $this->auth_manager->get_credentials();
		$system = implode(
			"\n",
			array(
				'Build a domain-agnostic storefront semantic lexicon from the supplied catalog facets.',
				'Use the catalog language plus likely shopper languages. Keep the configured model intelligence: infer grammatical forms, ordinary translations, market synonyms, transliterations, and common misspellings.',
				'Consolidate case, inflection, spelling, and exact-synonym variants into one concept instead of emitting duplicate concepts for every raw spelling.',
				'Cover shopper-relevant product types, families, materials, colors, genders, brands, forms, and meaningful feature values. Omit pure size lists, raw numbers, SKUs/article IDs, internal placeholders such as #VALUE!, and administrative values; unresolved queries retain the full LLM path.',
				'field is a canonical machine dimension from the tool enum, never a translated label or a generic group. Keep every broad concept in its real dimension: footwear uses department, clogs use product_type, light colors use color, and broad material families use material.',
				'aliases must contain only meanings that fully satisfy the same requirement. Never put sibling types, nearby colors/materials, or merely similar products in aliases.',
				'descendants are narrower concepts that fully satisfy a broader request. related are useful alternatives that do not fully satisfy it. parents are broader discovery-only concepts.',
				'Create useful broad parent concepts even if absent as literal catalog categories, and list only actual catalog concepts as their descendants. A broad request may include its narrower catalog concepts, but sibling identities must never be treated as aliases.',
				'Also create ordinary shopper groupings that can be verified from catalog values, such as light or dark color families, broad fit/use groups, and equivalent market groupings. Put only catalog values that fully satisfy a grouping in descendants; nearby values belong in related.',
				'For every broad attribute group, include the standalone and adjectival forms shoppers naturally place before a product type, plus their likely translations and inflections. For example a light-color concept should cover wording equivalent to light, light-colored, and lighter colors.',
				'Split composite category wording into independent constraints when shoppers commonly request its parts, for example gender plus product type.',
				'Keep the payload compact: normally 60-120 concepts, at most 8 aliases, 8 descendants, 6 related terms, and 4 parents per concept. Do not repeat a phrase in multiple relation lists.',
				'ignorable_phrases may contain only harmless conversational/recommendation grammar, articles, prepositions, and generic nouns such as option or shade. Never ignore a product type, attribute, negation, number, unit, model, brand, or descriptive adjective.',
				'action_phrases must cover add-to-cart, ordering, buying, checkout, and navigation requests that must stay on the full tool-capable LLM path.',
				'Return the complete result through store_semantic_lexicon. Do not answer with prose.',
			)
		);
		$messages = array(
			array( 'role' => 'system', 'content' => $system ),
			array( 'role' => 'user', 'content' => wp_json_encode( $source, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES ) ),
		);
		/*
		 * The frontend_fast client profile intentionally caps live chat tool calls
		 * at 90 seconds. This one background build is larger and must not inherit
		 * that customer-path cap; scope the override to this PHP request/call only.
		 */
		$background_timeout = static function ( $timeout ) {
			return max( 180, intval( $timeout ) );
		};
		add_filter( 'niwa_ai_openai_request_timeout', $background_timeout, 99, 4 );
		try {
			$result = $this->openai_client->chat_complete(
				$model,
				$messages,
				$auth_mode,
				$creds,
				180,
				$this->get_semantic_lexicon_tool_schema(),
				'required'
			);
		} finally {
			remove_filter( 'niwa_ai_openai_request_timeout', $background_timeout, 99 );
		}
		if ( empty( $result['success'] ) || empty( $result['tool_calls'][0]['arguments'] ) ) {
			return array(
				'success' => false,
				'error'   => isset( $result['error'] ) ? (string) $result['error'] : 'The semantic lexicon tool call was not returned.',
				'model'   => $model,
			);
		}
		$payload = json_decode( (string) $result['tool_calls'][0]['arguments'], true );
		if ( ! is_array( $payload ) ) {
			return array( 'success' => false, 'error' => 'The semantic lexicon payload was invalid JSON.', 'model' => $model );
		}
		$saved = Niwa_AI_Frontend_Semantic_Lexicon::save( $payload, (string) $source['source_hash'], $model );
		$saved['model']    = $model;
		$saved['products'] = intval( $source['products'] ?? 0 );
		return $saved;
	}

    /**
     * Unified tool-calling entry point.
     */
    public function answer( $query, $session_id = 'default', $history = array(), $context = array() ) {
        // Reset per-turn trigger flags so a previous turn's state does not leak.
        $this->lead_form_triggered      = false;
        $this->lead_form_trigger_reason = '';
		$this->scheduler_triggered              = false;
		$this->scheduler_trigger_reason         = '';
		$this->scheduler_trigger_service_id     = '';
		$this->scheduler_trigger_team_member_id = '';
		$trace_request_id = isset( $context['request_id'] )
			? substr( preg_replace( '/[^a-zA-Z0-9_\-:.]/', '', trim( (string) $context['request_id'] ) ), 0, 96 )
			: '';
		$this->active_trace_request_id = $this->latency_tracker
			&& method_exists( $this->latency_tracker, 'has_active_trace' )
			&& $this->latency_tracker->has_active_trace( $trace_request_id )
				? $trace_request_id
				: '';
		if ( $this->memory_searcher && method_exists( $this->memory_searcher, 'set_trace_request_id' ) ) {
			$this->memory_searcher->set_trace_request_id( $this->active_trace_request_id );
		}

		try {
			$result = $this->answer_with_tools( $query, $session_id, $history, $context );
			$result = $this->enforce_scheduler_route_postcondition( $query, $history, $result );
			$this->remember_frontend_selected_product( $session_id, $history, $result, $context );
			return $this->decorate_with_lead_form_flag( $result );
		} finally {
			if ( $this->memory_searcher && method_exists( $this->memory_searcher, 'set_trace_request_id' ) ) {
				$this->memory_searcher->set_trace_request_id( '' );
			}
			$this->active_trace_request_id = '';
		}
    }

    /**
     * Attach the `lead_form_trigger` flag (and optional reason) to the
     * response when the agent called `activate_lead_form` during the turn.
     * Centralized here so every return path in `answer_with_tools` is
     * decorated without having to touch each one.
     *
     * @param mixed $result Engine response array (possibly malformed).
     * @return mixed Same array, with trigger keys added when flagged.
     */
    private function decorate_with_lead_form_flag( $result ) {
        if ( ! $this->lead_form_triggered && ! $this->scheduler_triggered ) {
            return $result;
        }
        if ( ! is_array( $result ) ) {
            return $result;
        }
		if ( $this->lead_form_triggered ) {
			$result['lead_form_trigger'] = true;
			if ( '' !== $this->lead_form_trigger_reason ) {
				$result['lead_form_trigger_reason'] = $this->lead_form_trigger_reason;
			}
		}
		if ( $this->scheduler_triggered ) {
			$result['scheduler_trigger'] = true;
			if ( '' !== $this->scheduler_trigger_reason ) {
				$result['scheduler_trigger_reason'] = $this->scheduler_trigger_reason;
			}
			if ( '' !== $this->scheduler_trigger_service_id ) {
				$result['scheduler_service_id'] = $this->scheduler_trigger_service_id;
			}
			if ( '' !== $this->scheduler_trigger_team_member_id ) {
				$result['scheduler_team_member_id'] = $this->scheduler_trigger_team_member_id;
			}
        }
        return $result;
    }

	/**
	 * Verify direct text-only scheduling replies with a required semantic route.
	 *
	 * The main model can occasionally answer "I opened the calendar" without
	 * emitting activate_scheduler. A second, compact structured decision closes
	 * that gap without language-specific keyword rules: an affirmative reply is
	 * resolved against recent conversation context, and any claimed UI action is
	 * required to have a matching scheduler_trigger postcondition.
	 *
	 * @param string $query   Current visitor message.
	 * @param array  $history Recent conversation history.
	 * @param mixed  $result  Candidate engine response.
	 * @return mixed
	 */
	private function enforce_scheduler_route_postcondition( $query, $history, $result ) {
		if ( $this->scheduler_triggered || ! is_array( $result ) || empty( $result['success'] ) ) {
			return $result;
		}
		$source = isset( $result['source'] ) ? (string) $result['source'] : '';
		$draft  = isset( $result['answer'] ) ? trim( (string) $result['answer'] ) : '';
		if ( 'rlm_tools_text_only' !== $source || '' === $draft || ! $this->openai_client ) {
			return $result;
		}

		$config   = $this->get_scheduler_frontend_config();
		$services = ! empty( $config['enabled'] ) && isset( $config['services'] ) && is_array( $config['services'] ) ? $config['services'] : array();
		if ( empty( $services ) ) {
			return $result;
		}

		$service_ids = array();
		$catalog     = array();
		foreach ( $services as $service ) {
			$id = is_array( $service ) && isset( $service['id'] ) ? sanitize_key( (string) $service['id'] ) : '';
			if ( '' === $id ) {
				continue;
			}
			$service_ids[ $id ] = true;
			$catalog[] = array(
				'id'          => $id,
				'name'        => isset( $service['name'] ) ? sanitize_text_field( (string) $service['name'] ) : '',
				'description' => isset( $service['description'] ) ? sanitize_text_field( (string) $service['description'] ) : '',
			);
		}
		if ( empty( $service_ids ) ) {
			return $result;
		}

		$member_ids = array();
		foreach ( isset( $config['teamMembers'] ) && is_array( $config['teamMembers'] ) ? $config['teamMembers'] : array() as $member ) {
			$id = is_array( $member ) && isset( $member['id'] ) ? sanitize_key( (string) $member['id'] ) : '';
			if ( '' !== $id ) {
				$member_ids[ $id ] = true;
			}
		}

		$recent_turns = array();
		foreach ( array_slice( (array) $history, -8 ) as $message ) {
			if ( ! is_array( $message ) ) {
				continue;
			}
			$role    = isset( $message['role'] ) ? sanitize_key( (string) $message['role'] ) : '';
			$content = isset( $message['content'] ) ? $message['content'] : ( isset( $message['text'] ) ? $message['text'] : '' );
			if ( is_array( $content ) || is_object( $content ) ) {
				$content = wp_json_encode( $content );
			}
			$content = substr( trim( (string) $content ), 0, 1600 );
			if ( '' !== $role && '' !== $content ) {
				$recent_turns[] = array( 'role' => $role, 'content' => $content );
			}
		}

		$service_property = array(
			'type'        => 'string',
			'description' => 'Exact configured service id when the visitor selected a known service; otherwise an empty string.',
			'enum'        => array_merge( array( '' ), array_keys( $service_ids ) ),
		);
		$member_property = array(
			'type'        => 'string',
			'description' => 'Exact configured team member id only when explicitly selected; otherwise an empty string.',
			'enum'        => array_merge( array( '' ), array_keys( $member_ids ) ),
		);
		$tools = array(
			array(
				'type'     => 'function',
				'function' => array(
					'name'        => 'route_scheduler_turn',
					'description' => 'Return the verified scheduling UI action for the current visitor turn.',
					'parameters'  => array(
						'type'       => 'object',
						'properties' => array(
							'open_scheduler' => array(
								'type'        => 'boolean',
								'description' => 'True only when the visitor currently requests scheduling or affirmatively accepts the immediately preceding scheduling offer.',
							),
							'draft_claims_opened' => array(
								'type'        => 'boolean',
								'description' => 'Whether the assistant draft says or clearly implies that the scheduler/calendar/available times were opened.',
							),
							'service_id'    => $service_property,
							'team_member_id'=> $member_property,
							'reason'        => array( 'type' => 'string', 'description' => 'Short factual routing reason.' ),
							'corrected_answer' => array(
								'type'        => 'string',
								'description' => 'If open_scheduler is false but the draft falsely claims an open action, provide one short honest replacement in the same language. Otherwise return an empty string.',
							),
						),
						'required' => array( 'open_scheduler', 'draft_claims_opened', 'service_id', 'team_member_id', 'reason', 'corrected_answer' ),
					),
				),
			),
		);

		$messages = array(
			array(
				'role'    => 'system',
				'content' => 'You are a strict scheduling intent gate. Decide from the CURRENT VISITOR MESSAGE and RECENT TURNS, not from the assistant draft claim. Set open_scheduler=true only when the visitor explicitly asks to book/schedule/view or choose appointment dates or times, or clearly accepts the immediately preceding assistant offer to arrange/open scheduling. Resolve short confirmations such as yes/da from the prior turn. Set false for service information, recommendations, vague interest, declines, unrelated confirmations, or an assistant action claim unsupported by visitor intent. Never infer that an action happened merely because the draft says it did. Select only exact configured ids. Always call route_scheduler_turn.',
			),
			array(
				'role'    => 'user',
				'content' => wp_json_encode(
					array(
						'configured_services'    => $catalog,
						'recent_turns'           => $recent_turns,
						'current_visitor_message'=> substr( trim( (string) $query ), 0, 2400 ),
						'assistant_draft'        => substr( $draft, 0, 2400 ),
					)
				),
			),
		);

		$model     = $this->get_frontend_model();
		$auth_mode = $this->auth_manager->get_mode();
		$creds     = $this->auth_manager->get_credentials();
		$use_spark = $this->spark_adapter && class_exists( 'Niwa_AI_Spark_Adapter' ) && Niwa_AI_Spark_Adapter::is_spark_model( $model );
		try {
			$route = $use_spark && method_exists( $this->spark_adapter, 'chat_complete_required' )
				? $this->spark_adapter->chat_complete_required( $model, $messages, $auth_mode, $creds, $tools, 20 )
				: $this->openai_client->chat_complete( $model, $messages, $auth_mode, $creds, 20, $tools, 'required' );
		} catch ( \Throwable $error ) {
			return $result;
		}
		if ( empty( $route['success'] ) || empty( $route['tool_calls'][0]['arguments'] ) ) {
			return $result;
		}
		$args = json_decode( (string) $route['tool_calls'][0]['arguments'], true );
		if ( ! is_array( $args ) ) {
			return $result;
		}

		if ( ! empty( $args['open_scheduler'] ) ) {
			$service_id = isset( $args['service_id'] ) ? sanitize_key( (string) $args['service_id'] ) : '';
			$member_id  = isset( $args['team_member_id'] ) ? sanitize_key( (string) $args['team_member_id'] ) : '';
			$this->scheduler_triggered              = true;
			$this->scheduler_trigger_reason         = substr( sanitize_text_field( isset( $args['reason'] ) ? (string) $args['reason'] : 'Verified scheduling request.' ), 0, 200 );
			$this->scheduler_trigger_service_id     = isset( $service_ids[ $service_id ] ) ? $service_id : '';
			$this->scheduler_trigger_team_member_id = isset( $member_ids[ $member_id ] ) ? $member_id : '';
			return $result;
		}

		if ( ! empty( $args['draft_claims_opened'] ) && ! empty( $args['corrected_answer'] ) ) {
			$corrected = $this->response_postprocessor->clean_answer( (string) $args['corrected_answer'] );
			if ( '' !== trim( $corrected ) ) {
				$result['answer'] = $this->humanizer->apply( $corrected );
			}
		}

		return $result;
	}

	/**
	 * Confirm a proposed lead-form launch against the admin-defined intent.
	 *
	 * This is semantic and language-independent. The form is a UI side effect,
	 * so an unavailable or malformed verifier fails closed and the main model
	 * receives a denial tool result, allowing it to answer normally.
	 *
	 * @param string $query    Current visitor message.
	 * @param array  $context  Current frontend context.
	 * @param string $criteria Admin-defined activation criteria.
	 * @return array{allowed:bool,reason:string}
	 */
	private function verify_lead_form_activation( $query, $context, $criteria ) {
		if ( ! $this->openai_client || '' === trim( (string) $criteria ) ) {
			return array( 'allowed' => false, 'reason' => 'Lead-form intent could not be verified.' );
		}

		$turns = array();
		foreach ( (array) ( $context['_niwa_recent_turns'] ?? array() ) as $message ) {
			if ( ! is_array( $message ) ) {
				continue;
			}
			$role    = isset( $message['role'] ) ? sanitize_key( (string) $message['role'] ) : '';
			$content = isset( $message['content'] ) ? trim( (string) $message['content'] ) : '';
			if ( '' !== $role && '' !== $content ) {
				$turns[] = array( 'role' => $role, 'content' => substr( $content, 0, 1600 ) );
			}
		}

		$tools = array(
			array(
				'type'     => 'function',
				'function' => array(
					'name'        => 'verify_lead_form_intent',
					'description' => 'Return whether the current visitor turn genuinely matches the configured lead-form activation criteria.',
					'parameters'  => array(
						'type'       => 'object',
						'properties' => array(
							'allowed' => array(
								'type'        => 'boolean',
								'description' => 'True only for an explicit match or a clear acceptance of the immediately preceding form offer.',
							),
							'reason' => array( 'type' => 'string', 'description' => 'Short factual decision reason.' ),
						),
						'required' => array( 'allowed', 'reason' ),
					),
				),
			),
		);
		$messages = array(
			array(
				'role'    => 'system',
				'content' => 'You are a strict lead-form intent gate. Judge only the CURRENT VISITOR MESSAGE and RECENT TURNS against the CONFIGURED CRITERIA. Allow only an explicit current match or a clear acceptance of the immediately preceding assistant offer to open that form. Reject unrelated questions, informational requests, topic overlap without conversion intent, vague interest, and any tool proposal unsupported by the visitor. Work in every language. Always call verify_lead_form_intent.',
			),
			array(
				'role'    => 'user',
				'content' => wp_json_encode(
					array(
						'configured_criteria'     => substr( trim( (string) $criteria ), 0, 2400 ),
						'recent_turns'            => $turns,
						'current_visitor_message' => substr( trim( (string) $query ), 0, 2400 ),
					)
				),
			),
		);

		$model     = $this->get_frontend_model();
		$auth_mode = $this->auth_manager->get_mode();
		$creds     = $this->auth_manager->get_credentials();
		$use_spark = $this->spark_adapter && class_exists( 'Niwa_AI_Spark_Adapter' ) && Niwa_AI_Spark_Adapter::is_spark_model( $model );
		$started   = $this->trace_start();
		try {
			$result = $use_spark && method_exists( $this->spark_adapter, 'chat_complete_required' )
				? $this->spark_adapter->chat_complete_required( $model, $messages, $auth_mode, $creds, $tools, 20 )
				: $this->openai_client->chat_complete( $model, $messages, $auth_mode, $creds, 20, $tools, 'required' );
		} catch ( \Throwable $error ) {
			$this->trace_finish( 'lead_form_gate', $started, array( 'success' => false, 'allowed' => false ) );
			return array( 'allowed' => false, 'reason' => 'Lead-form intent verification failed.' );
		}

		$args = ! empty( $result['success'] ) && ! empty( $result['tool_calls'][0]['arguments'] )
			? json_decode( (string) $result['tool_calls'][0]['arguments'], true )
			: null;
		$allowed = is_array( $args ) && ! empty( $args['allowed'] );
		$reason  = is_array( $args ) && isset( $args['reason'] ) ? sanitize_text_field( (string) $args['reason'] ) : 'Lead-form intent was not verified.';
		$this->trace_finish( 'lead_form_gate', $started, array( 'success' => is_array( $args ), 'allowed' => $allowed ) );

		return array( 'allowed' => $allowed, 'reason' => substr( $reason, 0, 200 ) );
	}

	/**
	 * Lazily look up the lead-form session status for a public session id.
     *
     * Returns one of Niwa_AI_Lead_Form_Session_Store::STATUS_* constants when
     * a row exists, or an empty string when there is no session yet, when the
     * id is empty, or when the store class/table is unavailable. Failure is
     * silent — the caller treats "unknown" as "not done yet".
     *
     * @param string $session_id Public chat session id.
     * @return string
     */
    private function get_lead_form_session_status( $session_id ) {
        $session_id = trim( (string) $session_id );
        if ( '' === $session_id ) {
            return '';
        }
        if ( ! class_exists( 'Niwa_AI_Lead_Form_Session_Store' ) ) {
            return '';
        }

        try {
            $store = new Niwa_AI_Lead_Form_Session_Store();
            $row   = $store->find_by_session_id( $session_id );
            if ( ! is_array( $row ) || empty( $row['status'] ) ) {
                return '';
            }
            return (string) $row['status'];
        } catch ( \Throwable $e ) {
            return '';
        }
    }

    /**
     * Unified tool-calling pipeline.
     *
     * Flow:
     * 1. Send query + tools (memory_search, memory_get) to LLM.
     * 2. If LLM calls tools â†’ execute them â†’ send results back â†’ get final answer.
     * 3. If LLM responds directly (simple questions) â†’ return directly.
     * 4. Resolve product cards from tool results or answer text.
     */
    private function answer_with_tools( $query, $session_id, $history, $context = array() ) {
        if ( ! $this->openai_client ) {
            return $this->error_response( 'OpenAI client not available.', $context, $query, 'openai_client_unavailable' );
        }

		$stage_started = $this->trace_start();
		$conversation_context = $this->conversation_context_builder->build( $history, $query, $context, $this->get_history_context_limit() );
		$context['_niwa_recent_turns'] = isset( $conversation_context['messages'] ) && is_array( $conversation_context['messages'] )
			? array_slice( $conversation_context['messages'], -8 )
			: array();
        if ( empty( $context['conversation_language'] ) && ! empty( $conversation_context['language'] ) ) {
            $context['conversation_language'] = $conversation_context['language'];
        }
        $history_text   = isset( $conversation_context['prompt_context'] ) ? (string) $conversation_context['prompt_context'] : '';
		$previous_product_plan = $this->load_frontend_product_plan( $session_id );
		$this->trace_finish( 'context_build', $stage_started, array( 'history_messages' => count( (array) $history ) ) );
		$local_plan_started    = $this->trace_start();
		$catalog_source_hash   = $this->content_store && method_exists( $this->content_store, 'get_product_semantic_lexicon_hash' )
			? (string) $this->content_store->get_product_semantic_lexicon_hash()
			: '';
		$previous_plan_recovered = false;
		if ( empty( $previous_product_plan['constraints'] ) ) {
			$previous_product_plan = $this->recover_frontend_product_plan_from_conversation( $conversation_context, $catalog_source_hash );
			$previous_plan_recovered = ! empty( $previous_product_plan['constraints'] );
		}
		if ( ! empty( $previous_product_plan['constraints'] ) ) {
			$context['_niwa_previous_product_plan'] = $previous_product_plan;
		}
		$local_product_plan    = class_exists( 'Niwa_AI_Frontend_Semantic_Lexicon' )
			? Niwa_AI_Frontend_Semantic_Lexicon::resolve_query( $query, $previous_product_plan, $catalog_source_hash )
			: array( 'complete' => false, 'reason' => 'semantic_lexicon_unavailable' );
		$context['_niwa_current_product_plan'] = $local_product_plan;
		$this->trace_finish(
			'product_local_plan',
			$local_plan_started,
			array(
				'accepted'        => ! empty( $local_product_plan['complete'] ),
				'reason'          => isset( $local_product_plan['reason'] ) ? (string) $local_product_plan['reason'] : '',
				'unresolved_count'=> count( (array) ( $local_product_plan['unresolved_terms'] ?? array() ) ),
				'ambiguous_count' => count( (array) ( $local_product_plan['ambiguous_fields'] ?? array() ) ),
				'previous_plan_recovered' => $previous_plan_recovered,
			)
		);

		/*
		 * A short option reply after the assistant has named one exact card is not
		 * product discovery. Resolve the named card back to its product_id and
		 * inspect only that product's live variations before any catalog search or
		 * model call. This keeps "44" attached to the selected model instead of
		 * allowing retrieval ranking to silently replace it with another product.
		 */
		$selected_option_answer = $this->try_selected_product_option_follow_up(
			$query,
			$session_id,
			$conversation_context,
			$previous_product_plan,
			$context,
			$local_product_plan
		);
		if ( ! empty( $selected_option_answer['success'] ) ) {
			return $selected_option_answer;
		}

		$store_overview = $this->store_overview ? $this->store_overview->build() : "[STORE OVERVIEW]\nNo store data available.\n[/STORE OVERVIEW]";
		$instructions   = $this->build_tool_instructions( $store_overview, $history_text, $query, $context );
		if ( ! empty( $previous_product_plan['constraints'] ) ) {
			$instructions .= "\n\n[PREVIOUS_PRODUCT_PLAN]\n" . wp_json_encode( $previous_product_plan ) . "\n[/PREVIOUS_PRODUCT_PLAN]\n"
				. "- Set query_mode=follow_up only when the current message continues this product request. In that mode, list only fields explicitly replaced by the current message in replaced_fields; the backend retains every other previous constraint.\n"
				. "- Set query_mode=fresh for a new independent product request; the previous plan is then ignored.";
		}
		if ( empty( $local_product_plan['complete'] ) && ! empty( $local_product_plan['constraints'] ) ) {
			$instructions .= "\n\n[CATALOG_SEMANTIC_HINTS]\n"
				. wp_json_encode(
					array(
						'verified_constraints' => (array) $local_product_plan['constraints'],
						'unresolved_terms'     => (array) ( $local_product_plan['unresolved_terms'] ?? array() ),
						'ambiguous_fields'     => (array) ( $local_product_plan['ambiguous_fields'] ?? array() ),
					),
					JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES
				)
				. "\n[/CATALOG_SEMANTIC_HINTS]\n"
				. "- These catalog concepts are verified but incomplete. Resolve unresolved grammar and any same-field ambiguity from the current message and conversation; never silently discard either.\n"
				. '- If this is a product request, retain every applicable verified constraint in the complete structured memory_search plan.';
		}
		$tools          = $this->get_memory_tools_schema();
        $frontend_model = $this->get_frontend_model();
        $auth_mode      = $this->auth_manager->get_mode();
        $creds          = $this->auth_manager->get_credentials();
		$model_input    = array_merge(
			(array) ( $conversation_context['messages'] ?? array() ),
			array( array( 'role' => 'user', 'content' => (string) $query ) )
		);

        // Retrieval-first: for queries that need store data, run memory_search
        // BEFORE the first LLM call and inject the results into the prompt.
        // The common case then finishes in a single LLM call instead of a
        // tool round-trip (search → results → second call).
        $prefetch_id       = 'prefetch_search_' . substr( md5( (string) $query ), 0, 8 );
		$prefetch_request  = array( 'query' => (string) $query, 'scope' => 'all', 'limit' => self::MAX_FRONTEND_CONTEXT_RESULTS );
		if ( ! empty( $local_product_plan['complete'] ) ) {
			$prefetch_request = array_merge(
				$prefetch_request,
				array(
					'scope'                => 'product',
					'query_mode'           => (string) ( $local_product_plan['query_mode'] ?? 'fresh' ),
					'replaced_fields'      => (array) ( $local_product_plan['replaced_fields'] ?? array() ),
					'constraints_complete' => true,
					'allow_alternatives'   => false,
					'unresolved_terms'     => array(),
					'constraints'          => (array) ( $local_product_plan['constraints'] ?? array() ),
				)
			);
			if ( 'follow_up' === $prefetch_request['query_mode'] && ! empty( $previous_product_plan['response_language'] ) ) {
				$prefetch_request['response_language'] = (string) $previous_product_plan['response_language'];
			}
		}
		$prefetch_args     = wp_json_encode( $prefetch_request );
		$prefetch_payloads = array();
		$prefetch_ran      = false;
		$allow_eager_prefetch = ! empty( $local_product_plan['complete'] )
			|| $this->should_eager_prefetch_frontend_memory( $query, $previous_product_plan, $conversation_context );
		if ( $this->memory_searcher && $allow_eager_prefetch ) {
            $prefetch_payloads = $this->execute_memory_tools(
                array(
                    array(
                        'id'        => $prefetch_id,
                        'name'      => 'memory_search',
                        'arguments' => $prefetch_args,
                    ),
                ),
                $query,
                $context,
                $session_id
            );
            $prefetch_ran = ! empty( $prefetch_payloads ) && ! empty( $prefetch_payloads[0]['content'] );
            if ( $prefetch_ran ) {
                // Neutralize the "search first" prompt rules — the search already
                // happened. Leaving them in makes the model call memory_search
                // again and pay the tool round-trip this prefetch exists to avoid.
                $instructions = str_replace(
					'- Use memory_search before answering questions that need current product, store, page, policy, price, stock, shipping, contact, cart, or checkout facts, unless a cart or option follow-up clearly refers to one currently shown exact product card; use that card product_id and the live cart or variation tool directly.',
                    '- Search results for the current question are already provided in the [SEARCH RESULTS] block. Use them directly instead of calling memory_search.',
                    $instructions
                );
                $instructions .= "\n\n[SEARCH RESULTS]\n" . (string) $prefetch_payloads[0]['content'] . "\n[/SEARCH RESULTS]\n"
                    . "- The block above IS the memory_search result for the current user message, already executed for you.\n"
					. "- Search results are evidence, not intent. Resolve the current message from the recent conversation first; never start a new product recommendation merely because product rows appear here.\n"
					. "- For a short reply, confirmation, correction, or selection, continue the exact pending subject and ignore unrelated product rows.\n"
                    . "- Answer directly from these results for non-product facts and exact product identity lookups.\n"
                    . "- For add-to-cart requests, treat this block as the memory_search result you were asked to inspect before cart_add_product.\n"
					. "- If this is a product availability/recommendation request with type or attribute constraints and results do not contain match_class, call memory_search once with the complete structured constraints array. This is eligibility classification, not a redundant literal search.\n"
                    . "- Call memory_search again when an important constraint is missing, using constraints plus query_variants for grammatical forms and semantic paraphrases in the visitor's language.";
            }
        }

        // First API call — the configured frontend model decides whether the
        // current turn needs site evidence, structured product search, or no tool.
        $use_spark = $this->spark_adapter && Niwa_AI_Spark_Adapter::is_spark_model( $frontend_model );
		$stage_started = $this->trace_start();
		if ( $use_spark ) {
			$first = $this->spark_adapter->generate_with_tools( $frontend_model, $model_input, $instructions, $tools, $auth_mode, $creds, self::TOOL_ROUND_TIMEOUT );
		} else {
			$first = $this->openai_client->generate_with_tools( $frontend_model, $model_input, $instructions, $tools, $auth_mode, $creds, self::TOOL_ROUND_TIMEOUT );
        }
		if ( ! empty( $first['tool_calls'] ) ) {
			$first['tool_calls'] = $this->ground_frontend_product_tool_calls( $first['tool_calls'], $query );
		}
		$this->trace_finish(
			'llm_first',
			$stage_started,
			array(
				'model'      => $frontend_model,
				'success'    => ! empty( $first['success'] ),
				'tool_calls' => isset( $first['tool_calls'] ) && is_array( $first['tool_calls'] ) ? count( $first['tool_calls'] ) : 0,
				'planner'    => false,
				'prompt_chars' => strlen( $instructions ),
				'tool_schema_chars' => strlen( wp_json_encode( $tools ) ),
			)
		);

        if ( empty( $first['success'] ) ) {
            // Tool calling completely failed — try RAG fallback as last resort.
            // This uses WooCommerce keyword search (independent of memory_searcher).
            $rag_result = $this->answer_with_rag_fallback( $query, $history, array(), $context );
            if ( ! empty( $rag_result['success'] ) && ! in_array( (string) ( $rag_result['source'] ?? '' ), array( 'model_error', 'rlm_empty' ), true ) ) {
                return $rag_result;
            }
            $err = isset( $first['error'] ) ? (string) $first['error'] : 'unknown';
            return $this->error_response( 'First API call failed. ' . $err, $context, $query, 'first_api_call_failed' );
        }

        // Spark prompt-based fallback: adapter already searched and got an answer.
        if ( ! empty( $first['_spark_fallback'] ) && '' !== ( isset( $first['answer'] ) ? (string) $first['answer'] : '' ) ) {
            $raw_answer = $this->response_postprocessor->clean_answer( (string) $first['answer'] );
            $answer     = $this->humanizer->apply( $raw_answer );
            $payloads   = $prefetch_payloads;
            if ( ! empty( $first['_fallback_card_ids'] ) ) {
                $payloads[] = array(
                    '_card_ids'  => (array) $first['_fallback_card_ids'],
					'_card_refs' => $this->build_product_card_references( isset( $first['_fallback_results'] ) ? $first['_fallback_results'] : ( isset( $first['_fallback_rows'] ) ? $first['_fallback_rows'] : array() ) ),
					'_queries'   => array( (string) $query ),
                );
            }
			$cards = $this->select_product_cards( $raw_answer, $payloads );

            $confidence = 0.5;
            if ( ! empty( $first['_fallback_rows'] ) && $this->memory_searcher ) {
                $confidence = $this->memory_searcher->compute_confidence( $first['_fallback_rows'] );
            }

            return array(
                'success'    => true,
                'answer'     => $answer,
                'error'      => '',
                'confidence' => $confidence,
                'cards'      => $cards,
                'source'     => ! empty( $cards ) ? 'rlm_tools' : 'rlm_tools_text_only',
            );
        }

        if ( empty( $first['tool_calls'] ) ) {
            $raw_answer = isset( $first['answer'] ) ? (string) $first['answer'] : '';
            $raw_answer = $this->response_postprocessor->clean_answer( $raw_answer );

            // With prefetch the answer is already grounded in search results,
            // so the configured frontend model can return it directly.
            if ( '' !== $raw_answer ) {
				$reconciled = $this->reconcile_structured_product_answer(
					$query,
					$conversation_context,
					$raw_answer,
					$prefetch_payloads,
					$previous_product_plan,
					true
				);
				if ( empty( $reconciled['success'] ) ) {
					return $this->error_response(
						(string) ( $reconciled['error'] ?? 'Verified product answer reconciliation failed.' ),
						$context,
						$query,
						'product_answer_grounding_failed'
					);
				}
				$raw_answer = (string) $reconciled['answer'];
                $answer = $this->humanizer->apply( $raw_answer );
				$cards  = $this->select_product_cards( $raw_answer, $prefetch_payloads );

                $confidence = 0.4;
                foreach ( $prefetch_payloads as $payload ) {
                    if ( ! empty( $payload['_rows'] ) && $this->memory_searcher ) {
                        $confidence = $this->memory_searcher->compute_confidence( $payload['_rows'] );
                        break;
                    }
                }

                return array(
                    'success'    => true,
                    'answer'     => $answer,
                    'error'      => '',
                    'confidence' => $confidence,
                    'cards'      => $cards,
                    'source'     => ! empty( $cards ) ? 'rlm_tools' : 'rlm_tools_text_only',
                );
            }

            // LLM said “I don't know” or gave no answer — force memory_search
            // before concluding we don't have the information. When prefetch
            // already ran, reuse its payloads instead of searching again.
            if ( $prefetch_ran ) {
                $synthetic_id   = $prefetch_id;
                $synthetic_args = $prefetch_args;
                $tool_payloads  = $prefetch_payloads;
                $prefetch_payloads = array(); // consumed as tool messages — do not double-count below.
            } else {
                $synthetic_id   = 'fallback_search_' . substr( md5( $query ), 0, 8 );
                $synthetic_args = wp_json_encode( array( 'query' => (string) $query, 'scope' => 'all', 'limit' => self::MAX_FRONTEND_CONTEXT_RESULTS ) );

                $synthetic_call = array(
                    array(
                        'id'        => $synthetic_id,
                        'name'      => 'memory_search',
                        'arguments' => $synthetic_args,
                    ),
                );
                $tool_payloads = $this->execute_memory_tools( $synthetic_call, $query, $context, $session_id );
            }

            // Build synthetic assistant message with tool_call so API accepts tool results.
            $first['assistant_message'] = array(
                'role'       => 'assistant',
                'content'    => '',
                'tool_calls' => array(
                    array(
                        'id'       => $synthetic_id,
                        'type'     => 'function',
                        'function' => array(
                            'name'      => 'memory_search',
                            'arguments' => $synthetic_args,
                        ),
                    ),
                ),
            );
        } else {
            // Execute tool calls normally.
            $tool_payloads = $this->execute_memory_tools( $first['tool_calls'], $query, $context, $session_id );
        }
		$resolved_turn_language = $this->resolve_product_response_language( $tool_payloads, $previous_product_plan );
		if ( '' !== $resolved_turn_language ) {
			$instructions .= "\n\n[RESOLVED_TURN_LANGUAGE]\n"
				. 'The product intent planner resolved the active conversation language as '
				. $this->get_response_language_label( $resolved_turn_language )
				. ' (' . $resolved_turn_language . '). Write the complete customer-facing answer in that language.'
				. "\n[/RESOLVED_TURN_LANGUAGE]";
		}
        // Build messages for second API call.
        $assistant_msg = isset( $first['assistant_message'] ) ? $first['assistant_message'] : array();

		$messages = array(
			array( 'role' => 'system', 'content' => $instructions ),
		);
        if ( ! empty( $conversation_context['messages'] ) && is_array( $conversation_context['messages'] ) ) {
            foreach ( $conversation_context['messages'] as $history_message ) {
                if ( ! is_array( $history_message ) || empty( $history_message['content'] ) ) {
                    continue;
                }
                $messages[] = array(
                    'role'    => 'assistant' === ( isset( $history_message['role'] ) ? $history_message['role'] : '' ) ? 'assistant' : 'user',
                    'content' => (string) $history_message['content'],
                );
            }
        }
        $messages[] = array( 'role' => 'user', 'content' => (string) $query );
        if ( ! empty( $assistant_msg ) ) {
            $messages[] = $assistant_msg;
        }
        foreach ( $tool_payloads as $payload ) {
			$messages[] = $this->build_llm_tool_message( $payload );
        }

        // Final answer stage.
        // Some GPT-5.x Codex OAuth runs continue with additional tool calls after
        // the first replay round, so keep following the tool chain until the model
        // actually returns text or we hit a hard round limit.
        $final = $this->complete_final_tool_rounds(
            $messages,
            $query,
            $frontend_model,
            $auth_mode,
            $creds,
            $tools,
            $use_spark,
            $tool_payloads,
            $context,
            $session_id
        );
        if ( empty( $final['success'] ) ) {
            // Second call failed — try RAG fallback as last resort.
            $rag_result = $this->answer_with_rag_fallback( $query, $history, array(), $context );
            if ( ! empty( $rag_result['success'] ) && ! in_array( (string) ( $rag_result['source'] ?? '' ), array( 'model_error', 'rlm_empty' ), true ) ) {
                return $rag_result;
            }
            $err = isset( $final['error'] ) ? (string) $final['error'] : 'unknown';
            return $this->error_response( 'Second API call failed. ' . $err, $context, $query, 'final_api_call_failed' );
        }

        $raw_answer = $this->response_postprocessor->clean_answer( (string) $final['answer'] );
		$card_source_payloads = $this->prefer_structured_product_payloads( $prefetch_payloads, $tool_payloads );
		$reconciled = $this->reconcile_structured_product_answer(
			$query,
			$conversation_context,
			$raw_answer,
			$card_source_payloads,
			$previous_product_plan
		);
		if ( empty( $reconciled['success'] ) ) {
			return $this->error_response(
				(string) ( $reconciled['error'] ?? 'Verified product answer reconciliation failed.' ),
				$context,
				$query,
				'product_answer_grounding_failed'
			);
		}
		$raw_answer = (string) $reconciled['answer'];
        $answer     = $this->humanizer->apply( $raw_answer );

        // A successful transport with no usable model text is still retryable.
        // Never turn that runtime failure into a fabricated customer answer.
        if ( '' === trim( $answer ) ) {
			return $this->error_response( 'Final model response contained no usable text.', $context, $query, 'empty_model_response' );
        }

        // Resolve product cards — primary: match product names the LLM actually mentioned in its answer.
        // Prefetch payloads are merged for card/confidence resolution only; they were never
        // sent as tool messages in this branch, so they must not touch $tool_payloads earlier.
		// Structured follow-up searches must win over the earlier raw prefetch
		// when the same product appears in both payload sets.
		$cards = $this->select_product_cards( $raw_answer, $card_source_payloads );

        // Compute confidence from tool results.
        $confidence = 0.7;
        foreach ( $card_source_payloads as $payload ) {
            if ( ! empty( $payload['_rows'] ) && $this->memory_searcher ) {
                $confidence = $this->memory_searcher->compute_confidence( $payload['_rows'] );
                break;
            }
        }

        $response = array(
            'success'    => true,
            'answer'     => $answer,
            'error'      => '',
            'confidence' => $confidence,
            'cards'      => $cards,
            'source'     => ! empty( $cards ) ? 'rlm_tools' : 'rlm_tools_text_only',
        );
        $cart_result = $this->extract_latest_cart_result( $tool_payloads );
        if ( ! empty( $cart_result ) ) {
            $response['cart'] = $cart_result;
        }
        $navigation_result = $this->extract_latest_navigation_result( $tool_payloads );
        if ( ! empty( $navigation_result ) ) {
            $response['navigation'] = $navigation_result;
        }

        return $response;
    }

	/**
	 * Do not inject an unrelated catalog search into a short conversational turn
	 * while a product choice is pending. The normal tool-capable model can still
	 * call memory_search when the message genuinely changes catalog constraints;
	 * this only disables the unconditional search that happened before intent was
	 * resolved.
	 */
	private function should_eager_prefetch_frontend_memory( $query, $previous_product_plan = array(), $conversation_context = array() ) {
		$has_plan  = ! empty( $previous_product_plan['constraints'] );
		$has_cards = ! empty( $conversation_context['cards'] );
		$has_turns = ! empty( $conversation_context['messages'] );
		if ( $has_turns && ( $has_plan || $has_cards ) ) {
			return false;
		}
		return true;
	}

	/** Ground each structured field against its own catalog concept before search. */
	private function ground_frontend_product_tool_calls( $tool_calls, $query ) {
		if (
			! is_array( $tool_calls )
			|| ! class_exists( 'Niwa_AI_Frontend_Semantic_Lexicon' )
			|| ! method_exists( 'Niwa_AI_Frontend_Semantic_Lexicon', 'ground_constraints' )
			|| ! $this->content_store
			|| ! method_exists( $this->content_store, 'get_product_semantic_lexicon_hash' )
		) {
			return (array) $tool_calls;
		}

		$source_hash = (string) $this->content_store->get_product_semantic_lexicon_hash();
		foreach ( $tool_calls as &$call ) {
			if ( ! is_array( $call ) || 'memory_search' !== (string) ( $call['name'] ?? '' ) ) {
				continue;
			}
			$args = json_decode( (string) ( $call['arguments'] ?? '' ), true );
			if ( ! is_array( $args ) || empty( $args['constraints'] ) ) {
				continue;
			}
			$args['constraints'] = Niwa_AI_Frontend_Semantic_Lexicon::ground_constraints(
				$query,
				$args['constraints'],
				$source_hash
			);
			$call['arguments'] = wp_json_encode( $args );
		}
		unset( $call );

		return $tool_calls;
	}

	private function get_semantic_lexicon_tool_schema() {
		$canonical_fields = array(
			'department',
			'product_type',
			'product_family',
			'category',
			'brand',
			'color',
			'material',
			'gender',
			'use_case',
			'fit',
			'feature',
			'form',
			'flavor',
			'scent',
			'skin_type',
			'finish',
			'mounting',
			'chambers',
			'ingredient',
			'weight',
			'capacity',
			'dimensions',
			'voltage',
			'power',
			'cover',
			'language',
			'publisher',
		);
		$alias_list = array(
			'type'     => 'array',
			'maxItems' => 8,
			'items'    => array( 'type' => 'string' ),
		);
		$descendant_list = $alias_list;
		$descendant_list['maxItems'] = 8;
		$related_list = $alias_list;
		$related_list['maxItems'] = 6;
		$parent_list = $alias_list;
		$parent_list['maxItems'] = 4;
		return array(
			array(
				'type'     => 'function',
				'function' => array(
					'name'        => 'store_semantic_lexicon',
					'description' => 'Store the complete catalog semantic vocabulary for deterministic runtime intent parsing.',
					'parameters'  => array(
						'type'       => 'object',
						'properties' => array(
							'concepts' => array(
								'type'     => 'array',
								'minItems' => 1,
								'maxItems' => 140,
								'items'    => array(
									'type'       => 'object',
									'properties' => array(
										'field'       => array(
											'type'        => 'string',
											'enum'        => $canonical_fields,
											'description' => 'Canonical eligibility dimension. Never use translated names or generic group.',
										),
										'label'       => array( 'type' => 'string' ),
										'value'       => array( 'type' => 'string' ),
										'aliases'     => $alias_list,
										'descendants' => $descendant_list,
										'related'     => $related_list,
										'parents'     => $parent_list,
									),
									'required' => array( 'field', 'value', 'aliases', 'descendants', 'related', 'parents' ),
								),
							),
							'ignorable_phrases' => array(
								'type'     => 'array',
								'maxItems' => 80,
								'items'    => array( 'type' => 'string' ),
							),
							'action_phrases' => array(
								'type'     => 'array',
								'maxItems' => 40,
								'items'    => array( 'type' => 'string' ),
							),
						),
						'required' => array( 'concepts', 'ignorable_phrases', 'action_phrases' ),
					),
				),
			),
		);
	}

	private function payloads_have_structured_product_constraints( $payloads ) {
		foreach ( (array) $payloads as $payload ) {
			if (
				is_array( $payload )
				&& ! empty( $payload['_product_constraints'] )
				&& ! empty( $payload['_product_constraints_complete'] )
				&& empty( $payload['_product_unresolved_terms'] )
				&& (
					! empty( $payload['_product_card_grounding_complete'] )
					|| ( ! array_key_exists( '_product_search_complete', $payload ) || ! empty( $payload['_product_search_complete'] ) )
				)
			) {
				return true;
			}
		}
		return false;
	}

	/**
	 * Once structured results exist, raw prefetch product references are
	 * discovery-only and cannot create cards for another, unclassified ID.
	 */
	private function prefer_structured_product_payloads( $prefetch_payloads, $tool_payloads ) {
		$all = array_merge( (array) $prefetch_payloads, (array) $tool_payloads );
		if ( ! $this->payloads_have_structured_product_constraints( $tool_payloads ) ) {
			return $all;
		}
		foreach ( $all as &$payload ) {
			if ( ! is_array( $payload ) || ! empty( $payload['_product_constraints'] ) ) {
				continue;
			}
			unset( $payload['_card_ids'], $payload['_card_refs'] );
		}
		unset( $payload );
		return $all;
	}

	/**
	 * Return the complete best-match tier from a finished structured search.
	 *
	 * Product alternatives from weaker tiers stay available to the model for
	 * explanation, but they cannot silently replace or dilute the verified
	 * finalists used by the visible answer and cards.
	 */
	private function get_structured_product_finalists( $payloads ) {
		$references = array();
		$best_priority = PHP_INT_MAX;
		foreach ( (array) $payloads as $payload ) {
			if (
				! is_array( $payload )
				|| empty( $payload['_product_constraints'] )
				|| empty( $payload['_product_constraints_complete'] )
				|| ! empty( $payload['_product_unresolved_terms'] )
				|| empty( $payload['_product_search_complete'] )
			) {
				continue;
			}
			foreach ( (array) ( $payload['_card_refs'] ?? array() ) as $reference ) {
				if ( ! is_array( $reference ) ) {
					continue;
				}
				$product_id = intval( $reference['id'] ?? 0 );
				$title      = trim( (string) ( $reference['title'] ?? '' ) );
				$match_class = (string) ( $reference['match_class'] ?? '' );
				if (
					$product_id <= 0
					|| '' === $title
					|| ( array_key_exists( 'card_eligible', $reference ) && empty( $reference['card_eligible'] ) )
					|| Niwa_AI_Frontend_Product_Query_Plan::REJECTED === $match_class
				) {
					continue;
				}
				$priority = Niwa_AI_Frontend_Product_Query_Plan::class_priority( $match_class );
				if ( $priority < $best_priority ) {
					$best_priority = $priority;
					$references = array();
				}
				if ( $priority !== $best_priority || isset( $references[ $product_id ] ) ) {
					continue;
				}
				$references[ $product_id ] = array_filter(
					array(
						'id'                    => $product_id,
						'title'                 => $title,
						'url'                   => (string) ( $reference['url'] ?? '' ),
						'match_class'           => $match_class,
						'disclosure_required'   => ! empty( $reference['disclosure_required'] ),
						'matched_constraints'   => (array) ( $reference['matched_constraints'] ?? array() ),
						'missing_constraints'   => (array) ( $reference['missing_constraints'] ?? array() ),
						'constraint_evidence'   => (array) ( $reference['constraint_evidence'] ?? array() ),
						'variation_preview'     => (array) ( $reference['variation_preview'] ?? array() ),
					),
					static function ( $value ) {
						return ! ( '' === $value || array() === $value );
					}
				);
			}
		}
		return array_slice( array_values( $references ), 0, 4 );
	}

	/**
	 * Repair only a proven disagreement between structured finalists and text.
	 *
	 * The consistency check is based on product IDs/titles from tool payloads,
	 * never on language-specific negative-answer keywords.
	 */
	private function reconcile_structured_product_answer( $query, $conversation_context, $draft, $payloads, $previous_plan, $force_compose = false ) {
		$finalists = $this->get_structured_product_finalists( $payloads );
		if ( empty( $finalists ) ) {
			return array( 'success' => true, 'answer' => (string) $draft, 'repaired' => false );
		}

		$expected_ids = array_values( array_unique( array_map( 'intval', array_column( $finalists, 'id' ) ) ) );
		$grounded_ids = $this->product_card_selector->resolve_grounded_product_ids( $draft, $payloads );
		if ( ! $force_compose && empty( array_diff( $expected_ids, $grounded_ids ) ) ) {
			return array( 'success' => true, 'answer' => (string) $draft, 'repaired' => false );
		}

		$stage_started = $this->trace_start();
		$composed = $this->product_response_composer->compose(
			$query,
			$conversation_context,
			$finalists,
			$this->resolve_product_response_language( $payloads, $previous_plan ),
			$this->get_frontend_model(),
			$this->auth_manager->get_mode(),
			$this->auth_manager->get_credentials()
		);
		if ( empty( $composed['success'] ) ) {
			$this->trace_finish(
				'product_answer_reconcile',
				$stage_started,
				array( 'finalists' => count( $finalists ), 'repaired' => false, 'reason' => 'composition_failed' )
			);
			return array(
				'success' => false,
				'error'   => (string) ( $composed['error'] ?? 'Grounded product response composition failed.' ),
			);
		}

		$answer = $this->response_postprocessor->clean_answer( (string) ( $composed['answer'] ?? '' ) );
		$grounded_ids = $this->product_card_selector->resolve_grounded_product_ids( $answer, $payloads );
		if ( '' === trim( $answer ) || ! empty( array_diff( $expected_ids, $grounded_ids ) ) ) {
			$this->trace_finish(
				'product_answer_reconcile',
				$stage_started,
				array( 'finalists' => count( $finalists ), 'repaired' => false, 'reason' => 'ungrounded_rewrite' )
			);
			return array(
				'success' => false,
				'error'   => 'Grounded product response omitted a verified finalist.',
			);
		}

		$this->trace_finish(
			'product_answer_reconcile',
			$stage_started,
			array( 'finalists' => count( $finalists ), 'repaired' => true )
		);
		return array( 'success' => true, 'answer' => $answer, 'repaired' => true );
	}

	private function frontend_product_plan_transient_key( $session_id ) {
		$session_id = trim( (string) $session_id );
		return '' === $session_id ? '' : 'niwa_ai_frontend_product_plan_' . substr( hash( 'sha256', $session_id ), 0, 32 );
	}

	private function frontend_selected_product_transient_key( $session_id ) {
		$session_id = trim( (string) $session_id );
		return '' === $session_id ? '' : 'niwa_ai_frontend_selected_product_' . substr( hash( 'sha256', $session_id ), 0, 32 );
	}

	/**
	 * Resolve one exact product identity only when a recent assistant turn names
	 * exactly one of the cards that were actually shown to the visitor.
	 */
	private function resolve_selected_product_from_conversation_context( $conversation_context, $assistant_answer = '' ) {
		$conversation_context = is_array( $conversation_context ) ? $conversation_context : array();
		$cards = array_values(
			array_filter(
				(array) ( $conversation_context['cards'] ?? array() ),
				static function ( $card ) {
					return is_array( $card ) && intval( $card['id'] ?? 0 ) > 0 && '' !== trim( (string) ( $card['name'] ?? '' ) );
				}
			)
		);
		if ( empty( $cards ) ) {
			return array();
		}

		$texts = array();
		$assistant_answer = trim( (string) $assistant_answer );
		if ( '' !== $assistant_answer ) {
			$texts[] = $assistant_answer;
		}
		foreach ( array_reverse( (array) ( $conversation_context['messages'] ?? array() ) ) as $message ) {
			if ( ! is_array( $message ) || 'assistant' !== (string) ( $message['role'] ?? '' ) ) {
				continue;
			}
			$text = trim( (string) ( $message['content'] ?? '' ) );
			if ( '' !== $text ) {
				$texts[] = $text;
			}
		}

		foreach ( $texts as $text ) {
			$normalized_text = ' ' . $this->normalize_for_match( $text ) . ' ';
			$matches = array();
			foreach ( $cards as $card ) {
				$normalized_name = $this->normalize_for_match( $card['name'] );
				if ( '' !== $normalized_name && false !== mb_stripos( $normalized_text, ' ' . $normalized_name . ' ', 0, 'UTF-8' ) ) {
					$matches[ intval( $card['id'] ) ] = $card;
				}
			}
			if ( 1 === count( $matches ) ) {
				return reset( $matches );
			}
		}

		return array();
	}

	private function load_frontend_selected_product( $session_id, $conversation_context = array() ) {
		$key = $this->frontend_selected_product_transient_key( $session_id );
		if ( '' === $key || ! function_exists( 'get_transient' ) ) {
			return array();
		}
		$selected = get_transient( $key );
		if ( ! is_array( $selected ) || intval( $selected['id'] ?? 0 ) <= 0 || '' === trim( (string) ( $selected['name'] ?? '' ) ) ) {
			return array();
		}

		/* A stored selection is valid only while the newest assistant turn still names it. */
		$messages = array_reverse( (array) ( is_array( $conversation_context ) ? ( $conversation_context['messages'] ?? array() ) : array() ) );
		foreach ( $messages as $message ) {
			if ( ! is_array( $message ) || 'assistant' !== (string) ( $message['role'] ?? '' ) ) {
				continue;
			}
			$text = ' ' . $this->normalize_for_match( (string) ( $message['content'] ?? '' ) ) . ' ';
			$name = $this->normalize_for_match( $selected['name'] );
			return '' !== $name && false !== mb_stripos( $text, ' ' . $name . ' ', 0, 'UTF-8' ) ? $selected : array();
		}
		return array();
	}

	private function store_frontend_selected_product( $session_id, $selected ) {
		$key = $this->frontend_selected_product_transient_key( $session_id );
		$id  = is_array( $selected ) ? intval( $selected['id'] ?? 0 ) : 0;
		$name = is_array( $selected ) ? trim( (string) ( $selected['name'] ?? '' ) ) : '';
		if ( '' === $key || $id <= 0 || '' === $name || ! function_exists( 'set_transient' ) ) {
			return false;
		}
		$ttl = defined( 'HOUR_IN_SECONDS' ) ? HOUR_IN_SECONDS : 3600;
		return set_transient( $key, array( 'id' => $id, 'name' => $name, 'variable' => ! empty( $selected['variable'] ) ), $ttl );
	}

	/** Persist the exact card named by the assistant after a selection turn. */
	private function remember_frontend_selected_product( $session_id, $history, $result, $context = array() ) {
		if ( ! is_array( $result ) || empty( $result['success'] ) || empty( $result['answer'] ) ) {
			return;
		}
		$conversation_context = $this->conversation_context_builder->build( $history, '', $context, $this->get_history_context_limit() );
		$selected = $this->resolve_selected_product_from_conversation_context( $conversation_context, (string) $result['answer'] );
		if ( ! empty( $selected ) ) {
			$this->store_frontend_selected_product( $session_id, $selected );
		}
	}

	/**
	 * Match a short reply against one declared variation option on the selected
	 * product. Matching is structural (declared attribute values), not a list of
	 * language-specific keywords.
	 */
	private function match_selected_product_option_reply( $query, $availability ) {
		$query_normalized = $this->normalize_for_match( $query );
		$matches = array();
		if ( '' !== $query_normalized && mb_strlen( $query_normalized, 'UTF-8' ) <= 64 ) {
			foreach ( (array) ( $availability['groups'] ?? array() ) as $group_key => $group ) {
				if ( ! is_array( $group ) ) {
					continue;
				}
				$label = trim( (string) ( $group['label'] ?? '' ) );
				if ( '' === $label ) {
					$label = trim( str_replace( array( 'pa_', '_' ), array( '', ' ' ), (string) $group_key ) );
				}
				$label_normalized = $this->normalize_for_match( $label );
				foreach ( (array) ( $group['parent_values'] ?? array() ) as $value ) {
					$value = trim( (string) $value );
					$value_normalized = $this->normalize_for_match( $value );
					if ( '' === $value_normalized ) {
						continue;
					}
					$accepted = array( $value_normalized );
					if ( '' !== $label_normalized ) {
						$accepted[] = $label_normalized . ' ' . $value_normalized;
						$accepted[] = $value_normalized . ' ' . $label_normalized;
					}
					if ( in_array( $query_normalized, $accepted, true ) ) {
						$matches[ (string) $group_key ] = array(
							'group' => (string) $group_key,
							'label' => $label,
							'value' => $value,
						);
					}
				}
			}
			if ( empty( $matches ) && 1 === preg_match( '/^\d+(?:[.,]\d+)?$/u', $query_normalized ) ) {
				$numeric_groups = array();
				foreach ( (array) ( $availability['groups'] ?? array() ) as $group_key => $group ) {
					$values = is_array( $group ) ? (array) ( $group['parent_values'] ?? array() ) : array();
					if ( empty( $values ) ) {
						continue;
					}
					$numeric_values = array_filter(
						$values,
						function ( $value ) {
							return 1 === preg_match( '/^\d+(?:[.,]\d+)?$/u', $this->normalize_for_match( $value ) );
						}
					);
					if ( count( $numeric_values ) !== count( $values ) ) {
						continue;
					}
					$label = trim( (string) ( $group['label'] ?? '' ) );
					if ( '' === $label ) {
						$label = trim( str_replace( array( 'pa_', '_' ), array( '', ' ' ), (string) $group_key ) );
					}
					$numeric_groups[ (string) $group_key ] = array( 'group' => (string) $group_key, 'label' => $label, 'value' => trim( (string) $query ) );
				}
				if ( 1 === count( $numeric_groups ) ) {
					return reset( $numeric_groups );
				}
			}
			if ( 1 === count( $matches ) ) {
				return reset( $matches );
			}
		}

		return array();
	}

	/** Map one validated Woo option back to its catalog-plan field. */
	private function selected_product_option_constraint_field( $current_product_plan, $option ) {
		if ( ! class_exists( 'Niwa_AI_Frontend_Product_Query_Plan' ) || empty( $option['value'] ) ) {
			return '';
		}
		$replaced = array_fill_keys(
			array_values(
				array_filter(
					array_map( 'sanitize_key', (array) ( $current_product_plan['replaced_fields'] ?? array() ) )
				)
			),
			true
		);
		$matching_fields = array();
		foreach ( $this->normalize_product_eligibility_constraints( $current_product_plan['constraints'] ?? array() ) as $constraint ) {
			$field = sanitize_key( (string) ( $constraint['field'] ?? '' ) );
			if ( '' === $field || ( ! empty( $replaced ) && ! isset( $replaced[ $field ] ) ) ) {
				continue;
			}
			if ( Niwa_AI_Frontend_Product_Query_Plan::constraint_matches_values( array( (string) $option['value'] ), $constraint ) ) {
				$matching_fields[ $field ] = true;
			}
		}
		return 1 === count( $matching_fields ) ? (string) array_key_first( $matching_fields ) : '';
	}

	/** Replace the previous constraint on the selected variation axis. */
	private function replace_selected_product_option_constraint( $constraints, $availability, $option ) {
		$constraints = $this->normalize_product_eligibility_constraints( $constraints );
		$field       = sanitize_key( (string) ( $option['constraint_field'] ?? '' ) );
		$group       = (array) ( $availability['groups'][ (string) ( $option['group'] ?? '' ) ] ?? array() );
		$group_values = array_values(
			array_unique(
				array_filter(
					array_map(
						'strval',
						array_merge(
							(array) ( $group['parent_values'] ?? array() ),
							(array) ( $group['available_values'] ?? array() )
						)
					)
				)
			)
		);
		if ( '' === $field && ! empty( $group_values ) ) {
			$matching_fields = array();
			foreach ( $constraints as $constraint ) {
				if ( ! Niwa_AI_Frontend_Product_Query_Plan::constraint_matches_values( $group_values, $constraint ) ) {
					continue;
				}
				$constraint_field = sanitize_key( (string) ( $constraint['field'] ?? '' ) );
				if ( '' !== $constraint_field ) {
					$matching_fields[ $constraint_field ] = true;
				}
			}
			if ( 1 === count( $matching_fields ) ) {
				$field = (string) array_key_first( $matching_fields );
			}
		}
		$kept = array();
		foreach ( $constraints as $constraint ) {
			$constraint_field = sanitize_key( (string) ( $constraint['field'] ?? '' ) );
			if ( '' !== $field && $constraint_field === $field ) {
				continue;
			}
			if (
				! empty( $group_values )
				&& Niwa_AI_Frontend_Product_Query_Plan::constraint_matches_values( $group_values, $constraint )
			) {
				continue;
			}
			$kept[] = $constraint;
		}
		$kept[] = array(
			'field'    => '' !== $field ? $field : (string) ( $option['group'] ?? '' ),
			'label'    => (string) ( $option['label'] ?? '' ),
			'value'    => (string) ( $option['value'] ?? '' ),
			'required' => true,
			'polarity' => 'include',
		);

		return $this->normalize_product_eligibility_constraints( $kept );
	}

	/** Prove that current and retained variation constraints exist on one child. */
	private function selected_product_option_is_available( $availability, $option, $previous_constraints = array() ) {
		if ( empty( $availability['complete'] ) || empty( $option['group'] ) || '' === trim( (string) ( $option['value'] ?? '' ) ) ) {
			return false;
		}
		$groups = (array) ( $availability['groups'] ?? array() );
		$required = array(
			(string) $option['group'] => array( $this->normalize_for_match( $option['value'] ) ),
		);

		foreach ( (array) $previous_constraints as $constraint ) {
			if ( ! is_array( $constraint ) || empty( $constraint['required'] ) || 'exclude' === (string) ( $constraint['polarity'] ?? 'include' ) ) {
				continue;
			}
			$terms = array_merge(
				array( (string) ( $constraint['value'] ?? '' ) ),
				(array) ( $constraint['equivalents'] ?? array() ),
				(array) ( $constraint['descendants'] ?? array() )
			);
			$terms = array_values( array_unique( array_filter( array_map( array( $this, 'normalize_for_match' ), $terms ) ) ) );
			if ( empty( $terms ) ) {
				continue;
			}

			$matched_groups = array();
			foreach ( $groups as $group_key => $group ) {
				if ( (string) $group_key === (string) $option['group'] || ! is_array( $group ) ) {
					continue;
				}
				$declared = array_merge( (array) ( $group['parent_values'] ?? array() ), (array) ( $group['available_values'] ?? array() ) );
				$declared = array_values( array_unique( array_filter( array_map( array( $this, 'normalize_for_match' ), $declared ) ) ) );
				$overlap = array();
				foreach ( $declared as $declared_value ) {
					foreach ( $terms as $term ) {
						if ( $this->normalized_catalog_option_terms_match( $declared_value, $term ) ) {
							$overlap[] = $declared_value;
							break;
						}
					}
				}
				if ( ! empty( $overlap ) ) {
					$matched_groups[ (string) $group_key ] = $overlap;
				}
			}
			if ( 1 === count( $matched_groups ) ) {
				$group_key = (string) array_key_first( $matched_groups );
				$required[ $group_key ] = array_values( array_unique( array_merge( (array) ( $required[ $group_key ] ?? array() ), reset( $matched_groups ) ) ) );
			}
		}

		foreach ( (array) ( $availability['variations'] ?? array() ) as $variation ) {
			$matches_all = true;
			foreach ( $required as $group_key => $accepted_values ) {
				$variation_values = is_array( $variation ) ? (array) ( $variation[ $group_key ] ?? array() ) : array();
				$variation_values = array_values( array_unique( array_filter( array_map( array( $this, 'normalize_for_match' ), $variation_values ) ) ) );
				if ( empty( array_intersect( $accepted_values, $variation_values ) ) ) {
					$matches_all = false;
					break;
				}
			}
			if ( $matches_all ) {
				return true;
			}
		}

		return false;
	}

	/** Exact/simple-inflection comparison for already-normalized catalog values. */
	private function normalized_catalog_option_terms_match( $left, $right ) {
		$left  = $this->normalize_for_match( $left );
		$right = $this->normalize_for_match( $right );
		if ( '' === $left || '' === $right ) {
			return false;
		}
		if ( $left === $right ) {
			return true;
		}
		if ( preg_match( '/\p{N}/u', $left . $right ) ) {
			return false;
		}
		$left_tokens  = array_values( array_filter( (array) preg_split( '/\s+/u', $left ) ) );
		$right_tokens = array_values( array_filter( (array) preg_split( '/\s+/u', $right ) ) );
		if ( count( $left_tokens ) !== count( $right_tokens ) ) {
			return false;
		}
		foreach ( $left_tokens as $index => $left_token ) {
			$right_token = (string) $right_tokens[ $index ];
			if ( $left_token === $right_token ) {
				continue;
			}
			$left_length  = mb_strlen( $left_token, 'UTF-8' );
			$right_length = mb_strlen( $right_token, 'UTF-8' );
			if ( min( $left_length, $right_length ) < 4 ) {
				return false;
			}
			$left_root  = mb_substr( $left_token, 0, $left_length - 1, 'UTF-8' );
			$right_root = mb_substr( $right_token, 0, $right_length - 1, 'UTF-8' );
			if ( $left_root !== $right_root ) {
				return false;
			}
		}
		return true;
	}

	/** Resolve the selected option deterministically, then let the conversation model phrase it. */
	private function try_selected_product_option_follow_up( $query, $session_id, $conversation_context, $previous_product_plan, $context = array(), $current_product_plan = array() ) {
		if ( ! function_exists( 'wc_get_product' ) || ! class_exists( 'Niwa_AI_Product_Search_Evidence' ) ) {
			return array();
		}
		$stage_started = $this->trace_start();
		$selected = $this->resolve_selected_product_from_conversation_context( $conversation_context );
		if ( empty( $selected ) ) {
			$selected = $this->load_frontend_selected_product( $session_id, $conversation_context );
		}
		$product_id = intval( $selected['id'] ?? 0 );
		$product = $product_id > 0 ? wc_get_product( $product_id ) : null;
		if ( ! $product || ! method_exists( $product, 'is_type' ) || ! $product->is_type( 'variable' ) ) {
			return array();
		}

		$evidence = Niwa_AI_Product_Search_Evidence::build( $product );
		$availability = $this->build_product_variation_availability( $product, $evidence );
		$option = $this->match_selected_product_option_reply( $query, $availability );
		$requested_option_unavailable = false;
		if ( empty( $option ) && $this->selected_product_option_resolver ) {
			$resolved_option = $this->selected_product_option_resolver->resolve(
				$query,
				$conversation_context,
				(string) $selected['name'],
				$availability,
				$this->get_frontend_model(),
				$this->auth_manager->get_mode(),
				$this->auth_manager->get_credentials()
			);
			if ( ! empty( $resolved_option['success'] ) && 'selected' === (string) ( $resolved_option['status'] ?? '' ) ) {
				$option = (array) ( $resolved_option['option'] ?? array() );
				$option['constraint_field'] = $this->selected_product_option_constraint_field( $current_product_plan, $option );
			} elseif ( ! empty( $resolved_option['success'] ) && 'requested_option_unavailable' === (string) ( $resolved_option['status'] ?? '' ) ) {
				$requested_option_unavailable = true;
			}
		}
		if ( empty( $option ) && ! $requested_option_unavailable ) {
			return array();
		}
		$constraints = is_array( $previous_product_plan ) ? (array) ( $previous_product_plan['constraints'] ?? array() ) : array();
		$available = ! $requested_option_unavailable && $this->selected_product_option_is_available( $availability, $option, $constraints );
		$composed = $this->selected_product_response_composer->compose(
			$query,
			$conversation_context,
			array(
				'product_id'   => $product_id,
				'product_name' => (string) $selected['name'],
				'selection_status' => $requested_option_unavailable ? 'requested_option_not_declared' : 'selected_option',
				'option_label' => (string) ( $option['label'] ?? '' ),
				'option_value' => (string) ( $option['value'] ?? '' ),
				'available'    => (bool) $available,
			),
			$this->get_frontend_model(),
			$this->auth_manager->get_mode(),
			$this->auth_manager->get_credentials()
		);
		if ( empty( $composed['success'] ) ) {
			$this->trace_finish(
				'selected_product_option',
				$stage_started,
				array( 'product_id' => $product_id, 'available' => $available, 'searched' => false, 'composed' => false )
			);
			return $this->error_response(
				isset( $composed['error'] ) ? (string) $composed['error'] : 'Selected-product response composition failed.',
				$context,
				$query,
				'selected_product_response_failed'
			);
		}
		$raw_answer = $this->response_postprocessor->clean_answer( (string) $composed['answer'] );
		$answer     = $this->humanizer->apply( $raw_answer );
		if ( '' === trim( (string) $answer ) ) {
			return $this->error_response(
				'Selected-product response contained no usable text.',
				$context,
				$query,
				'selected_product_response_empty'
			);
		}

		$query_terms = array( (string) $query );
		foreach ( $constraints as $constraint ) {
			if ( is_array( $constraint ) && ! empty( $constraint['value'] ) ) {
				$query_terms[] = (string) $constraint['value'];
			}
		}
		$preview_constraints = $requested_option_unavailable
			? $constraints
			: $this->replace_selected_product_option_constraint( $constraints, $availability, $option );
		$preview = ! $requested_option_unavailable && class_exists( 'Niwa_AI_Product_Variation_Preview' )
			? Niwa_AI_Product_Variation_Preview::resolve( (array) ( $evidence['variations'] ?? array() ), $query_terms, $preview_constraints )
			: array();
		$previews = ! empty( $preview ) ? array( $product_id => $preview ) : array();
		$cards = $available && ! empty( $preview ) && method_exists( $this->product_card_selector, 'build_cards_for_product_ids' )
			? $this->product_card_selector->build_cards_for_product_ids( array( $product_id ), $previews )
			: array();
		$this->trace_finish(
			'selected_product_option',
			$stage_started,
			array( 'product_id' => $product_id, 'available' => $available, 'searched' => false, 'composed' => true )
		);
		if ( $available ) {
			$updated_plan                = is_array( $previous_product_plan ) ? $previous_product_plan : array();
			$updated_plan['query_mode']  = 'follow_up';
			$updated_plan['constraints'] = $preview_constraints;
			$this->store_frontend_product_plan( $session_id, $updated_plan );
		}

		return array(
			'success'    => true,
			'answer'     => $answer,
			'error'      => '',
			'confidence' => 1.0,
			'cards'      => $cards,
			'source'     => 'selected_product_option',
		);
	}

	private function load_frontend_product_plan( $session_id ) {
		$key = $this->frontend_product_plan_transient_key( $session_id );
		if ( '' === $key || ! function_exists( 'get_transient' ) ) {
			return array();
		}
		$plan = get_transient( $key );
		if ( ! is_array( $plan ) || empty( $plan['constraints'] ) || ! class_exists( 'Niwa_AI_Frontend_Product_Query_Plan' ) ) {
			return array();
		}
		$plan['constraints'] = $this->normalize_product_eligibility_constraints( $plan['constraints'] );
		return empty( $plan['constraints'] ) ? array() : $plan;
	}

	/**
	 * Recover the active product plan from visible conversation evidence when a
	 * transient disappeared between queued requests.
	 */
	private function recover_frontend_product_plan_from_conversation( $conversation_context, $source_hash ) {
		if (
			empty( $conversation_context['cards'] )
			|| empty( $conversation_context['messages'] )
			|| '' === trim( (string) $source_hash )
			|| ! class_exists( 'Niwa_AI_Frontend_Semantic_Lexicon' )
		) {
			return array();
		}

		$plan = array();
		foreach ( array_slice( (array) $conversation_context['messages'], -12 ) as $message ) {
			if ( ! is_array( $message ) || 'user' !== (string) ( $message['role'] ?? '' ) || '' === trim( (string) ( $message['content'] ?? '' ) ) ) {
				continue;
			}
			$resolved = Niwa_AI_Frontend_Semantic_Lexicon::resolve_query( (string) $message['content'], $plan, $source_hash );
			$current_constraints = $this->normalize_product_eligibility_constraints( $resolved['constraints'] ?? array() );
			if ( empty( $current_constraints ) ) {
				continue;
			}

			$query_mode = (string) ( $resolved['query_mode'] ?? '' );
			if ( ! in_array( $query_mode, array( 'fresh', 'follow_up' ), true ) ) {
				$has_identity = false;
				foreach ( $current_constraints as $constraint ) {
					$field = sanitize_key( (string) ( $constraint['field'] ?? '' ) );
					if ( in_array( $field, array( 'department', 'product_type', 'product_family', 'category', 'model', 'sku' ), true ) ) {
						$has_identity = true;
						break;
					}
				}
				$query_mode = empty( $plan['constraints'] ) || $has_identity ? 'fresh' : 'follow_up';
			}

			$constraints = 'follow_up' === $query_mode && ! empty( $plan['constraints'] )
				? $this->merge_frontend_product_follow_up_constraints(
					$current_constraints,
					(array) $plan['constraints'],
					(array) ( $resolved['replaced_fields'] ?? array() )
				)
				: $current_constraints;
			$plan = array(
				'query_mode'  => $query_mode,
				'constraints' => $constraints,
			);
		}

		return empty( $plan['constraints'] ) ? array() : $plan;
	}

	/** Remove request-state slots that are not stable product properties. */
	private function normalize_product_eligibility_constraints( $constraints ) {
		if ( ! class_exists( 'Niwa_AI_Frontend_Product_Query_Plan' ) ) {
			return array();
		}
		$constraints = Niwa_AI_Frontend_Product_Query_Plan::normalize_constraints( $constraints );
		$intent_fields = array_fill_keys(
			array( 'scope', 'availability', 'stock', 'stock_status', 'in_stock', 'catalog', 'offer', 'shopping_intent', 'purchase_intent', 'result_count', 'recommendation_count', 'option_count', 'number_of_results', 'result_diversity', 'variety', 'diversity', 'selection_size' ),
			true
		);
		return array_values(
			array_filter(
				$constraints,
				static function ( $constraint ) use ( $intent_fields ) {
					$field = is_array( $constraint ) && isset( $constraint['field'] ) ? sanitize_key( (string) $constraint['field'] ) : '';
					return '' !== $field && ! isset( $intent_fields[ $field ] );
				}
			)
		);
	}

	private function store_frontend_product_plan( $session_id, $plan ) {
		$key = $this->frontend_product_plan_transient_key( $session_id );
		if ( '' === $key || ! function_exists( 'set_transient' ) || ! is_array( $plan ) || empty( $plan['constraints'] ) ) {
			return false;
		}
		$ttl = defined( 'HOUR_IN_SECONDS' ) ? HOUR_IN_SECONDS : 3600;
		return set_transient( $key, $plan, $ttl );
	}

	private function resolve_product_response_language( $payloads, $previous_plan = array() ) {
		$has_fresh     = false;
		$has_follow_up = false;
		foreach ( array_reverse( (array) $payloads ) as $payload ) {
			$language = is_array( $payload ) ? $this->normalize_response_language_code( $payload['_response_language'] ?? '' ) : '';
			if ( '' !== $language ) {
				return $language;
			}
			$query_mode    = is_array( $payload ) ? (string) ( $payload['_product_query_mode'] ?? '' ) : '';
			$has_fresh     = $has_fresh || 'fresh' === $query_mode;
			$has_follow_up = $has_follow_up || 'follow_up' === $query_mode;
		}
		if ( $has_fresh || ! $has_follow_up ) {
			return '';
		}
		return $this->normalize_response_language_code(
			is_array( $previous_plan ) ? ( $previous_plan['response_language'] ?? '' ) : ''
		);
	}

	/** Retain prior fields unless the follow-up explicitly replaces them. */
	private function merge_frontend_product_follow_up_constraints( $current, $previous, $replaced_fields = array() ) {
		if ( ! class_exists( 'Niwa_AI_Frontend_Product_Query_Plan' ) ) {
			return (array) $current;
		}
		$current  = $this->normalize_product_eligibility_constraints( $current );
		$previous = $this->normalize_product_eligibility_constraints( $previous );
		$blocked  = array_fill_keys( array_values( array_filter( array_map( 'sanitize_key', (array) $replaced_fields ) ) ), true );
		foreach ( $current as $constraint ) {
			$field = isset( $constraint['field'] ) ? sanitize_key( (string) $constraint['field'] ) : '';
			if ( '' !== $field ) {
				$blocked[ $field ] = true;
			}
		}

		$merged = $current;
		foreach ( $previous as $constraint ) {
			$field = isset( $constraint['field'] ) ? sanitize_key( (string) $constraint['field'] ) : '';
			if ( '' === $field || isset( $blocked[ $field ] ) ) {
				continue;
			}
			$merged[]          = $constraint;
			$blocked[ $field ] = true;
		}
		return $this->normalize_product_eligibility_constraints( $merged );
	}

    private function build_tool_instructions( $store_overview, $history_text, $query = '', $context = array() ) {
        $agent_mode_block = $this->build_agent_mode_instructions_block( 2600 );

        $instructions = implode(
            "\n",
            array(
                'You are Niwa, a helpful female sales assistant for this online store.',
                '',
                'IDENTITY:',
                '- You are female. In Serbian, Croatian, or Bosnian, always use feminine verb forms.',
                '- Be a practical customer-facing sales assistant, not a system narrator.',
                '',
                'SEARCH AND CONTEXT:',
				'- Use memory_search before answering questions that need current product, store, page, policy, price, stock, shipping, contact, cart, or checkout facts, unless a cart or option follow-up clearly refers to one currently shown exact product card; use that card product_id and the live cart or variation tool directly.',
				'- memory_search searches exact titles, categories, tags, attributes, available variations, short descriptions, and full descriptions. Each returned candidate is compact: use its bounded best evidence excerpt together with retrieval_basis and claim_scope instead of expecting every full source field or variation array in the tool payload. Use memory_get for details of one selected post_id when the compact evidence is insufficient.',
				'- Product price, sale price, and stock may appear under mutable_snapshot with freshness=indexed_not_live. Never present those snapshot values as guaranteed current. The finalist product card and checkout are authoritative for current price and availability.',
				'- For requests about best-selling or most popular products by sales, call product_rankings. Memory search ranks relevance, not sales. Use the returned order, name the selected products exactly, and never disclose raw sales counts.',
                '- Distinguish discovery from verification: a semantically close description may identify the intended product, but never present a nearby material, color, size, or other property as an exact match.',
				'- Product discovery and product eligibility are separate. Before claiming that a product matches a request, call memory_search with a structured constraints array unless the visitor message is exactly one complete product title with no additional requirement. ID, SKU, and model hits can still carry other constraints and do not bypass planning.',
				'- For a structured product search, set constraints_complete=true only after every explicit and retained requirement is mapped, and return every unmapped product phrase in unresolved_terms. An incomplete plan is not eligible for a product recommendation.',
				'- Set query_mode=fresh for a new product request. Set query_mode=follow_up for an elliptical continuation and put only explicitly changed field keys in replaced_fields; the backend will retain all other constraints from the previous verified product plan.',
				'- Required constraints stay hard. Set allow_alternatives=true only after the visitor explicitly permits relaxing at least one required criterion; otherwise return no match rather than recommending a product that misses it.',
				'- When the visitor rejects, dismisses, or asks for a different option after one or more product cards were shown, put the rejected visible product IDs in excluded_product_ids. Keep all other product constraints unchanged; exclusion means find another eligible item, not relax the request.',
				'- Set response_language to the language for the customer-facing reply. Resolve it from the current message and recent turns; a short follow-up inherits the active conversation language unless the visitor clearly switches.',
                '- In each constraint keep the literal requested value separate from equivalents, descendants that truly satisfy a broader concept, related alternatives, and parent concepts. Never put a sibling or merely similar product type into equivalents/descendants.',
				'- The backend classifies results as exact_verified, exact_inferred, close_alternative, broad_alternative, or rejected. Respect that class exactly. Never describe close/broad as exact.',
                '- If the first search misses an important constraint, call memory_search once more with a complete structured constraints array plus language-appropriate grammatical forms and semantic paraphrases in query_variants before concluding that the product is unavailable.',
                '- For questions about what Niwa can do, backend/admin/founder capabilities, content operations, reports, analytics, campaign/source attribution, products, stock, prices, orders, MCP, or owner workflows, use memory_search with scope=\'site\' when the [AGENT_MODE] summary is not enough. Explain public product capabilities only; do not expose private admin data or internal tool names.',
                '- If the visitor is continuing the conversation, answering your offer, asking for use advice, recipe ideas, or casual small-talk, answer directly when the existing context is enough.',
                '- Do not require or emit hidden control tags. Never show analysis notes, prompt labels, internal tool reasoning, or implementation details.',
                '- Whenever you name or recommend a product from memory_search, reproduce its complete product title exactly as returned. Never shorten, translate, or paraphrase a product title. Product cards are created only for products you explicitly name, so select only products that genuinely match the visitor request and never pad the answer with irrelevant items. If a needed fact is missing, say you do not want to guess and continue from known data.',
                '',
                'RESPONSE STYLE:',
                '- Respond in the visitor language for this turn.',
                '- Keep replies short, concrete, and natural: usually 2-4 sentences, maximum one useful question.',
                '- Do not start with filler phrases such as Naravno, Svakako, Da, Sure, Of course, or Great question.',
                '- Do not force product suggestions into greetings or small-talk.',
				'- Inspect every returned product candidate. Recommend up to the top four complete titles from the best available match class. If you name three eligible products, all three remain visible as product cards; never omit or pad products to force a particular grid size.',
				'- Product search returns at most six candidates. Do not state that only a smaller number of variants, packs, or products exists when additional matching candidates are present in the returned results.',
                '- Connect recommendations to the visitor need and guide toward the next useful buying step when the fit is clear.',
				'- Treat ordinary translations and market synonyms returned as exact_inferred as the requested attribute, without explaining the vocabulary difference.',
				'- When the visitor explicitly allowed alternatives and only close/broad alternatives exist, say naturally that they are the closest available options and mention the material difference. Otherwise keep every required criterion strict.',
                '',
                'PAGE LINKS:',
                '- Do not paste raw URLs inline. Put page links at the end as [Button Label](url), one per line, with descriptive labels.',
            )
        );
        if ( '' !== trim( (string) $store_overview ) ) {
            $instructions .= "\n\n" . $store_overview;
        }
        if ( '' !== trim( (string) $history_text ) ) {
            $instructions .= "\n\n" . $history_text;
        }

        $instructions .= $this->build_frontend_humanizer_instructions();
        $instructions .= $this->build_ecommerce_sales_agent_instructions();
        $instructions = str_replace( "Respond EXCLUSIVELY in the user's language.", 'Respond EXCLUSIVELY in the correct language for this turn.', $instructions );
        $instructions .= $this->build_turn_language_instructions( $query, $context );
        $instructions .= "\n\nFOLLOW-UP CONTEXT RULES:\n"
            . "- In every language, resolve acknowledgements, pronouns, demonstratives, ordinal references, and elliptical attribute-only turns against the immediately previous exchange before treating them as a new topic.\n"
            . "- If the previous assistant offered an explanation and the visitor asks for that explanation without restating its subject, continue the offered subject directly.\n"
            . "- If the previous assistant asked for a missing product option and the user replies with a value, treat that value as the missing option.\n"
			. "- Once the visitor selects a shown product by position or reference, keep that exact product identity for subsequent size/color/option replies and cart confirmation. Never replace it with newly retrieved alternatives unless the visitor asks for another product.\n"
            . "- For an elliptical product follow-up such as a new color, size, material, voltage, or dimension, retain every unsuperseded constraint from the immediately previous product request and replace only the field changed by the visitor. A new product type resets incompatible older constraints.\n";
        if ( ! empty( $context['pending_cart'] ) && is_array( $context['pending_cart'] ) ) {
            $pending_cart_json = wp_json_encode( $context['pending_cart'] );
            if ( is_string( $pending_cart_json ) && '' !== $pending_cart_json ) {
                $instructions .= "\n\n[PENDING_CART_SELECTION]\n" . $pending_cart_json . "\n[/PENDING_CART_SELECTION]\n"
                    . "- If the current user message answers this pending cart selection, call cart_add_product with the pending product_id or product_query and merge the user's answer into variation_attributes.\n"
                    . "- Preserve already selected pending attributes. Only ask again if the current answer still does not satisfy required_options.\n";
            }
        }
        $instructions .= "\n\nPRODUCT RESOLUTION RULES FOR CART REQUESTS:\n"
            . "- For add-to-cart requests that do not clearly refer to a currently shown card, call memory_search first and inspect the returned product results before using cart_add_product.\n"
            . "- Treat every explicit word in the current user message as a constraint: product type, color, material, size, pack weight, capacity, model number, SKU/code, gender, flavor, scent, and any other descriptor.\n"
            . "- If exactly one search result satisfies all explicit constraints, call cart_add_product with that result's product_id. Do not ask the customer to choose just because search returned other products that miss one of the constraints.\n"
            . "- Examples: if results include blue/red/white leather shirts and the user asked for the red leather shirt, choose the red leather shirt. If results include 30g/60g/90g truffles and the user asked for 60g, choose the 60g truffle.\n"
            . "- Ask a clarification question only when two or more search results still satisfy all explicit constraints, or when a required variation option is missing.\n"
            . "- Do not use an older product from the conversation context when the current message starts a fresh product request and gives its own descriptors.\n";
        $instructions .= "\n\nCART TOOL RULES:\n"
            . "- When the user asks to add, put, buy, order, or place a product in the cart, call cart_add_product.\n"
            . "- If the target product is one of the previously shown product cards and the current user message refers to it contextually, use its product_id. Interpret phrases like \"the second one\" by card position.\n"
            . "- Explicit details in the current user message override card position and previous product IDs. If the user says 150g, size 42, red, blue, another model, or a similar qualifier, do not add a product that does not match that qualifier.\n"
            . "- Prefer product_id from memory_search or product cards over broad product_query. Use product_query only when no product_id is available.\n"
            . "- When the user specifies a pack size, weight, color, size, or other variant in natural language, include the exact user phrase in product_query only as supporting context if you also pass product_id.\n"
            . "- For variable products, pass known choices in variation_attributes, for example {\"size\":\"42\",\"color\":\"red\"}. If choices are missing, still call cart_add_product with the product_id so the tool can return required_options.\n"
            . "- If cart_add_product returns status=needs_selection, ask the customer one concise question covering only the missing options and list the available choices from required_options.\n"
            . "- If cart_add_product returns code=product_pack_mismatch, do not claim the selected product was added. Search for the requested pack or ask the customer to confirm the exact pack.\n"
            . "- If cart_add_product returns status=added, tell the customer that the exact product or variation was added to the cart. Do not ask them to add it manually.\n"
            . "- Do not invent variation IDs or option values. Use product IDs from history/search and options returned by the tool.";
        $instructions .= "\n\nNAVIGATION TOOL RULES:\n"
            . "- When the customer asks to go to checkout, pay, finish or complete the order, open the cart page, or be taken to any page of this site, call navigate_to_page.\n"
            . "- Use target='checkout' for checkout/payment/complete-order requests, target='cart' for the cart page, and target='shop'/'account'/'home'/'blog' for those destinations.\n"
            . "- For any other page, blog post, or product page, pass post_id when you have it from memory_search results, otherwise pass page_query with the page name. Call memory_search with scope='site' first if you have not located the page yet.\n"
            . "- After cart_add_product succeeds and the customer wants to checkout or finish the order, call navigate_to_page with target='checkout' in the same turn.\n"
            . "- After the tool returns status=navigate, reply with ONE short sentence telling the customer you are taking them there. Do NOT also add a PAGE LINK FORMAT button for the same destination.\n"
            . "- If the tool returns status=needs_selection, ask the customer which of the listed pages they meant, then call the tool again with the chosen post_id.\n"
            . "- If the customer only asks where something is or asks for a link, WITHOUT asking to be taken there, use the PAGE LINK FORMAT instead of navigate_to_page.\n"
            . "- Only pages of this store website can be opened. Never pass external URLs.";

        if ( '' !== $agent_mode_block ) {
            $instructions .= "\n\n" . $agent_mode_block;
        }

        // Inject frontend skills context
        $frontend_skills_block = $this->build_frontend_skills_block( $query );
        if ( '' !== $frontend_skills_block ) {
            $instructions .= "\n\n[SKILLS]\n" . $frontend_skills_block . "\n[/SKILLS]";
            $instructions .= "\n- If a [SKILLS] section is present, use the matched skill instructions as high-priority guidance for answering the user's question.";
            $instructions .= "\n- Use skill_get to load full content of a matched skill when the catalog shows a relevant one.";
        }

        // Lead form completion signal — when the visitor's lead form session
        // is already completed (or partial-submitted), tell the agent to stop
        // calling activate_lead_form. Without this, the agent re-launches the
        // form on every turn that mentions the trigger topic and the visitor
        // sees the completion screen pop up repeatedly.
        $lf_session_id = isset( $context['session'] ) ? (string) $context['session'] : '';
        $lf_status     = $this->get_lead_form_session_status( $lf_session_id );
        if ( in_array(
            $lf_status,
            array(
                Niwa_AI_Lead_Form_Session_Store::STATUS_COMPLETED,
                Niwa_AI_Lead_Form_Session_Store::STATUS_PARTIAL,
            ),
            true
        ) ) {
            $instructions .= "\n\n[LEAD_FORM_STATUS] " . $lf_status . " [/LEAD_FORM_STATUS]";
            $instructions .= "\n- The visitor already completed (or partial-submitted) the lead form earlier in this session. Do NOT call activate_lead_form again. Continue the conversation normally without launching the form.";
        }

        // Visitor email already captured earlier in the session (email
        // capture, CBEH, lead form, or a prior tool call). Surface it so the
        // agent can pass it straight into tools like
        // submit_missing_product_email without asking the visitor again.
        if ( ! empty( $context['visitor_email'] ) && is_string( $context['visitor_email'] ) ) {
            $visitor_email = trim( (string) $context['visitor_email'] );
            if ( '' !== $visitor_email ) {
                $instructions .= "\n\n[VISITOR_EMAIL] " . $visitor_email . " [/VISITOR_EMAIL]";
                $instructions .= "\n- The visitor's email is already on file (shown above). Do NOT ask them for it again. When a tool asks for an email argument, pass this value verbatim.";
            }
        }

		$scheduler_context = $this->build_scheduler_services_context();
		if ( '' !== $scheduler_context ) {
			$instructions .= "\n\n[SCHEDULER_SERVICES]\n" . $scheduler_context . "\n[/SCHEDULER_SERVICES]";
			$instructions .= "\n- These are the services that can actually be booked. Use their descriptions and durations to answer questions and recommend the best fitting service without inventing services or availability.";
			$instructions .= "\n- When the visitor wants to book, schedule, choose a date/time, or accepts your offer to arrange an appointment, call activate_scheduler. Pass the exact service_id when their choice is known; otherwise let the scheduler ask them to choose.";
			$instructions .= "\n- Never say or imply that the scheduler, calendar, dates, or times were opened unless you call activate_scheduler in the same turn.";
		}

        return $instructions;
    }

	/**
	 * Load the sanitized public scheduler configuration without making the
	 * frontend agent depend on admin-only objects.
	 *
	 * @return array
	 */
	private function get_scheduler_frontend_config() {
		if ( ! class_exists( 'Niwa_AI_Appointment_Service' ) ) {
			return array();
		}
		try {
			$service = new Niwa_AI_Appointment_Service();
			$config  = method_exists( $service, 'get_frontend_config' ) ? $service->get_frontend_config() : array();
			return is_array( $config ) ? $config : array();
		} catch ( \Throwable $e ) {
			return array();
		}
	}

	/**
	 * Compact service catalog injected into the customer-facing prompt.
	 *
	 * @return string
	 */
	private function build_scheduler_services_context() {
		$config   = $this->get_scheduler_frontend_config();
		if ( empty( $config['enabled'] ) ) {
			return '';
		}
		$services = isset( $config['services'] ) && is_array( $config['services'] ) ? $config['services'] : array();
		$lines    = array();
		foreach ( array_slice( $services, 0, 50 ) as $service ) {
			if ( ! is_array( $service ) ) {
				continue;
			}
			$id   = isset( $service['id'] ) ? sanitize_key( (string) $service['id'] ) : '';
			$name = isset( $service['name'] ) ? trim( (string) $service['name'] ) : '';
			if ( '' === $id || '' === $name ) {
				continue;
			}
			$duration    = isset( $service['duration_minutes'] )
				? max( 1, intval( $service['duration_minutes'] ) )
				: ( isset( $service['durationMinutes'] ) ? max( 1, intval( $service['durationMinutes'] ) ) : 0 );
			$description = isset( $service['description'] ) ? trim( (string) $service['description'] ) : '';
			$line        = '- service_id=' . $id . '; name=' . $name;
			if ( $duration > 0 ) {
				$line .= '; duration=' . $duration . ' min';
			}
			if ( '' !== $description ) {
				$line .= '; description=' . mb_substr( wp_strip_all_tags( $description ), 0, 500 );
			}
			$lines[] = $line;
		}
		return implode( "\n", $lines );
	}

    private function build_frontend_humanizer_instructions() {
        return "\n[AI-HUMANIZER v2.1.0 ALWAYS-ON]\n"
            . "- Follow the installed ai-humanizer skill for every customer-facing sentence.\n"
            . "- No em dashes or en dashes in visible replies. Use commas, periods, parentheses, or shorter sentences.\n"
            . "- Remove chatbot filler, praise-openers, generic conclusions, vague hype, and mechanical bolding.\n"
            . "- Vary sentence length. Keep answers concrete, specific, and natural for the visitor's language.\n"
            . "- These are output-quality rules only. Do not use the listed words as intent triggers or language-specific behavior gates.\n"
            . "[/AI-HUMANIZER v2.1.0 ALWAYS-ON]\n";
    }

    private function build_ecommerce_sales_agent_instructions() {
        return Niwa_AI_Customer_Sales_Assistant_Profile::ecommerce_sales_agent_block();
    }

    private function build_frontend_skills_block( $query ) {
        if ( ! $this->skills_manager || ! method_exists( $this->skills_manager, 'build_skills_context' ) ) {
            return '';
        }
        return trim( (string) $this->skills_manager->build_skills_context( $query, 'frontend' ) );
    }

    private function get_memory_tools_schema() {
        $tools = array(
            array(
                'type'     => 'function',
                'function' => array(
                    'name'        => 'memory_search',
                    'description' => 'Search store memory across product titles, SKUs, categories, tags, attributes, available variations, short descriptions, full descriptions, pages, and site knowledge. For product availability or recommendations with type/attribute requirements, include structured constraints. The backend then guarantees exact-before-alternative ordering and marks mismatches. query_variants are discovery only and never prove a fact.',
                    'parameters'  => array(
                        'type'       => 'object',
                        'properties' => array(
                            'query' => array(
                                'type'        => 'string',
                                'description' => 'Search query in the user\'s language.',
                            ),
                            'query_variants' => array(
                                'type'        => 'array',
                                'items'       => array( 'type' => 'string' ),
                                'description' => 'Up to four grammatical forms or semantic paraphrases in the visitor language. Do not translate product titles.',
                            ),
							'query_mode' => array(
								'type'        => 'string',
								'enum'        => array( 'fresh', 'follow_up' ),
								'description' => 'fresh for a new independent product request; follow_up only when the current message modifies or continues the immediately previous product request.',
							),
							'response_language' => array(
								'type'        => 'string',
								'description' => 'ISO 639 language code for the customer-facing reply, resolved from the current message and recent conversation. A short follow-up inherits the active conversation language unless the visitor clearly switches languages.',
							),
							'replaced_fields' => array(
								'type'        => 'array',
								'items'       => array( 'type' => 'string' ),
								'description' => 'For query_mode=follow_up, language-neutral field keys explicitly replaced by the current message, such as color or size. Use an empty array for fresh requests.',
							),
							'constraints_complete' => array(
								'type'        => 'boolean',
								'description' => 'True only after every explicit and context-retained product requirement in the current request is represented in constraints. Use false if any product term or requirement is still unresolved.',
							),
							'allow_alternatives' => array(
								'type'        => 'boolean',
								'description' => 'True only when the visitor explicitly allowed relaxing a required product criterion. False for ordinary availability, recommendation, and contextual what-else requests.',
							),
							'excluded_product_ids' => array(
								'type'        => 'array',
								'items'       => array( 'type' => 'integer' ),
								'description' => 'Visible product IDs the visitor just rejected or asked to replace. Exclude them from this search while preserving every product requirement.',
							),
							'unresolved_terms' => array(
								'type'        => 'array',
								'items'       => array( 'type' => 'string' ),
								'description' => 'Product-related words or phrases from the current request that were not mapped to a constraint. Must be an empty array before a product can be presented as eligible.',
							),
                            'limit' => array(
                                'type'        => 'integer',
								'minimum'     => 1,
								'maximum'     => self::MAX_FRONTEND_CONTEXT_RESULTS,
								'description' => 'Maximum candidates returned to the model. The backend always caps this at six and may expand a structured recommendation request to preserve four-result selection.',
                            ),
                            'scope' => array(
                                'type'        => 'string',
                                'enum'        => array( 'product', 'site', 'all' ),
                                'description' => 'Search scope: product for products, site for pages/policies/store info, all for both.',
                            ),
							'constraints' => array(
								'type'        => 'array',
								'description' => 'Complete current product requirements, including constraints retained from the immediately previous product request. Literal value is authoritative. equivalents are true synonyms only; descendants are narrower concepts that satisfy a broad request; related and parents are alternatives and never exact. Use forbidden_terms for explicit conflicts.',
								'items'       => array(
									'type'       => 'object',
									'properties' => array(
										'field' => array(
											'type'        => 'string',
											'description' => 'Language-neutral slot key such as product_type, product_family, color, material, dimensions, voltage, compatibility, ingredient, form, use, or another catalog-relevant field.',
										),
										'label' => array( 'type' => 'string' ),
										'value' => array( 'type' => 'string' ),
										'required' => array( 'type' => 'boolean' ),
										'polarity' => array( 'type' => 'string', 'enum' => array( 'include', 'exclude' ) ),
										'equivalents' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
										'descendants' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
										'related' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
										'parents' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
										'forbidden_terms' => array( 'type' => 'array', 'items' => array( 'type' => 'string' ) ),
										'min' => array( 'type' => 'number' ),
										'max' => array( 'type' => 'number' ),
										'target' => array( 'type' => 'number' ),
										'tolerance' => array( 'type' => 'number' ),
										'unit' => array( 'type' => 'string' ),
									),
									'required' => array( 'field' ),
								),
							),
                        ),
                        'required' => array( 'query' ),
                    ),
                ),
            ),
            array(
                'type'     => 'function',
                'function' => array(
                    'name'        => 'memory_get',
                    'description' => 'Fetch full details for a specific item by post_id. Use after memory_search to get more information about a specific product or page.',
                    'parameters'  => array(
                        'type'       => 'object',
                        'properties' => array(
                            'post_id' => array(
                                'type'        => 'integer',
                                'description' => 'The post_id from memory_search results.',
                            ),
                        ),
                        'required' => array( 'post_id' ),
                    ),
                ),
            ),
			array(
				'type'     => 'function',
				'function' => array(
					'name'        => 'product_rankings',
					'description' => 'Read the live WooCommerce sales ranking for customer-facing bestseller or popularity requests. This is the authoritative sales-order tool; memory_search does not contain sales rank.',
					'parameters'  => array(
						'type'       => 'object',
						'properties' => array(
							'ranking' => array(
								'type' => 'string',
								'enum' => array( 'best_selling' ),
							),
							'limit' => array(
								'type'    => 'integer',
								'minimum' => 1,
								'maximum' => 6,
							),
						),
						'required'   => array( 'ranking' ),
					),
				),
			),
            array(
                'type'     => 'function',
                'function' => array(
                    'name'        => 'cart_add_product',
                    'description' => 'Add a product to the customer cart. Use when the customer asks to add, put, buy, order, or place a recommended product in the cart. Supports variable products: pass product_id or product_query plus variation_attributes such as size/color when known. If required options are missing, the tool returns required_options so you can ask the customer for only the missing choices.',
                    'parameters'  => array(
                        'type'       => 'object',
                        'properties' => array(
                            'product_id' => array(
                                'type'        => 'integer',
                                'description' => 'WooCommerce product ID. Prefer the exact product_id resolved from memory_search results or previously shown product cards.',
                            ),
                            'product_query' => array(
                                'type'        => 'string',
                                'description' => 'Exact product name or natural product reference only when product_id is not known, or supporting context when product_id is known.',
                            ),
                            'variation_id' => array(
                                'type'        => 'integer',
                                'description' => 'WooCommerce variation ID if already known.',
                            ),
                            'quantity' => array(
                                'type'        => 'integer',
                                'description' => 'Quantity to add. Defaults to 1.',
                            ),
                            'variation_attributes' => array(
                                'type'                 => 'object',
                                'description'          => 'Selected variation attributes keyed by label/name, for example {"size":"42","color":"red"}.',
                                'additionalProperties' => array( 'type' => 'string' ),
                            ),
                        ),
                    ),
                ),
            ),
            array(
                'type'     => 'function',
                'function' => array(
                    'name'        => 'navigate_to_page',
                    'description' => 'Redirect the customer\'s browser to a page on THIS store website. Use when the customer asks to go to checkout, pay, finish/complete the order, open the cart page, or be taken to any other page of this site (shop, account, home, blog, a specific page or product page). Pass target for well-known destinations, post_id when you have it from memory_search results, page_query to find a page by name, or url only with an internal URL from search results. External URLs are rejected. After the tool returns status=navigate, reply with one short sentence telling the customer you are taking them there.',
                    'parameters'  => array(
                        'type'       => 'object',
                        'properties' => array(
                            'target' => array(
                                'type'        => 'string',
                                'enum'        => array( 'checkout', 'cart', 'shop', 'account', 'home', 'blog' ),
                                'description' => 'Well-known destination. Use checkout for checkout/payment/complete-order requests, cart for the cart page.',
                            ),
                            'post_id' => array(
                                'type'        => 'integer',
                                'description' => 'post_id of a page, post, or product from memory_search results. Preferred over page_query when known.',
                            ),
                            'page_query' => array(
                                'type'        => 'string',
                                'description' => 'Page, post, or product name to find on this site when no target or post_id applies.',
                            ),
                            'url' => array(
                                'type'        => 'string',
                                'description' => 'Internal URL of this site, only when already known from memory_search results. Must be on this store\'s domain.',
                            ),
                        ),
                    ),
                ),
            ),
        );

        // Add activate_lead_form tool when the Lead Form Generator is
        // enabled and the admin provided a `trigger_description` — the
        // description IS the tool description, so the model knows when to
        // call it. Without admin guidance we do not expose the tool.
        if ( class_exists( 'Niwa_AI_Lead_Form_Schema' ) ) {
            $lf_opts     = Niwa_AI_Lead_Form_Schema::get();
            $lf_enabled  = ! empty( $lf_opts['enabled'] );
            $lf_trigger  = isset( $lf_opts['trigger_description'] ) ? trim( (string) $lf_opts['trigger_description'] ) : '';
            $lf_has_qs   = Niwa_AI_Lead_Form_Schema::total_questions( $lf_opts ) > 0;
            if ( $lf_enabled && $lf_has_qs && '' !== $lf_trigger ) {
                $tools[] = array(
                    'type'     => 'function',
                    'function' => array(
                        'name'        => 'activate_lead_form',
                        'description' => 'Launch the visitor-facing lead form on the current page. Call this tool ONLY when the visitor\'s intent matches the following admin-defined criteria: ' . $lf_trigger . ' Do not call it preemptively, speculatively, or when the visitor is just browsing. Call it at most once per conversation. After calling, still produce a short natural-language reply (1 sentence) acknowledging the next step — the form renders itself.',
                        'parameters'  => array(
                            'type'       => 'object',
                            'properties' => array(
                                'reason' => array(
                                    'type'        => 'string',
                                    'description' => 'Short internal justification (one phrase) describing which admin-defined criterion matched. Not shown to the user.',
                                ),
                            ),
                            'required' => array(),
                        ),
                    ),
                );
            }
        }

		$scheduler_config   = $this->get_scheduler_frontend_config();
		$scheduler_services = ! empty( $scheduler_config['enabled'] ) && isset( $scheduler_config['services'] ) && is_array( $scheduler_config['services'] )
			? $scheduler_config['services']
			: array();
		if ( ! empty( $scheduler_services ) ) {
			$service_ids = array();
			foreach ( $scheduler_services as $scheduler_service ) {
				$id = is_array( $scheduler_service ) && isset( $scheduler_service['id'] ) ? sanitize_key( (string) $scheduler_service['id'] ) : '';
				if ( '' !== $id ) {
					$service_ids[] = $id;
				}
			}
			$service_property = array(
				'type'        => 'string',
				'description' => 'Exact service_id from SCHEDULER_SERVICES when the visitor already selected a service. Omit when they should choose in the scheduler.',
			);
			if ( ! empty( $service_ids ) ) {
				$service_property['enum'] = array_values( array_unique( $service_ids ) );
			}
			$tools[] = array(
				'type'     => 'function',
				'function' => array(
					'name'        => 'activate_scheduler',
					'description' => 'Open the visitor-facing appointment scheduler. Use when the visitor asks to book, schedule, see available dates/times, or accepts an appointment recommendation. Do not claim a time is reserved until the scheduler confirms the booking.',
					'parameters'  => array(
						'type'       => 'object',
						'properties' => array(
							'service_id' => $service_property,
							'team_member_id' => array(
								'type'        => 'string',
								'description' => 'Optional exact team member id only when the visitor selected a person.',
							),
							'reason' => array(
								'type'        => 'string',
								'description' => 'Short internal reason for opening the scheduler. Not shown to the visitor.',
							),
						),
					),
				),
			);
		}

        // Add submit_missing_product_email tool when the Missing Product
        // Waitlist is enabled. The tool description is fully self-contained:
        // the model gets WHEN/WHEN-NOT/HOW guidance inline, so no admin-side
        // trigger description is required. If the email was already captured
        // elsewhere in the session (email capture / CBEH / lead form), the
        // agent will see it in the [VISITOR_EMAIL] context line and can pass
        // it straight through without asking the visitor again.
        if ( class_exists( 'Niwa_AI_Lead_Capture_Settings' ) ) {
            $mp_opts    = Niwa_AI_Lead_Capture_Settings::get_settings();
            $mp_enabled = ! empty( $mp_opts['missing_product_enabled'] );
            if ( $mp_enabled ) {
                $tools[] = array(
                    'type'     => 'function',
                    'function' => array(
                        'name'        => 'submit_missing_product_email',
                        'description' => 'Register the visitor\'s interest in a specific product that is NOT in the catalog, so the store owner can notify them by email once it becomes available. '
                            . 'WHEN TO CALL (any of these triggers is enough): '
                            . '(a) The visitor asks for a specific named product and your memory_search did NOT return that exact product — only unrelated or same-category items. '
                            . '(b) The visitor explicitly refuses the alternative you proposed for a product they asked for. Act on the FIRST refusal — do not keep re-offering the same or new alternatives and do not wait for a second or third "no". A single "ne", "no", "nije to", "ne želim to", "hoću baš X", or any equivalent in the visitor\'s language is enough to treat the alternative as rejected. '
                            . '(c) The visitor explicitly asks whether you can notify them / let them know / contact them when the product arrives ("javite mi kad bude", "obavestite me", "let me know when", etc.). '
                            . 'STRICT INTERPRETATION OF "RELEVANT MATCH": A product is a relevant match ONLY if it is essentially the same product the visitor named. Items from the same category that differ in the core product type are NOT a relevant match and must not block this tool. For example: "so sa tartufima" (truffle salt) is NOT a relevant match for "sir sa tartufima" (truffle cheese); "crna majica" is NOT a relevant match for "crna košulja"; "60g pakovanje" is NOT a relevant match when the visitor asked for "150g pakovanje". When in doubt, treat it as NOT a match and offer the waitlist. '
                            . 'WHEN NOT TO CALL: (i) memory_search returned the exact named product the visitor asked for. (ii) The visitor is casually browsing, comparing, or small-talking with no specific product in mind. (iii) You have already successfully called this tool once in the current conversation. '
                            . 'HOW TO CALL: '
                            . '(1) If the visitor\'s email is already known — either from the [VISITOR_EMAIL] context line or because they provided it earlier in this conversation — call the tool IMMEDIATELY with both `product_query` and `email`. In the same turn, add ONE short natural-language sentence in the visitor\'s language confirming you will notify them (e.g. "Javiću vam čim stigne."). Do not ask for the email again. '
                            . '(2) If the email is NOT yet known, DO NOT call the tool yet. Instead, in the same turn where the trigger fired, smoothly offer the waitlist in the visitor\'s language and ask for an email in a single short sentence (e.g. "Mogu da vas obavestim čim stigne — na koji email?"). Do NOT push more alternatives. Once the visitor replies with an email, call the tool with both `product_query` and `email`. '
                            . 'OFFER PROACTIVELY: you are expected to offer the waitlist on your own initiative the moment any trigger above is met. Do not wait for the visitor to explicitly ask to be notified. Call this tool at most once per conversation with a successful save.',
                        'parameters'  => array(
                            'type'       => 'object',
                            'properties' => array(
                                'product_query' => array(
                                    'type'        => 'string',
                                    'description' => 'The product the visitor was looking for, in their own words or the cleanest short name you can infer. Included in the admin notification email.',
                                ),
                                'email'         => array(
                                    'type'        => 'string',
                                    'description' => 'The visitor\'s email address. If [VISITOR_EMAIL] is available in context, use that value verbatim. Otherwise pass the email the visitor explicitly typed. Omit only if you genuinely do not have one yet — in that case, ask the visitor instead of calling the tool.',
                                ),
                                'reason'        => array(
                                    'type'        => 'string',
                                    'description' => 'Short internal justification (one phrase). Not shown to the visitor.',
                                ),
                            ),
                            'required'   => array( 'product_query' ),
                        ),
                    ),
                );
            }
        }

        // Add skill_get tool when skills manager is available.
        if ( $this->skills_manager ) {
            $tools[] = array(
                'type'     => 'function',
                'function' => array(
                    'name'        => 'skill_get',
                    'description' => 'Load the full content of a custom skill by slug. Use when a matched skill from [SKILLS] needs full instructions.',
                    'parameters'  => array(
                        'type'       => 'object',
                        'properties' => array(
                            'slug' => array(
                                'type'        => 'string',
                                'description' => 'The skill slug identifier.',
                            ),
                        ),
                        'required' => array( 'slug' ),
                    ),
                ),
            );
        }

        return $tools;
    }

    private function execute_memory_tools( $tool_calls, $query, $context = array(), $session_id = '' ) {
        $payloads = array();
		$tool_stage_started = $this->trace_start();
		$search_query_count = 0;
		$structured_search  = false;

        foreach ( (array) $tool_calls as $call ) {
            $call_id  = isset( $call['id'] ) ? (string) $call['id'] : '';
            $name     = isset( $call['name'] ) ? (string) $call['name'] : '';
            $args_raw = isset( $call['arguments'] ) ? (string) $call['arguments'] : '';

            $args = json_decode( $args_raw, true );
            if ( ! is_array( $args ) ) {
                $args = array();
            }

            if ( 'memory_search' === $name ) {
                $q       = isset( $args['query'] ) ? (string) $args['query'] : (string) $query;
				$limit   = max( 1, min( self::MAX_FRONTEND_CONTEXT_RESULTS, isset( $args['limit'] ) ? intval( $args['limit'] ) : self::MAX_FRONTEND_CONTEXT_RESULTS ) );
                $scope   = isset( $args['scope'] ) ? (string) $args['scope'] : 'all';
				$constraints = $this->normalize_product_eligibility_constraints( isset( $args['constraints'] ) ? $args['constraints'] : array() );
				if ( ! empty( $constraints ) ) {
					$scope = 'product';
					$limit = self::MAX_FRONTEND_CONTEXT_RESULTS;
				}
				$query_mode = isset( $args['query_mode'] ) ? sanitize_key( (string) $args['query_mode'] ) : '';
				$allow_alternatives = ! empty( $args['allow_alternatives'] );
				$excluded_product_ids = array_values(
					array_unique(
						array_filter(
							array_map(
								'intval',
								array_slice(
									isset( $args['excluded_product_ids'] ) && is_array( $args['excluded_product_ids'] )
										? $args['excluded_product_ids']
										: array(),
									0,
									12
								)
							)
						)
					)
				);
				$response_language = isset( $args['response_language'] )
					? $this->normalize_response_language_code( $args['response_language'] )
					: '';
				$replaced_fields = array();
				foreach ( array_slice( isset( $args['replaced_fields'] ) && is_array( $args['replaced_fields'] ) ? $args['replaced_fields'] : array(), 0, 12 ) as $replaced_field ) {
					$replaced_field = sanitize_key( (string) $replaced_field );
					if ( '' !== $replaced_field ) {
						$replaced_fields[] = $replaced_field;
					}
				}
				$replaced_fields = array_values( array_unique( $replaced_fields ) );
				$previous_plan = $this->load_frontend_product_plan( $session_id );
				if ( empty( $previous_plan['constraints'] ) && ! empty( $context['_niwa_previous_product_plan']['constraints'] ) ) {
					$previous_plan = (array) $context['_niwa_previous_product_plan'];
				}
				$verified_current_constraints = $this->normalize_product_eligibility_constraints(
					(array) ( $context['_niwa_current_product_plan']['constraints'] ?? array() )
				);
				$verified_current_mode = sanitize_key(
					(string) ( $context['_niwa_current_product_plan']['query_mode'] ?? '' )
				);
				if (
					'fresh' === $query_mode
					&& ! empty( $previous_plan['constraints'] )
					&& 'product' === $scope
					&& 'fresh' !== $verified_current_mode
				) {
					$query_mode = 'follow_up';
				}
				if ( 'follow_up' === $query_mode ) {
					$constraints   = $this->merge_frontend_product_follow_up_constraints(
						$constraints,
						isset( $previous_plan['constraints'] ) ? $previous_plan['constraints'] : array(),
						$replaced_fields
					);
					if ( '' === $response_language && ! empty( $previous_plan['response_language'] ) ) {
						$response_language = $this->normalize_response_language_code( $previous_plan['response_language'] );
					}
				}
				$unresolved_terms = array();
				foreach ( array_slice( isset( $args['unresolved_terms'] ) && is_array( $args['unresolved_terms'] ) ? $args['unresolved_terms'] : array(), 0, 8 ) as $unresolved_term ) {
					$unresolved_term = trim( wp_strip_all_tags( (string) $unresolved_term ) );
					if ( '' !== $unresolved_term ) {
						$unresolved_terms[] = mb_substr( $unresolved_term, 0, 120 );
					}
				}
				$unresolved_terms    = array_values( array_unique( $unresolved_terms ) );
				$constraints_complete = ! empty( $constraints )
					&& in_array( $query_mode, array( 'fresh', 'follow_up' ), true )
					&& ! empty( $args['constraints_complete'] )
					&& empty( $unresolved_terms );
				$search_limit = ! empty( $constraints ) ? max( $limit, 20 ) : $limit;
                $filters = array();
                if ( 'site' === $scope ) {
                    $filters['post_type'] = Niwa_AI_Content_Store::site_post_types();
                } elseif ( 'product' === $scope ) {
                    $filters['post_type'] = 'product';
                }
                if ( in_array( $scope, array( 'all', 'product' ), true ) ) {
                    // Product discovery must complete its semantic lane before
                    // the answer model can conclude that no product exists.
                    // Restrict the forced scan to product vectors for scope=all
                    // so site/page embeddings do not add latency or crowding.
                    $filters['_require_semantic_product_scan'] = true;
                    $filters['_semantic_post_type']            = 'product';
                }
                foreach ( $constraints as $constraint ) {
                    $constraint_field = is_array( $constraint ) && isset( $constraint['field'] ) ? sanitize_key( (string) $constraint['field'] ) : '';
                    if (
                        in_array( $constraint_field, array( 'department', 'product_type', 'product_family', 'category', 'gender' ), true )
                        && count( isset( $constraint['descendants'] ) ? (array) $constraint['descendants'] : array() ) >= 5
                    ) {
                        $filters['_batch_like_variants'] = true;
                        break;
                    }
                }
                $queries = array( $q );
				if ( ! empty( $constraints ) && method_exists( 'Niwa_AI_Frontend_Product_Query_Plan', 'build_search_variants' ) ) {
					$queries = array_merge( $queries, Niwa_AI_Frontend_Product_Query_Plan::build_search_variants( $constraints, 4 ) );
				}
                if ( ! empty( $args['query_variants'] ) && is_array( $args['query_variants'] ) ) {
                    $queries = array_merge( $queries, array_slice( $args['query_variants'], 0, 4 ) );
                }
				$queries = array_slice( $this->dedupe_search_queries( $queries ), 0, 7 );
				$search_query_count += count( $queries );
				$structured_search = $structured_search || ! empty( $constraints );

                $rows          = array();
                $batched_found = array();
				$search_meta_by_query = array();
				$search_meta_supported = $this->memory_searcher && method_exists( $this->memory_searcher, 'get_last_search_meta' );
				$product_search_complete = true;
                if ( $this->memory_searcher && count( $queries ) > 1 && method_exists( $this->memory_searcher, 'search_many' ) ) {
					$batched_found = $this->memory_searcher->search_many( $queries, $search_limit, $filters );
                    $batched_found = is_array( $batched_found ) ? $batched_found : array();
                }
                foreach ( $queries as $search_query ) {
                    // Preserve the exact query order and merge semantics used by
                    // the legacy per-query path. A present empty result is valid;
                    // fall back only when the batch contract omitted the key.
                    if ( array_key_exists( $search_query, $batched_found ) ) {
                        $found = is_array( $batched_found[ $search_query ] ) ? $batched_found[ $search_query ] : array();
                    } else {
						$found = $this->memory_searcher ? $this->memory_searcher->search( $search_query, $search_limit, $filters ) : array();
                    }
					if ( $search_meta_supported ) {
						$search_meta = $this->memory_searcher->get_last_search_meta( $search_query );
						$search_meta_by_query[ $search_query ] = is_array( $search_meta ) ? $search_meta : array();
						if ( in_array( $scope, array( 'all', 'product' ), true ) && empty( $search_meta['semantic_complete'] ) ) {
							$product_search_complete = false;
						}
					}
					$rows = $this->merge_memory_search_rows( $rows, $found, max( $search_limit * 2, 20 ) );
                }
				if ( ! empty( $excluded_product_ids ) ) {
					$rows = array_values(
						array_filter(
							$rows,
							static function ( $row ) use ( $excluded_product_ids ) {
								$product_id = is_array( $row )
									? intval( $row['post_id'] ?? ( $row['id'] ?? 0 ) )
									: 0;
								return $product_id <= 0 || ! in_array( $product_id, $excluded_product_ids, true );
							}
						)
					);
				}
				// Keep the bounded multi-variant pool intact until structured
				// classification has enforced every product constraint. Truncating
				// by retrieval score first can discard exact lower-ranked variants.
				$candidate_pool_limit = ! empty( $constraints ) ? max( $search_limit * 2, 40 ) : $search_limit;
				$rows    = array_slice( $rows, 0, max( 1, intval( $candidate_pool_limit ) ) );
                $results = $this->memory_searcher ? $this->memory_searcher->build_tool_results( $rows, $q ) : array();
                $results = $this->compact_frontend_tool_results( $results );
				if ( ! empty( $constraints ) && class_exists( 'Niwa_AI_Frontend_Product_Query_Plan' ) ) {
					$results = $this->classify_frontend_product_candidates( $results, $constraints );
					$results = array_values(
						array_filter(
							$results,
							static function ( $result ) use ( $constraints_complete, $allow_alternatives ) {
								if ( ! is_array( $result ) ) {
									return false;
								}
								$match_class = isset( $result['match_class'] ) ? (string) $result['match_class'] : '';
								if ( 'rejected' === $match_class ) {
									return false;
								}
								return ! $constraints_complete
									|| $allow_alternatives
									|| in_array(
										$match_class,
										array(
											Niwa_AI_Frontend_Product_Query_Plan::EXACT_VERIFIED,
											Niwa_AI_Frontend_Product_Query_Plan::EXACT_INFERRED,
										),
										true
									);
							}
						)
					);
				}
				$results = array_slice( $results, 0, max( 1, intval( $limit ) ) );

				$card_refs = empty( $constraints ) || ( $constraints_complete && $product_search_complete )
					? $this->build_product_card_references( $results )
					: ( $constraints_complete ? $this->build_exact_product_card_references( $results ) : array() );
                $card_ids  = array_column( $card_refs, 'id' );
				$constraint_plan = array(
					'complete'         => $constraints_complete,
					'unresolved_terms' => $unresolved_terms,
				);
				$search_state = array(
					'complete' => $product_search_complete,
					'queries'  => $search_meta_by_query,
				);
				if ( $constraints_complete && $product_search_complete ) {
					$stored_plan = array(
						'query_mode'        => $query_mode,
						'response_language' => $response_language,
						'constraints'       => $constraints,
					);
					$this->store_frontend_product_plan(
						$session_id,
						$stored_plan
					);
					if (
						'fresh' === $query_mode
						&& class_exists( 'Niwa_AI_Frontend_Semantic_Lexicon' )
						&& $this->content_store
						&& method_exists( $this->content_store, 'get_product_semantic_lexicon_hash' )
					) {
						Niwa_AI_Frontend_Semantic_Lexicon::cache_plan(
							$query,
							$stored_plan,
							(string) $this->content_store->get_product_semantic_lexicon_hash()
						);
					}
				}

                $payloads[] = array(
                    'role'         => 'tool',
                    'tool_call_id' => $call_id,
					'content'      => wp_json_encode( array( 'results' => $results, 'constraint_plan' => $constraint_plan, 'search_state' => $search_state ) ),
                    '_card_ids'    => $card_ids,
                    '_card_refs'   => $card_refs,
                    '_rows'        => $rows,
                    '_queries'     => $queries,
					'_product_constraints' => $constraints,
					'_product_constraints_complete' => $constraints_complete,
					'_product_unresolved_terms' => $unresolved_terms,
					'_product_search_complete' => $product_search_complete,
					'_product_card_grounding_complete' => ! empty( $card_refs ),
					'_product_query_mode' => $query_mode,
					'_product_replaced_fields' => $replaced_fields,
					'_product_allow_alternatives' => $allow_alternatives,
					'_product_excluded_ids' => $excluded_product_ids,
					'_response_language' => $response_language,
                );
                continue;
            }

            if ( 'memory_get' === $name ) {
                $pid  = isset( $args['post_id'] ) ? intval( $args['post_id'] ) : 0;
                $row  = $this->content_store ? $this->content_store->get_product_detail( $pid ) : array();
                $text = $this->build_memory_text_from_row( $row );
                $card_refs = $this->build_product_card_references( array( $row ) );

                $payloads[] = array(
                    'role'         => 'tool',
                    'tool_call_id' => $call_id,
                    'content'      => wp_json_encode( array( 'post_id' => $pid, 'text' => $text ) ),
                    '_card_ids'    => array_column( $card_refs, 'id' ),
                    '_card_refs'   => $card_refs,
                );
                continue;
            }

			if ( 'product_rankings' === $name ) {
				$limit = isset( $args['limit'] ) ? intval( $args['limit'] ) : 4;
				$results = class_exists( 'Niwa_AI_Frontend_Product_Ranking_Service' )
					? ( new Niwa_AI_Frontend_Product_Ranking_Service() )->get_bestsellers( $limit )
					: array();
				$public_results = array_map(
					static function ( $result ) {
						return array(
							'post_id'    => isset( $result['post_id'] ) ? intval( $result['post_id'] ) : 0,
							'title'      => isset( $result['title'] ) ? (string) $result['title'] : '',
							'url'        => isset( $result['url'] ) ? (string) $result['url'] : '',
							'sales_rank' => isset( $result['sales_rank'] ) ? intval( $result['sales_rank'] ) : 0,
						);
					},
					$results
				);
				$card_refs = $this->build_product_card_references( $results );
				$payloads[] = array(
					'role'         => 'tool',
					'tool_call_id' => $call_id,
					'content'      => wp_json_encode( array( 'ranking' => 'best_selling', 'results' => $public_results ) ),
					'_card_ids'    => array_column( $card_refs, 'id' ),
					'_card_refs'   => $card_refs,
				);
				continue;
			}

            if ( 'cart_add_product' === $name ) {
                if ( empty( $context['cart_enabled'] ) ) {
                    $result = array(
                        'success' => false,
                        'status'  => 'error',
                        'code'    => 'cart_requires_live_request',
                        'message' => 'Cart changes require the live customer browser request. Ask the customer to retry in a moment.',
                    );
                } else {
                    $args['_user_query'] = (string) $query;
                    $cart_context = array(
                        'recommended'  => true,
                        'source'       => 'niwa_agent',
                        'cart_context' => 'agent_cart',
                        'session'      => '' !== (string) $session_id ? (string) $session_id : ( isset( $context['session'] ) ? (string) $context['session'] : '' ),
                        'page_url'     => isset( $context['page_url'] ) ? (string) $context['page_url'] : '',
                    );

                    $result = class_exists( 'Niwa_AI_Cart_Tool_Service' )
                        ? ( new Niwa_AI_Cart_Tool_Service() )->add_product( $args, $cart_context )
                        : array(
                        'success' => false,
                        'status'  => 'error',
                        'code'    => 'cart_service_unavailable',
                        'message' => 'Cart service is not available.',
                        );
                }

                $payloads[] = array(
                    'role'         => 'tool',
                    'tool_call_id' => $call_id,
                    'content'      => wp_json_encode( $result ),
                    '_card_ids'    => array(),
                    '_cart_result' => $result,
                );
                continue;
            }

            if ( 'navigate_to_page' === $name ) {
                if ( empty( $context['cart_enabled'] ) ) {
                    $result = array(
                        'success' => false,
                        'status'  => 'error',
                        'code'    => 'navigation_requires_live_request',
                        'message' => 'Browser navigation requires the live customer browser request. Share the page as a PAGE LINK FORMAT button instead.',
                    );
                } else {
                    $result = class_exists( 'Niwa_AI_Navigation_Tool_Service' )
                        ? ( new Niwa_AI_Navigation_Tool_Service() )->resolve( $args )
                        : array(
                            'success' => false,
                            'status'  => 'error',
                            'code'    => 'navigation_service_unavailable',
                            'message' => 'Navigation service is not available.',
                        );
                }

                $payloads[] = array(
                    'role'               => 'tool',
                    'tool_call_id'       => $call_id,
                    'content'            => wp_json_encode( $result ),
                    '_card_ids'          => array(),
                    '_navigation_result' => $result,
                );
                continue;
            }

            if ( 'activate_scheduler' === $name ) {
				$config   = $this->get_scheduler_frontend_config();
				$services = isset( $config['services'] ) && is_array( $config['services'] ) ? $config['services'] : array();
				$allowed  = array();
				foreach ( $services as $service ) {
					$id = is_array( $service ) && isset( $service['id'] ) ? sanitize_key( (string) $service['id'] ) : '';
					if ( '' !== $id ) {
						$allowed[ $id ] = true;
					}
				}
				$service_id = isset( $args['service_id'] ) ? sanitize_key( (string) $args['service_id'] ) : '';
				if ( '' !== $service_id && ! isset( $allowed[ $service_id ] ) ) {
					$service_id = '';
				}
				$team_member_id = isset( $args['team_member_id'] ) ? sanitize_key( (string) $args['team_member_id'] ) : '';
				$reason         = isset( $args['reason'] ) ? sanitize_text_field( (string) $args['reason'] ) : '';

				if ( ! $this->scheduler_triggered ) {
					$this->scheduler_triggered              = true;
					$this->scheduler_trigger_reason         = substr( $reason, 0, 200 );
					$this->scheduler_trigger_service_id     = $service_id;
					$this->scheduler_trigger_team_member_id = $team_member_id;
				}

				$payloads[] = array(
					'role'         => 'tool',
					'tool_call_id' => $call_id,
					'content'      => wp_json_encode(
						array(
							'success'    => true,
							'service_id' => $service_id,
							'message'    => 'The appointment scheduler will open in the visitor browser. Do not claim a booking exists until the scheduler confirms it. Keep the final reply to one short sentence.',
						)
					),
					'_card_ids'    => array(),
				);
				continue;
			}

            if ( 'activate_lead_form' === $name ) {
                // Mark the turn so the final response carries the trigger
                // flag. Only record the first activation per turn (dedupe).
                $reason = isset( $args['reason'] ) ? (string) $args['reason'] : '';
				$lf_opts = Niwa_AI_Lead_Form_Schema::get();
				$lf_criteria = isset( $lf_opts['trigger_description'] ) ? trim( (string) $lf_opts['trigger_description'] ) : '';
				$verification = $this->verify_lead_form_activation( $query, $context, $lf_criteria );
				if ( empty( $verification['allowed'] ) ) {
					$payloads[] = array(
						'role'         => 'tool',
						'tool_call_id' => $call_id,
						'content'      => wp_json_encode(
							array(
								'success' => false,
								'code'    => 'lead_form_intent_not_verified',
								'reason'  => isset( $verification['reason'] ) ? (string) $verification['reason'] : '',
								'message' => 'The visitor intent does not match the configured lead-form criteria. Do not call activate_lead_form again in this turn. Answer the visitor\'s actual question normally from the available evidence.',
							)
						),
						'_card_ids'    => array(),
					);
					continue;
				}

                // Guard: if the lead-form was already completed (or a contact
                // was captured via partial submit) for this visitor's session,
                // do NOT re-trigger. Re-triggering pops the form's completion
                // screen on every "credit"-related turn, which is bad UX. Tell
                // the agent the flow is done so it stops calling this tool.
                $effective_session = '' !== (string) $session_id
                    ? (string) $session_id
                    : ( isset( $context['session'] ) ? (string) $context['session'] : '' );

                $existing_status = $this->get_lead_form_session_status( $effective_session );
                $already_done    = in_array(
                    $existing_status,
                    array(
                        Niwa_AI_Lead_Form_Session_Store::STATUS_COMPLETED,
                        Niwa_AI_Lead_Form_Session_Store::STATUS_PARTIAL,
                    ),
                    true
                );

                if ( $already_done ) {
                    $payloads[] = array(
                        'role'         => 'tool',
                        'tool_call_id' => $call_id,
                        'content'      => wp_json_encode(
                            array(
                                'success' => false,
                                'code'    => 'lead_form_already_completed',
                                'status'  => $existing_status,
                                'message' => 'Lead form was already completed (or partial-submitted) earlier in this visitor session. Do NOT call activate_lead_form again. Continue the conversation normally without re-launching the form.',
                            )
                        ),
                        '_card_ids'    => array(),
                    );
                    continue;
                }

                if ( ! $this->lead_form_triggered ) {
                    $this->lead_form_triggered      = true;
                    $this->lead_form_trigger_reason = substr( $reason, 0, 200 );
                }

                $payloads[] = array(
                    'role'         => 'tool',
                    'tool_call_id' => $call_id,
                    'content'      => wp_json_encode(
                        array(
                            'success' => true,
                            'message' => 'Lead form flow will launch in the visitor browser. Keep your final reply to one short sentence acknowledging the next step.',
                        )
                    ),
                    '_card_ids'    => array(),
                );
                continue;
            }

            if ( 'submit_missing_product_email' === $name ) {
                $product_query = isset( $args['product_query'] ) ? trim( (string) $args['product_query'] ) : '';
                $email_arg     = isset( $args['email'] ) ? trim( (string) $args['email'] ) : '';
                $reason        = isset( $args['reason'] ) ? trim( (string) $args['reason'] ) : '';

                // Fallback: session-attached email from the chat context (set
                // by run_frontend_chat_request from the visitor_email POST
                // field, which chat-client.js populates from sessionStorage
                // if email_capture / CBEH / lead-form already saved one).
                if ( '' === $email_arg && ! empty( $context['visitor_email'] ) ) {
                    $email_arg = trim( (string) $context['visitor_email'] );
                }

                $tool_result = $this->save_missing_product_waitlist( $email_arg, $product_query, $reason );

                $payloads[] = array(
                    'role'         => 'tool',
                    'tool_call_id' => $call_id,
                    'content'      => wp_json_encode( $tool_result ),
                    '_card_ids'    => array(),
                );
                continue;
            }

            if ( 'skill_get' === $name && $this->skills_manager ) {
                $slug  = isset( $args['slug'] ) ? (string) $args['slug'] : '';
                $skill = $this->skills_manager->get_skill( $slug );

                if ( ! empty( $skill['success'] ) ) {
                    $output = array(
                        'slug'    => $slug,
                        'name'    => isset( $skill['name'] ) ? $skill['name'] : $slug,
                        'content' => isset( $skill['content'] ) ? $skill['content'] : '',
                    );
                } else {
                    $output = array( 'error' => 'Skill not found: ' . $slug );
                }

                $payloads[] = array(
                    'role'         => 'tool',
                    'tool_call_id' => $call_id,
                    'content'      => wp_json_encode( $output ),
                    '_card_ids'    => array(),
                );
                continue;
            }
        }

		$this->trace_finish(
			'tool_execution',
			$tool_stage_started,
			array(
				'calls'          => count( (array) $tool_calls ),
				'payloads'       => count( $payloads ),
				'search_queries' => $search_query_count,
				'structured'     => $structured_search,
			)
		);

		return $payloads;
    }

	/** Keep exact structured cards available when only the semantic lane is offline. */
	private function build_exact_product_card_references( $results ) {
		$exact = array_values(
			array_filter(
				(array) $results,
				static function ( $result ) {
					if ( ! is_array( $result ) ) {
						return false;
					}
					$match_class = (string) ( $result['match_class'] ?? '' );
					return ! empty( $result['card_eligible'] )
						&& in_array(
							$match_class,
							array(
								Niwa_AI_Frontend_Product_Query_Plan::EXACT_VERIFIED,
								Niwa_AI_Frontend_Product_Query_Plan::EXACT_INFERRED,
							),
							true
						)
						&& empty( $result['missing_constraints'] )
						&& empty( $result['conflicting_constraints'] );
				}
			)
		);
		return $this->build_product_card_references( $exact );
	}

	private function build_product_card_references( $items ) {
        $references = array();
        foreach ( (array) $items as $item ) {
            if ( ! is_array( $item ) ) {
                continue;
            }

            $type = isset( $item['type'] ) ? (string) $item['type'] : ( isset( $item['post_type'] ) ? (string) $item['post_type'] : 'product' );
            if ( '' !== $type && 'product' !== $type ) {
                continue;
            }
			if ( ( array_key_exists( 'card_eligible', $item ) && empty( $item['card_eligible'] ) ) || ( isset( $item['match_class'] ) && 'rejected' === $item['match_class'] ) ) {
				continue;
			}

            $product_id = isset( $item['post_id'] ) ? intval( $item['post_id'] ) : ( isset( $item['id'] ) ? intval( $item['id'] ) : 0 );
            $title      = isset( $item['title'] ) ? trim( (string) $item['title'] ) : ( isset( $item['name'] ) ? trim( (string) $item['name'] ) : '' );
            $url        = isset( $item['url'] ) ? trim( (string) $item['url'] ) : '';
            if ( $product_id <= 0 || ( '' === $title && '' === $url ) ) {
                continue;
            }

            $references[ $product_id ] = array(
                'id'     => $product_id,
                'title'  => $title,
                'url'    => $url,
                'score'  => isset( $item['score'] ) ? floatval( $item['score'] ) : 0.0,
                'source' => isset( $item['source'] ) ? sanitize_key( (string) $item['source'] ) : '',
            );
			foreach ( array( 'match_class', 'card_eligible', 'disclosure_required', 'matched_constraints', 'missing_constraints', 'conflicting_constraints', 'constraint_evidence' ) as $match_key ) {
				if ( array_key_exists( $match_key, $item ) ) {
					$references[ $product_id ][ $match_key ] = $item[ $match_key ];
				}
			}
            if ( ! empty( $item['variation_preview'] ) && is_array( $item['variation_preview'] ) ) {
                $references[ $product_id ]['variation_preview'] = $item['variation_preview'];
            }
        }

		return array_values( $references );
	}

	/**
	 * Keep backend-only retrieval rows and card metadata out of the API payload.
	 * The model needs only the compact JSON content; the full payload remains in
	 * memory for deterministic card selection, confidence, and disclosure.
	 */
	private function build_llm_tool_message( $payload ) {
		$payload = is_array( $payload ) ? $payload : array();
		return array(
			'role'         => 'tool',
			'tool_call_id' => isset( $payload['tool_call_id'] ) ? (string) $payload['tool_call_id'] : '',
			'content'      => isset( $payload['content'] ) ? (string) $payload['content'] : '{}',
		);
	}

    private function merge_memory_search_rows( $existing, $incoming, $limit ) {
        $merged = array();
        $index  = array();
        foreach ( array_merge( (array) $existing, (array) $incoming ) as $row ) {
            if ( ! is_array( $row ) ) {
                continue;
            }
            $post_id = isset( $row['post_id'] ) ? intval( $row['post_id'] ) : 0;
            $doc_key = isset( $row['doc_key'] ) ? (string) $row['doc_key'] : '';
            $post_type = isset( $row['post_type'] ) ? (string) $row['post_type'] : '';
            $key     = 'product' === $post_type && $post_id > 0 ? 'p:' . $post_id : 'd:' . $doc_key;
            if ( ! isset( $index[ $key ] ) ) {
                $index[ $key ] = count( $merged );
                $merged[]      = $row;
                continue;
            }

            $position = intval( $index[ $key ] );
            $current  = $merged[ $position ];
            if ( floatval( isset( $row['_score'] ) ? $row['_score'] : 0 ) > floatval( isset( $current['_score'] ) ? $current['_score'] : 0 ) ) {
                $row = array_merge( $current, $row );
                $merged[ $position ] = $row;
            }
        }

        usort(
            $merged,
            static function ( $left, $right ) {
                return floatval( isset( $right['_score'] ) ? $right['_score'] : 0 ) <=> floatval( isset( $left['_score'] ) ? $left['_score'] : 0 );
            }
        );
        return array_slice( $merged, 0, max( 1, intval( $limit ) ) );
    }

    private function dedupe_search_queries( $queries ) {
        $out  = array();
        $seen = array();
        foreach ( (array) $queries as $query ) {
            $query = trim( (string) $query );
            if ( '' === $query ) {
                continue;
            }
            $key = $this->normalize_search_query_key( $query );
            if ( '' === $key || isset( $seen[ $key ] ) ) {
                continue;
            }
            $seen[ $key ] = true;
            $out[]        = $query;
        }
        return $out;
    }

    private function normalize_search_query_key( $query ) {
        $query = trim( (string) $query );
        if ( class_exists( 'Normalizer' ) ) {
            $normalized = \Normalizer::normalize( $query, \Normalizer::FORM_KC );
            if ( is_string( $normalized ) && '' !== $normalized ) {
                $query = $normalized;
            }
        }
        $query = function_exists( 'mb_strtolower' ) ? mb_strtolower( $query, 'UTF-8' ) : strtolower( $query );
        if ( function_exists( 'remove_accents' ) ) {
            $query = remove_accents( $query );
        }
        $query = preg_replace( '/[^\p{L}\p{N}]+/u', ' ', $query );
        return trim( preg_replace( '/\s+/u', ' ', (string) $query ) );
    }

    /** Keep enough indexed evidence for reasoning without sending full product pages. */
    private function compact_frontend_tool_results( $results ) {
        $compact = array();
        foreach ( (array) $results as $item ) {
            if ( ! is_array( $item ) ) {
                continue;
            }

            $out = array();
            foreach ( array( 'post_id', 'title', 'type', 'score', 'exact_match', 'brand', 'categories', 'tags', 'matched_fields', 'url' ) as $key ) {
                if ( array_key_exists( $key, $item ) ) {
                    $out[ $key ] = $item[ $key ];
                }
            }
			$mutable_snapshot = array();
			foreach ( array( 'price', 'sale_price', 'stock_status' ) as $mutable_key ) {
				if ( array_key_exists( $mutable_key, $item ) && '' !== (string) $item[ $mutable_key ] ) {
					$mutable_snapshot[ $mutable_key ] = $item[ $mutable_key ];
				}
			}
			if ( ! empty( $mutable_snapshot ) ) {
				$out['mutable_snapshot'] = array_merge(
					array(
						'freshness'            => 'indexed_not_live',
						'authoritative_surface' => 'finalist_product_card_or_checkout',
					),
					$mutable_snapshot
				);
			}
            // Memory_Searcher already applies a per-candidate byte budget and
            // produces query-aware excerpts. Do not cap those values again: a
            // second cut can remove the exact factual phrase selected upstream.
            foreach ( array( 'attributes', 'short_desc', 'content' ) as $evidence_key ) {
                if ( ! empty( $item[ $evidence_key ] ) ) {
                    $out[ $evidence_key ] = $item[ $evidence_key ];
                }
            }
            if ( ! empty( $item['evidence'] ) && is_array( $item['evidence'] ) ) {
                $out['evidence'] = array();
                foreach ( array( 'excerpt', 'source_field', 'source_doc_key', 'retrieval_basis', 'claim_scope', 'factual_constraint_overlap', 'unmatched_query_terms' ) as $evidence_key ) {
                    if ( array_key_exists( $evidence_key, $item['evidence'] ) ) {
                        $out['evidence'][ $evidence_key ] = $item['evidence'][ $evidence_key ];
                    }
                }
            }
            $compact[] = $out;
        }

        return $compact;
    }

	/**
	 * Revalidate the best static tier against live purchasable variation facts.
	 * The internal matrix is stripped before candidates are sent to the model.
	 */
	private function classify_frontend_product_candidates( $results, $constraints ) {
		$classified = Niwa_AI_Frontend_Product_Query_Plan::classify_candidates( $results, $constraints );
		if ( function_exists( 'wc_get_product' ) && class_exists( 'Niwa_AI_Product_Search_Evidence' ) ) {
			foreach ( $classified as &$candidate ) {
				if ( ! is_array( $candidate ) || empty( $candidate['card_eligible'] ) ) {
					continue;
				}
				$product_id = isset( $candidate['post_id'] ) ? intval( $candidate['post_id'] ) : intval( isset( $candidate['id'] ) ? $candidate['id'] : 0 );
				$product    = $product_id > 0 ? wc_get_product( $product_id ) : null;
				if ( ! $product || ! method_exists( $product, 'is_type' ) || ! $product->is_type( 'variable' ) ) {
					continue;
				}
				$availability = $this->build_product_variation_availability( $product );
				if ( ! empty( $availability['complete'] ) ) {
					$candidate['_variation_availability'] = $availability;
				}
			}
			unset( $candidate );
			$classified = Niwa_AI_Frontend_Product_Query_Plan::classify_candidates( $classified, $constraints );
		}

		foreach ( $classified as &$candidate ) {
			unset( $candidate['_variation_availability'] );
		}
		unset( $candidate );
		return $classified;
	}

	/** Build a compact attribute matrix from authoritative Woo variation claims. */
	private function build_product_variation_availability( $product, $evidence = null ) {
		$evidence = is_array( $evidence ) ? $evidence : Niwa_AI_Product_Search_Evidence::build( $product );
		$coverage = isset( $evidence['coverage'] ) && is_array( $evidence['coverage'] ) ? $evidence['coverage'] : array();
		if ( empty( $coverage['complete'] ) || intval( isset( $coverage['processed_count'] ) ? $coverage['processed_count'] : -1 ) !== intval( isset( $coverage['indexed_count'] ) ? $coverage['indexed_count'] : -2 ) ) {
			return array( 'complete' => false, 'groups' => array(), 'variations' => array() );
		}

		$axis_keys = array();
		if ( method_exists( $product, 'get_attributes' ) ) {
			foreach ( (array) $product->get_attributes() as $attribute ) {
				if ( is_object( $attribute ) && method_exists( $attribute, 'get_variation' ) && $attribute->get_variation() && method_exists( $attribute, 'get_name' ) ) {
					$axis_keys[ (string) $attribute->get_name() ] = true;
				}
			}
		}

		$groups     = array();
		$variations = array();
		foreach ( isset( $evidence['structured_claims'] ) ? (array) $evidence['structured_claims'] : array() as $claim ) {
			if ( ! is_array( $claim ) ) {
				continue;
			}
			$key   = isset( $claim['predicate']['raw_key'] ) ? (string) $claim['predicate']['raw_key'] : '';
			$label = isset( $claim['predicate']['label'] ) ? trim( (string) $claim['predicate']['label'] ) : '';
			$value = isset( $claim['object']['raw'] ) ? trim( (string) $claim['object']['raw'] ) : '';
			$scope = isset( $claim['subject']['scope'] ) ? (string) $claim['subject']['scope'] : '';
			if ( '' === $key || '' === $value || ! isset( $axis_keys[ $key ] ) ) {
				continue;
			}
			if ( ! isset( $groups[ $key ] ) ) {
				$groups[ $key ] = array( 'label' => $label, 'parent_values' => array(), 'available_values' => array() );
			} elseif ( '' === (string) ( $groups[ $key ]['label'] ?? '' ) && '' !== $label ) {
				$groups[ $key ]['label'] = $label;
			}
			if ( 'product' === $scope ) {
				if ( ! in_array( $value, $groups[ $key ]['parent_values'], true ) ) {
					$groups[ $key ]['parent_values'][] = $value;
				}
				continue;
			}
			if ( 'variation' !== $scope || 'available_variation' !== ( isset( $claim['availability_scope'] ) ? (string) $claim['availability_scope'] : '' ) ) {
				continue;
			}
			if ( ! in_array( $value, $groups[ $key ]['available_values'], true ) ) {
				$groups[ $key ]['available_values'][] = $value;
			}
			$variation_id = intval( isset( $claim['subject']['id'] ) ? $claim['subject']['id'] : 0 );
			if ( $variation_id > 0 ) {
				if ( ! isset( $variations[ $variation_id ] ) ) {
					$variations[ $variation_id ] = array();
				}
				if ( ! isset( $variations[ $variation_id ][ $key ] ) ) {
					$variations[ $variation_id ][ $key ] = array();
				}
				if ( ! in_array( $value, $variations[ $variation_id ][ $key ], true ) ) {
					$variations[ $variation_id ][ $key ][] = $value;
				}
			}
		}

		/*
		 * Woo stores an "Any" variation option as an empty child attribute.
		 * It means that the purchasable child accepts every declared parent value,
		 * not that the value is unavailable. Expand it only from the authoritative
		 * parent option set so the same-child combination check remains exact.
		 */
		foreach ( isset( $evidence['variations'] ) ? (array) $evidence['variations'] : array() as $variation ) {
			if ( ! is_array( $variation ) || empty( $variation['purchasable'] ) ) {
				continue;
			}
			$variation_id = intval( $variation['variation_id'] ?? 0 );
			if ( $variation_id <= 0 ) {
				continue;
			}
			$wildcard_keys = array_values( array_filter( array_map( 'strval', (array) ( $variation['wildcard_attributes'] ?? array() ) ) ) );
			foreach ( (array) ( $variation['selection'] ?? array() ) as $selection_key => $selection_value ) {
				if ( '' === trim( (string) $selection_value ) ) {
					$wildcard_keys[] = (string) $selection_key;
				}
			}
			foreach ( array_values( array_unique( $wildcard_keys ) ) as $wildcard_key ) {
				$key = preg_replace( '/^attribute_/', '', (string) $wildcard_key );
				if ( '' === $key || ! isset( $axis_keys[ $key ], $groups[ $key ] ) ) {
					continue;
				}
				$values = array_values( array_unique( array_filter( array_map( 'strval', (array) $groups[ $key ]['parent_values'] ) ) ) );
				if ( empty( $values ) ) {
					continue;
				}
				$groups[ $key ]['available_values'] = array_values( array_unique( array_merge( $groups[ $key ]['available_values'], $values ) ) );
				if ( ! isset( $variations[ $variation_id ] ) ) {
					$variations[ $variation_id ] = array();
				}
				$variations[ $variation_id ][ $key ] = array_values( array_unique( array_merge( (array) ( $variations[ $variation_id ][ $key ] ?? array() ), $values ) ) );
			}
		}

		return array(
			'complete'   => true,
			'groups'     => $groups,
			'variations' => array_values( $variations ),
		);
	}

    /**
     * Load variation previews only for the products grounded in the final answer.
     * Current card price/stock/image values are loaded by the card builder after
     * this method, so the LLM never waits on Woo hydration for every candidate.
     */
    private function hydrate_frontend_product_card_previews( $product_ids, $queries_by_product = array(), $constraints_by_product = array(), $fallback_previews = array() ) {
        $previews = is_array( $fallback_previews ) ? $fallback_previews : array();
        if ( ! function_exists( 'wc_get_product' ) ) {
            return $previews;
        }

        foreach ( array_slice( array_values( array_unique( array_map( 'intval', (array) $product_ids ) ) ), 0, 6 ) as $product_id ) {
            if ( $product_id <= 0 ) {
                continue;
            }
            $product = wc_get_product( $product_id );
            if ( ! $product ) {
                continue;
            }

            $evidence = class_exists( 'Niwa_AI_Product_Search_Evidence' ) ? Niwa_AI_Product_Search_Evidence::build( $product ) : array();
            if ( ! empty( $evidence['variations'] ) && class_exists( 'Niwa_AI_Product_Variation_Preview' ) ) {
				$queries     = isset( $queries_by_product[ $product_id ] ) ? (array) $queries_by_product[ $product_id ] : array();
				$constraints = isset( $constraints_by_product[ $product_id ] ) ? (array) $constraints_by_product[ $product_id ] : array();
                $preview = Niwa_AI_Product_Variation_Preview::resolve( $evidence['variations'], $queries, $constraints );
                if ( ! empty( $preview ) ) {
                    $previews[ $product_id ] = $preview;
                } elseif ( ! empty( $constraints ) ) {
                    unset( $previews[ $product_id ] );
                }
            }
        }

        return $previews;
    }

    /**
     * Build clean memory text from a DB row â€” no raw JSON dumps.
     */
    private function build_memory_text_from_row( $row ) {
        if ( empty( $row ) || ! is_array( $row ) ) {
            return '';
        }

        $parts     = array();
        $post_type = isset( $row['post_type'] ) ? (string) $row['post_type'] : '';
        $is_product = ( 'product' === $post_type );

        if ( ! empty( $row['title'] ) ) {
            $parts[] = 'Name: ' . (string) $row['title'];
        }
        if ( ! empty( $post_type ) ) {
            $parts[] = 'Type: ' . $post_type;
        }
        if ( $is_product && ! empty( $row['brand'] ) ) {
            $parts[] = 'Brand: ' . (string) $row['brand'];
        }
        if ( $is_product && isset( $row['price'] ) && '' !== $row['price'] ) {
            $parts[] = 'Indexed price snapshot (not guaranteed current): ' . (string) $row['price'];
        }
        if ( $is_product && isset( $row['sale_price'] ) && '' !== $row['sale_price'] ) {
            $parts[] = 'Indexed sale-price snapshot (not guaranteed current): ' . (string) $row['sale_price'];
        }
        if ( $is_product && ! empty( $row['stock_status'] ) ) {
            $parts[] = 'Indexed stock snapshot (not guaranteed current): ' . (string) $row['stock_status'];
        }
        if ( ! empty( $row['categories_text'] ) ) {
            $parts[] = 'Categories: ' . $this->clip_text( $row['categories_text'], 300 );
        }
        if ( ! empty( $row['tags_text'] ) ) {
            $parts[] = 'Tags: ' . $this->clip_text( $row['tags_text'], 200 );
        }
        if ( ! empty( $row['attributes_text'] ) ) {
            $parts[] = 'Attributes: ' . $this->clip_text( $row['attributes_text'], 16000 );
        }
        if ( ! empty( $row['short_desc'] ) ) {
            $parts[] = 'Short description: ' . $this->clip_text( $row['short_desc'], 16000 );
        }
        if ( ! empty( $row['content_text'] ) ) {
            $content_limit = $is_product ? 16000 : 1500;
            $parts[]       = 'Content: ' . $this->clip_text( $row['content_text'], $content_limit );
        }
        if ( ! empty( $row['url'] ) ) {
            $parts[] = 'URL: ' . (string) $row['url'];
        }

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

    private function answer_with_rag_fallback( $query, $history, $constraints, $context = array() ) {
        $language    = $this->detect_preferred_response_language( $query, $context );
		$stage_started = $this->trace_start();
		$rag_results = $this->rag_fallback ? $this->rag_fallback->search( $query, $constraints, self::MAX_FRONTEND_CONTEXT_RESULTS ) : array();
		if ( ! empty( $rag_results ) && class_exists( 'Niwa_AI_Frontend_Product_Query_Plan' ) ) {
			$rag_results = Niwa_AI_Frontend_Product_Query_Plan::classify_candidates( $rag_results, array() );
		}
		$this->trace_finish( 'rag_fallback_search', $stage_started, array( 'results' => count( (array) $rag_results ) ) );
        if ( empty( $rag_results ) ) {
            return array(
                'success'    => true,
                'answer'     => $this->get_localized_frontend_fallback_text( 'no_results', $language ),
                'error'      => '',
                'confidence' => 0.0,
                'cards'      => array(),
                'source'     => 'rlm_empty',
            );
        }

        $history_text     = $this->history_to_text( $history );
        $store_overview   = $this->store_overview ? $this->store_overview->build() : "[STORE OVERVIEW]\nNo store data available.\n[/STORE OVERVIEW]";
        $agent_mode_block = $this->build_agent_mode_instructions_block( 2200 );

        $rag_context = '';
        $blocks      = array();
        foreach ( $rag_results as $r ) {
            $lines   = array();
            $lines[] = 'Name: ' . $this->clip_text( isset( $r['name'] ) ? $r['name'] : '', 200 );
            if ( ! empty( $r['price'] ) ) {
                $lines[] = 'Price: ' . $this->clip_text( $r['price'], 60 );
            }
            if ( ! empty( $r['oldPrice'] ) ) {
                $lines[] = 'Old price: ' . $this->clip_text( $r['oldPrice'], 60 );
            }
            if ( ! empty( $r['url'] ) ) {
                $lines[] = 'URL: ' . $this->clip_text( $r['url'], 300 );
            }
			if ( ! empty( $r['match_class'] ) ) {
				$lines[] = 'Match class: ' . $this->clip_text( $r['match_class'], 40 );
			}
			if ( ! empty( $r['disclosure_required'] ) ) {
				$lines[] = 'This is an unverified discovery alternative. Do not describe it as an exact match.';
			}
            $blocks[] = "[PRODUCT]\n" . implode( "\n", array_filter( $lines ) ) . "\n[/PRODUCT]";
        }
        $rag_context = implode( "\n\n", $blocks );

        $model     = $this->get_frontend_model();
        $auth_mode = $this->auth_manager->get_mode();
        $creds     = $this->auth_manager->get_credentials();

        $instructions = "You are Niwa, a helpful female sales assistant.\n\nRULES:\n- Respond EXCLUSIVELY in the user's language.\n- CRITICAL: You are female. When speaking Serbian, Croatian, or Bosnian, ALWAYS use feminine verb forms that match that language. NEVER use masculine forms.\n- Keep answers short (max 6-8 lines), clear, no emojis.\n- Do NOT invent product names. Only recommend products from [RELEVANT PRODUCTS].\n- Use EXACT product names from the list.\n\n" . $store_overview . "\n\n[RELEVANT PRODUCTS]\n" . $rag_context . "\n[/RELEVANT PRODUCTS]\n\n[HISTORY]\n" . $history_text . "\n[/HISTORY]";

        $instructions = str_replace( "Respond EXCLUSIVELY in the user's language.", 'Respond EXCLUSIVELY in the correct language for this turn.', $instructions );
        $instructions .= $this->build_turn_language_instructions( $query, $context );
        $instructions .= $this->build_ecommerce_sales_agent_instructions();

        if ( '' !== $agent_mode_block ) {
            $instructions .= "\n\n" . $agent_mode_block;
        }

		$stage_started = $this->trace_start();
		$result = $this->openai_client->generate( $model, $query, $instructions, $auth_mode, $creds, 45 );
		$this->trace_finish( 'llm_fallback', $stage_started, array( 'model' => $model, 'success' => ! empty( $result['success'] ) ) );

        if ( ! $result['success'] ) {
            return array(
				'success'    => false,
				'retryable'  => true,
				'answer'     => '',
				'error'      => isset( $result['error'] ) ? (string) $result['error'] : 'Fallback model request failed.',
				'error_code' => 'fallback_api_call_failed',
                'confidence' => 0.0,
                'cards'      => array(),
                'source'     => 'model_error',
            );
        }

        $raw_answer = $this->response_postprocessor->clean_answer( (string) $result['answer'] );
        $answer     = $this->humanizer->apply( $raw_answer );
		$cards      = $this->select_product_cards(
            $raw_answer,
            array(
                array(
                    '_card_refs' => $this->build_product_card_references( $rag_results ),
                    '_queries'   => array( (string) $query ),
                ),
            )
        );

        return array(
            'success'    => true,
            'answer'     => $answer,
            'error'      => '',
            'confidence' => 0.35,
            'cards'      => $cards,
            'source'     => 'rag_vector_search',
        );
    }

    private function error_response( $message, $context = array(), $query = '', $error_code = 'model_error' ) {
		unset( $context, $query );
        return array(
			'success'    => false,
			'retryable'  => true,
			'answer'     => '',
            'error'      => (string) $message,
			'error_code' => sanitize_key( (string) $error_code ),
            'confidence' => 0.0,
            'cards'      => array(),
            'source'     => 'model_error',
        );
    }

    private function detect_preferred_response_language( $query, $context = array() ) {
        unset( $query );

        $context = is_array( $context ) ? $context : array();
        foreach ( array( 'conversation_language', 'ui_language', 'language_code', 'language', 'runtime_locale', 'ui_locale', 'locale' ) as $key ) {
            if ( empty( $context[ $key ] ) ) {
                continue;
            }

            $normalized = $this->normalize_response_language_code( $context[ $key ] );
            if ( '' !== $normalized ) {
                return $normalized;
            }
        }

        $configured = $this->get_frontend_ui_language_setting();
        if ( '' !== $configured ) {
            return $configured;
        }

        return $this->normalize_response_language_code( function_exists( 'determine_locale' ) ? determine_locale() : 'en_US' );
    }

    private function build_turn_language_instructions( $query, $context = array() ) {
        $preferred_language = $this->detect_preferred_response_language( $query, $context );
		$has_recent_turns    = ! empty( $context['_niwa_recent_turns'] );
        $lines              = array(
            '',
            'LANGUAGE PRIORITY:',
            '- Follow the language of the CURRENT user message when it is clear.',
            '- Do not let website copy, retrieved product text, WordPress locale, or earlier turns in another language override the current turn language.',
            '- Even when product names or catalog fields are stored in another language, keep the surrounding answer in the selected turn language.',
        );
		if ( $has_recent_turns ) {
			$lines[] = '- If the current user message is short, ambiguous, or language-neutral, continue in the language of the most recent substantive conversation turn. The website UI language is not the conversation language.';
		} else {
			$lines[] = '- If the current user message is short, ambiguous, or language-neutral and there is no substantive conversation turn, default to this preferred turn language: ' . $this->get_response_language_label( $preferred_language ) . '.';
		}

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

    private function get_frontend_ui_language_setting() {
        if ( ! function_exists( 'get_option' ) ) {
            return '';
        }

        $ui_settings = get_option( Niwa_AI_Constants::OPT_UI_SETTINGS, array() );
        if ( ! is_array( $ui_settings ) || empty( $ui_settings['ui_language'] ) ) {
            return '';
        }

        return $this->normalize_response_language_code( $ui_settings['ui_language'] );
    }

    private function normalize_response_language_code( $language ) {
        $language = strtolower( trim( str_replace( '-', '_', (string) $language ) ) );
        if ( '' === $language ) {
            return '';
        }

        $parts    = preg_split( '/_+/', $language );
        $language = isset( $parts[0] ) ? preg_replace( '/[^a-z]/', '', (string) $parts[0] ) : '';
        if ( '' === $language ) {
            return '';
        }

        if ( 'bs' === $language ) {
            return 'sr';
        }

        return in_array( $language, array( 'en', 'sr', 'hr', 'de', 'es', 'fr', 'it' ), true )
            ? $language
            : substr( $language, 0, 2 );
    }

    private function get_response_language_label( $language ) {
        $normalized = $this->normalize_response_language_code( $language );
        $labels     = array(
            'en' => 'English',
            'sr' => 'Serbian',
            'hr' => 'Croatian',
            'de' => 'German',
            'es' => 'Spanish',
            'fr' => 'French',
            'it' => 'Italian',
        );

        return isset( $labels[ $normalized ] ) ? $labels[ $normalized ] : ucfirst( $normalized );
    }

    private function get_localized_frontend_fallback_text( $key, $language ) {
        $language  = $this->normalize_response_language_code( $language );
        $fallbacks = array(
            'hr' => array(
                'greeting'     => 'Bok. Kako mogu pomoci?',
                'no_results'   => 'Nisam pronasla relevantne proizvode za taj upit.',
            ),
            'de' => array(
                'greeting'     => 'Hallo. Wie kann ich helfen?',
                'no_results'   => 'Ich konnte keine passenden Produkte fuer diese Anfrage finden.',
            ),
            'es' => array(
                'greeting'     => 'Hola. Como puedo ayudarte?',
                'no_results'   => 'No pude encontrar productos relevantes para esa consulta.',
            ),
            'fr' => array(
                'greeting'     => 'Bonjour. Comment puis-je aider ?',
                'no_results'   => 'Je n ai pas trouve de produits pertinents pour cette demande.',
            ),
            'sr' => array(
                'greeting'     => 'Zdravo. Kako mogu da pomognem?',
                'no_results'   => 'Nisam pronasla relevantne proizvode za taj upit.',
            ),
            'en' => array(
                'greeting'     => 'Hi. How can I help?',
                'no_results'   => 'I could not find relevant products for that query.',
            ),
        );

        $language = isset( $fallbacks[ $language ] ) ? $language : 'en';
		return isset( $fallbacks[ $language ][ $key ] ) ? $fallbacks[ $language ][ $key ] : $fallbacks[ $language ]['no_results'];
    }

	private function select_product_cards( $answer, $tool_payloads ) {
		$stage_started = $this->trace_start();
		if (
			method_exists( $this->product_card_selector, 'resolve_grounded_product_ids' )
			&& method_exists( $this->product_card_selector, 'build_cards_for_product_ids' )
		) {
			$product_ids = $this->product_card_selector->resolve_grounded_product_ids( $answer, $tool_payloads );
			$product_ids = array_slice( array_values( array_unique( array_map( 'intval', (array) $product_ids ) ) ), 0, 4 );
			$previews    = array();
			$match_metadata = array();
			$queries_by_product = array();
			$constraints_by_product = array();
			foreach ( (array) $tool_payloads as $payload ) {
				if ( ! is_array( $payload ) ) {
					continue;
				}
				$payload_queries = array();
				foreach ( (array) ( $payload['_queries'] ?? array() ) as $query ) {
					$query = trim( (string) $query );
					if ( '' !== $query ) {
						$payload_queries[] = $query;
					}
				}
				$payload_queries = array_values( array_unique( $payload_queries ) );
				$payload_constraints = ! empty( $payload['_product_constraints_complete'] ) && empty( $payload['_product_unresolved_terms'] )
					? (array) ( $payload['_product_constraints'] ?? array() )
					: array();
				foreach ( (array) ( $payload['_card_refs'] ?? array() ) as $reference ) {
					$product_id = is_array( $reference ) ? intval( $reference['id'] ?? 0 ) : 0;
					if ( in_array( $product_id, $product_ids, true ) && is_array( $reference ) ) {
						$match_metadata[ $product_id ] = $reference;
						if ( ! empty( $payload_queries ) ) {
							$queries_by_product[ $product_id ] = $payload_queries;
						}
						if ( ! empty( $payload_constraints ) ) {
							$constraints_by_product[ $product_id ] = $payload_constraints;
						}
					}
					if ( in_array( $product_id, $product_ids, true ) && ! empty( $reference['variation_preview'] ) && is_array( $reference['variation_preview'] ) ) {
						$previews[ $product_id ] = $reference['variation_preview'];
					}
				}
			}
			$hydration_started = $this->trace_start();
			$previews = $this->hydrate_frontend_product_card_previews(
				$product_ids,
				$queries_by_product,
				$constraints_by_product,
				$previews
			);
			$this->trace_finish(
				'product_hydration',
				$hydration_started,
				array(
					'finalists' => count( $product_ids ),
					'previews'  => count( $previews ),
					'lazy'      => true,
				)
			);
			$cards = $this->product_card_selector->build_cards_for_product_ids( $product_ids, $previews, $match_metadata );
		} else {
			$cards = $this->product_card_selector->select_cards( $answer, $tool_payloads );
		}
		$this->trace_finish( 'cards', $stage_started, array( 'count' => count( (array) $cards ) ) );

		return $cards;
	}

	private function trace_start() {
		if ( ! $this->latency_tracker || '' === $this->active_trace_request_id || ! method_exists( $this->latency_tracker, 'start_stage' ) ) {
			return 0;
		}

		return (int) $this->latency_tracker->start_stage( $this->active_trace_request_id );
	}

	private function trace_finish( $stage, $started_ns, $meta = array() ) {
		if ( ! $this->latency_tracker || ! method_exists( $this->latency_tracker, 'finish_stage' ) ) {
			return 0.0;
		}

		return (float) $this->latency_tracker->finish_stage( $this->active_trace_request_id, $stage, $started_ns, $meta );
	}

    private function clip_text( $text, $limit ) {
        $text = $this->sanitize_context_text( $text );
        if ( mb_strlen( $text ) > $limit ) {
            $text = mb_substr( $text, 0, max( 0, $limit - 3 ) ) . '...';
        }
        return $text;
    }

    private function complete_final_tool_rounds( $messages, $query, $model, $auth_mode, $creds, $tools, $use_spark, &$tool_payloads, $context = array(), $session_id = '' ) {
        if ( $use_spark ) {
			$stage_started = $this->trace_start();
			$final = $this->spark_adapter->chat_complete_final( $model, $messages, $auth_mode, $creds, $tools, self::FINAL_ROUND_TIMEOUT );
			$this->trace_finish( 'llm_final', $stage_started, array( 'model' => $model, 'round' => 1, 'success' => ! empty( $final['success'] ), 'spark' => true, 'prompt_chars' => strlen( wp_json_encode( $messages ) ) ) );
			return $final;
        }

        $working_messages = is_array( $messages ) ? $messages : array();
        for ( $round = 1; $round <= self::MAX_FINAL_TOOL_ROUNDS; $round++ ) {
			$stage_started = $this->trace_start();
            $final = $this->openai_client->chat_complete(
                $model,
                $working_messages,
                $auth_mode,
                $creds,
                self::FINAL_ROUND_TIMEOUT,
                $tools,
                'none'
            );
			$this->trace_finish(
				'llm_final',
				$stage_started,
				array(
					'model'      => $model,
					'round'      => $round,
					'success'    => ! empty( $final['success'] ),
					'tool_calls' => isset( $final['tool_calls'] ) && is_array( $final['tool_calls'] ) ? count( $final['tool_calls'] ) : 0,
					'prompt_chars' => strlen( wp_json_encode( $working_messages ) ),
				)
			);

            if ( empty( $final['success'] ) ) {
                return $final;
            }

            $next_tool_calls = isset( $final['tool_calls'] ) && is_array( $final['tool_calls'] ) ? $final['tool_calls'] : array();
            if ( empty( $next_tool_calls ) || self::MAX_FINAL_TOOL_ROUNDS === $round ) {
                return $final;
            }

            $assistant_message = isset( $final['message'] ) && is_array( $final['message'] ) ? $final['message'] : array();
            if ( empty( $assistant_message ) ) {
                return $final;
            }

            $next_tool_payloads = $this->execute_memory_tools( $next_tool_calls, $query, $context, $session_id );
            if ( empty( $next_tool_payloads ) ) {
                return $final;
            }

            $this->rlm_debug(
                'FINAL_TOOL_FOLLOWUP',
                $query,
                array(
                    'model'      => $model,
                    'round'      => $round,
                    'tool_calls' => count( $next_tool_calls ),
                )
            );

            $working_messages[] = $assistant_message;
            foreach ( $next_tool_payloads as $payload ) {
				$working_messages[] = $this->build_llm_tool_message( $payload );
                $tool_payloads[]    = $payload;
            }
        }

        return array(
            'success' => false,
            'error'   => 'Final tool round limit exceeded.',
            'answer'  => '',
        );
    }

    private function extract_latest_cart_result( $tool_payloads ) {
        $latest = array();
        foreach ( (array) $tool_payloads as $payload ) {
            if ( ! empty( $payload['_cart_result'] ) && is_array( $payload['_cart_result'] ) ) {
                $latest = $payload['_cart_result'];
            }
        }

        return $latest;
    }

    private function extract_latest_navigation_result( $tool_payloads ) {
        $latest = array();
        foreach ( (array) $tool_payloads as $payload ) {
            if ( ! empty( $payload['_navigation_result'] ) && is_array( $payload['_navigation_result'] ) ) {
                $latest = $payload['_navigation_result'];
            }
        }

        return $latest;
    }

    private function history_to_text( $history ) {
        $context = $this->conversation_context_builder->build( $history, '', array(), $this->get_history_context_limit() );
        return isset( $context['prompt_context'] ) ? (string) $context['prompt_context'] : '';
    }

    private function get_frontend_model() {
        if ( $this->model_router && method_exists( $this->model_router, 'route_for_frontend_chat' ) ) {
            return (string) $this->model_router->route_for_frontend_chat();
        }

        return $this->model_router ? $this->model_router->route_for_agent() : 'gpt-5.4-mini';
    }

    /**
     * Save a missing-product waitlist request — validate email, persist as a
     * contact with source `missing_product_waitlist`, and notify the admin.
     * Returns a shape the agent reads back as the tool output so it can
     * explain to the visitor what happened (saved, needs email, invalid).
     *
     * @param string $email         Candidate email (may be empty).
     * @param string $product_query The product the visitor asked about.
     * @param string $reason        Internal agent justification (for log).
     * @return array Tool result shape.
     */
    private function save_missing_product_waitlist( $email, $product_query, $reason ) {
        $email         = trim( (string) $email );
        $product_query = substr( trim( (string) $product_query ), 0, 200 );

        if ( '' === $email ) {
            return array(
                'success'      => false,
                'needs_email'  => true,
                'message'      => 'No email is known yet. Ask the visitor politely, in their own language, for the email address where they\'d like the availability notification, and call this tool again with both product_query and email once they answer.',
                'product_query' => $product_query,
            );
        }

        if ( ! is_email( $email ) ) {
            return array(
                'success'     => false,
                'needs_email' => true,
                'message'     => 'The email provided is not valid. Ask the visitor to double-check and resend it, then call this tool again with the corrected value.',
                'product_query' => $product_query,
            );
        }

        if ( ! class_exists( 'Niwa_AI_Contact_Service' ) ) {
            return array(
                'success' => false,
                'message' => 'Contact service unavailable. Tell the visitor you couldn\'t save the request and suggest they try again shortly.',
            );
        }

        $contact_service = new Niwa_AI_Contact_Service();
        $saved = $contact_service->save_contact( $email, '', '', 'missing_product_waitlist' );
        if ( empty( $saved['success'] ) ) {
            return array(
                'success' => false,
                'message' => 'We could not save the request. Tell the visitor you\'ll try again and suggest they check back soon.',
            );
        }

        $this->send_missing_product_waitlist_notification( $email, $product_query, (string) $reason );

        return array(
            'success'       => true,
            'message'       => 'The visitor\'s email has been saved to the waitlist and the store admin has been notified. Produce a short (1-2 sentence) natural-language reply in the visitor\'s language confirming you\'ll notify them when the product is available. Do not repeat the email back. Do not call this tool again in this conversation.',
            'product_query' => $product_query,
        );
    }

    /**
     * Send a dedicated admin notification that carries the product query +
     * locale, since the generic Contact_Service notification doesn't.
     *
     * @param string $email         Visitor email (already validated).
     * @param string $product_query Product the visitor asked about.
     * @param string $reason        Internal justification (optional).
     * @return void
     */
    private function send_missing_product_waitlist_notification( $email, $product_query, $reason = '' ) {
        $lead_opts          = class_exists( 'Niwa_AI_Lead_Capture_Settings' ) ? Niwa_AI_Lead_Capture_Settings::get_settings() : array();
        $notification_email = isset( $lead_opts['lead_notification_email'] ) ? $lead_opts['lead_notification_email'] : '';

        if ( empty( $notification_email ) || ! is_email( $notification_email ) ) {
            return;
        }

        $locale        = function_exists( 'determine_locale' ) ? (string) determine_locale() : ( function_exists( 'get_locale' ) ? (string) get_locale() : '' );
        $query_display = '' !== $product_query ? $product_query : '(unknown product)';

        $subject = sprintf( '[Niwa AI] Missing product waitlist: %s', $query_display );
        $body    = sprintf(
            "A visitor asked about a product that is not currently in the catalog and left their email to be notified when it becomes available.\n\nProduct requested: %s\nVisitor email: %s\nSite: %s\nLocale: %s\nTime: %s\n\nAgent reason: %s\n\nOnce the product is live, reply to this email manually to let the visitor know.",
            $query_display,
            $email,
            home_url(),
            $locale,
            current_time( 'mysql' ),
            '' !== $reason ? $reason : '(none)'
        );

        wp_mail( $notification_email, $subject, $body );
    }

    private function get_history_context_limit() {
        $limit = self::DEFAULT_HISTORY_CONTEXT_LIMIT;

        if ( function_exists( 'apply_filters' ) ) {
            $limit = (int) apply_filters( 'niwa_ai_context_history_limit', $limit );
        }

        return max( 1, $limit );
    }

    private function sanitize_context_text( $text ) {
        $text = (string) $text;
        $text = preg_replace( '/```[\s\S]*?```/u', ' ', $text );
        $text = preg_replace( '/<script[\s\S]*?<\/script>/iu', ' ', $text );
        $text = preg_replace( '/\b(ignore\s+previous\s+instructions|system\s*prompt|developer\s*message|tool\s*call)\b/iu', ' ', $text );
        $text = wp_strip_all_tags( $text );
        $text = preg_replace( '/\s{2,}/', ' ', $text );
        return mb_substr( trim( $text ), 0, 2500 );
    }

    private function build_agent_mode_instructions_block( $max_chars = 2500 ) {
        $profile = $this->get_agent_mode_profile();
        if ( empty( $profile ) ) {
            return '';
        }

        $field_map = class_exists( 'Niwa_AI_Agent_Profile_Summary_Service' )
            ? Niwa_AI_Agent_Profile_Summary_Service::profile_field_map()
            : array();

        $parts = array();
        foreach ( $field_map as $key => $label ) {
            if ( empty( $profile[ $key ] ) || ! is_string( $profile[ $key ] ) ) {
                continue;
            }

            $text = Niwa_AI_Agent_Profile_Summary_Service::resolve_prompt_field_text( $key, $profile[ $key ] );
            if ( '' === $text ) {
                continue;
            }

            $parts[] = '[' . $label . "]\n" . $text . "\n[/" . $label . ']';
        }

        if ( empty( $parts ) ) {
            return '';
        }

        $max_chars = max( 300, intval( $max_chars ) );
        $prefix = "[AGENT_MODE]\nUse the following custom business instructions from admin as high-priority guidance when answering users.\n\n";
        $suffix = "\n[/AGENT_MODE]";
        $block = $prefix . implode( "\n\n", $parts ) . $suffix;
        if ( mb_strlen( $block ) <= $max_chars ) {
            return $block;
        }

        $included = array();
        foreach ( $parts as $part ) {
            $candidate_parts = array_merge( $included, array( $part ) );
            $candidate = $prefix . implode( "\n\n", $candidate_parts ) . $suffix;
            if ( mb_strlen( $candidate ) <= $max_chars ) {
                $included[] = $part;
            }
        }

        if ( empty( $included ) ) {
            return '';
        }

        return $prefix . implode( "\n\n", $included ) . $suffix;
    }

    private function get_agent_mode_profile() {
        $candidates = array( Niwa_AI_Constants::OPT_AGENT_PROFILE, Niwa_AI_Constants::LEGACY_OPT_AGENT_PROFILE );
        foreach ( $candidates as $option_key ) {
            $profile = get_option( $option_key, array() );
            if ( is_array( $profile ) && ! empty( $profile ) ) {
                return $profile;
            }
        }

        return array();
    }

    private function normalize_for_match( $text ) {
        $text = mb_strtolower( (string) $text );
        $map  = array(
            "\u{0161}" => 's', "\u{0111}" => 'dj', "\u{010D}" => 'c', "\u{0107}" => 'c', "\u{017E}" => 'z',
            "\u{0160}" => 's', "\u{0110}" => 'dj', "\u{010C}" => 'c', "\u{0106}" => 'c', "\u{017D}" => 'z',
        );
        $text = strtr( $text, $map );
        $text = preg_replace( '/[^\p{L}\p{N}\s]/u', ' ', $text );
        $text = preg_replace( '/\s{2,}/u', ' ', $text );
        return trim( $text );
    }

    private function rlm_debug( $step, $query = '', $extra = array() ) {
        unset( $step, $query, $extra );
    }

}
