<?php
/**
 * Project Synapse service.
 *
 * @package Noesia_Smart_Waiter
 */

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

if ( ! class_exists( 'Noesia_SWA_Project_Synapse_Service' ) ) {
	/**
	 * Service owner for Project Synapse.
	 */
	class Noesia_SWA_Project_Synapse_Service {

		const OPTION_OPENAI_API_KEY = 'project_synapse_openai_api_key';
		const OPTION_OPENAI_MODEL   = 'project_synapse_openai_model';
		const OPTION_INSTALLED_PACK = 'noesia_swa_project_synapse_installed_pack';

		const REMOTE_MANIFEST_URL = 'https://noesia.io/wp-content/uploads/noesia-smart-waiter/project-synapse/manifest.json';
		const MODULE_UPLOAD_DIR   = 'noesia-smart-waiter/project-synapse';
		const PACK_SLUG           = 'synapse-core-en';
		const PACK_FOLDER         = 'core-en';

		/**
		 * Get supported OpenAI models.
		 *
		 * @return array<string,string>
		 */
		public function get_supported_openai_models() {
			return array(
				'gpt-4.1-mini' => __( 'gpt-4.1-mini - recommended balance', 'noesia-smart-waiter' ),
				'gpt-4.1'      => __( 'gpt-4.1 - deeper reasoning', 'noesia-smart-waiter' ),
				'gpt-4o-mini'  => __( 'gpt-4o-mini - lower cost', 'noesia-smart-waiter' ),
				'gpt-4o'       => __( 'gpt-4o - multimodal ready', 'noesia-smart-waiter' ),
			);
		}

		/**
		 * Sanitize module-owned settings.
		 *
		 * @param array<string,mixed> $new_input Sanitized settings output.
		 * @param array<string,mixed> $input     Raw input.
		 * @param array<string,mixed> $existing  Existing settings.
		 * @return array<string,mixed>
		 */
		public function sanitize_options( $new_input, $input, $existing ) {
			if ( ! is_array( $new_input ) ) {
				$new_input = is_array( $existing ) ? $existing : array();
			}

			if ( ! is_array( $input ) ) {
				return $new_input;
			}

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

			if ( isset( $input[ self::OPTION_OPENAI_MODEL ] ) ) {
				$posted_model   = sanitize_text_field( wp_unslash( (string) $input[ self::OPTION_OPENAI_MODEL ] ) );
				$allowed_models = array_keys( $this->get_supported_openai_models() );
				$existing_model = isset( $existing[ self::OPTION_OPENAI_MODEL ] ) ? sanitize_text_field( (string) $existing[ self::OPTION_OPENAI_MODEL ] ) : 'gpt-4.1-mini';

				if ( in_array( $posted_model, $allowed_models, true ) ) {
					$new_input[ self::OPTION_OPENAI_MODEL ] = $posted_model;
				} elseif ( in_array( $existing_model, $allowed_models, true ) ) {
					$new_input[ self::OPTION_OPENAI_MODEL ] = $existing_model;
				} else {
					$new_input[ self::OPTION_OPENAI_MODEL ] = 'gpt-4.1-mini';
				}
			}

			if ( isset( $input[ self::OPTION_OPENAI_API_KEY ] ) ) {
				$api_key = sanitize_text_field( wp_unslash( (string) $input[ self::OPTION_OPENAI_API_KEY ] ) );

				if ( '' === $api_key && isset( $existing[ self::OPTION_OPENAI_API_KEY ] ) ) {
					$new_input[ self::OPTION_OPENAI_API_KEY ] = sanitize_text_field( (string) $existing[ self::OPTION_OPENAI_API_KEY ] );
				} else {
					$new_input[ self::OPTION_OPENAI_API_KEY ] = $api_key;
				}
			}

			return $new_input;
		}

		/**
		 * Get public payload for JavaScript.
		 *
		 * @return array<string,mixed>
		 */
		public function get_public_payload() {
			return array(
				'status'        => $this->get_pack_status(),
				'scenes'        => $this->get_scene_manifest(),
				'manifest_url'  => self::REMOTE_MANIFEST_URL,
				'openai_status' => $this->get_settings_summary(),
			);
		}

		/**
		 * Get settings summary.
		 *
		 * @return array<string,mixed>
		 */
		public function get_settings_summary() {
			$options = get_option( 'noesia_swa_options', array() );
			$options = is_array( $options ) ? $options : array();

			$api_key = isset( $options[ self::OPTION_OPENAI_API_KEY ] ) ? sanitize_text_field( (string) $options[ self::OPTION_OPENAI_API_KEY ] ) : '';
			$model   = isset( $options[ self::OPTION_OPENAI_MODEL ] ) ? sanitize_text_field( (string) $options[ self::OPTION_OPENAI_MODEL ] ) : 'gpt-4.1-mini';

			if ( ! isset( $this->get_supported_openai_models()[ $model ] ) ) {
				$model = 'gpt-4.1-mini';
			}

			return array(
				'has_openai_key' => '' !== $api_key,
				'openai_status'  => '' !== $api_key ? __( 'Configured', 'noesia-smart-waiter' ) : __( 'Not configured', 'noesia-smart-waiter' ),
				'openai_masked'  => $this->mask_api_key( $api_key ),
				'openai_model'   => $model,
				'models'         => $this->get_supported_openai_models(),
			);
		}

		/**
		 * Save Project Synapse settings from AJAX.
		 *
		 * @param string $api_key OpenAI API key. Blank keeps the saved key.
		 * @param string $model   OpenAI model.
		 * @return array<string,mixed>|WP_Error
		 */
		public function save_settings( $api_key, $model ) {
			$options = get_option( 'noesia_swa_options', array() );
			$options = is_array( $options ) ? $options : array();

			$api_key        = trim( sanitize_text_field( (string) $api_key ) );
			$model          = sanitize_text_field( (string) $model );
			$allowed_models = array_keys( $this->get_supported_openai_models() );

			if ( '' !== $api_key ) {
				if ( 0 !== strpos( $api_key, 'sk-' ) ) {
					return new WP_Error( 'noesia_synapse_invalid_api_key', __( 'The OpenAI API key should start with sk-. Check the key and try again.', 'noesia-smart-waiter' ) );
				}

				$options[ self::OPTION_OPENAI_API_KEY ] = $api_key;
			}

			$options[ self::OPTION_OPENAI_MODEL ] = in_array( $model, $allowed_models, true ) ? $model : 'gpt-4.1-mini';

			update_option( 'noesia_swa_options', $options, false );

			return $this->get_settings_summary();
		}

		/**
		 * Mask API key.
		 *
		 * @param string $api_key API key.
		 * @return string
		 */
		public function mask_api_key( $api_key ) {
			$api_key = trim( (string) $api_key );

			if ( '' === $api_key ) {
				return '';
			}

			if ( strlen( $api_key ) <= 12 ) {
				return str_repeat( '*', strlen( $api_key ) );
			}

			return substr( $api_key, 0, 6 ) . str_repeat( '*', max( 6, strlen( $api_key ) - 12 ) ) . substr( $api_key, -6 );
		}

		/**
		 * Get scene manifest.
		 *
		 * @return array<string,array<string,string>>
		 */
		public function get_scene_manifest() {
			return array(
				'ask-synapse'  => array(
					'label'       => __( 'Ask Synapse', 'noesia-smart-waiter' ),
					'mode'        => 'meaning-library',
					'image'       => 'aether-starfield.webp',
					'description' => __( 'General conversation across meaning, faith, philosophy, grief, and everyday life.', 'noesia-smart-waiter' ),
				),
				'christianity' => array(
					'label'       => __( 'Christianity', 'noesia-smart-waiter' ),
					'mode'        => 'meaning-library',
					'image'       => 'christianity-sanctuary-v2.webp',
					'description' => __( 'Source-grounded reflection from Christian texts.', 'noesia-smart-waiter' ),
				),
				'stoicism'     => array(
					'label'       => __( 'Stoicism', 'noesia-smart-waiter' ),
					'mode'        => 'meaning-library',
					'image'       => 'stoicism-sanctuary.webp',
					'description' => __( 'Calm, discipline, acceptance, and action.', 'noesia-smart-waiter' ),
				),
				'buddhism'     => array(
					'label'       => __( 'Buddhism', 'noesia-smart-waiter' ),
					'mode'        => 'meaning-library',
					'image'       => 'buddhism-sanctuary.webp',
					'description' => __( 'Suffering, compassion, attention, and release.', 'noesia-smart-waiter' ),
				),
				'islam'        => array(
					'label'       => __( 'Islam', 'noesia-smart-waiter' ),
					'mode'        => 'meaning-library',
					'image'       => 'islam-sanctuary.webp',
					'description' => __( 'Mercy, patience, prayer, and surrender.', 'noesia-smart-waiter' ),
				),
				'hinduism'     => array(
					'label'       => __( 'Hinduism', 'noesia-smart-waiter' ),
					'mode'        => 'meaning-library',
					'image'       => 'hinduism-sanctuary.webp',
					'description' => __( 'Duty, self-knowledge, devotion, and liberation.', 'noesia-smart-waiter' ),
				),
				'taoism'       => array(
					'label'       => __( 'Taoism', 'noesia-smart-waiter' ),
					'mode'        => 'meaning-library',
					'image'       => 'taoism-sanctuary.webp',
					'description' => __( 'Flow, simplicity, non-force, and balance.', 'noesia-smart-waiter' ),
				),
				'aether'       => array(
					'label'       => __( 'AETHER', 'noesia-smart-waiter' ),
					'mode'        => 'aether',
					'image'       => 'aether-starfield.webp',
					'description' => __( 'Write, release, and let the message dissolve.', 'noesia-smart-waiter' ),
				),
			);
		}

		/**
		 * Get pack status.
		 *
		 * @return array<string,mixed>
		 */
		public function get_pack_status() {
			$pack_file  = $this->get_pack_dir() . '/pack.json';
			$index_file = $this->get_index_file();
			$installed  = file_exists( $pack_file ) && file_exists( $index_file );
			$pack_meta  = $installed ? $this->read_json_file( $pack_file ) : array();

			return array(
				'installed'    => $installed,
				'pack_slug'    => self::PACK_SLUG,
				'pack_version' => isset( $pack_meta['version'] ) ? sanitize_text_field( (string) $pack_meta['version'] ) : '',
				'pack_title'   => isset( $pack_meta['name'] ) ? sanitize_text_field( (string) $pack_meta['name'] ) : __( 'Synapse Core EN', 'noesia-smart-waiter' ),
				'pack_dir'     => $this->get_pack_dir(),
				'pack_url'     => $this->get_pack_url(),
				'index_file'   => $index_file,
				'manifest_url' => self::REMOTE_MANIFEST_URL,
				'asset_urls'   => $this->get_sanctuary_asset_urls(),
				'installed_at' => $this->get_installed_at(),
				'chunk_count'  => $this->get_index_chunk_count(),
				'project_dir'  => $this->get_project_dir(),
				'project_url'  => $this->get_project_url(),
			);
		}

		/**
		 * Install knowledge pack from the remote manifest.
		 *
		 * @return array<string,mixed>|WP_Error
		 */
		public function install_pack() {
			$manifest = $this->fetch_remote_manifest();
			if ( is_wp_error( $manifest ) ) {
				return $manifest;
			}

			$pack = $this->resolve_manifest_pack( $manifest );
			if ( is_wp_error( $pack ) ) {
				return $pack;
			}

			$zip_url = $this->resolve_pack_zip_url( $pack );
			if ( '' === $zip_url ) {
				return new WP_Error( 'noesia_synapse_missing_zip', __( 'The Project Synapse manifest does not include a valid zip URL.', 'noesia-smart-waiter' ) );
			}

			require_once ABSPATH . 'wp-admin/includes/file.php';

			$project_dir = $this->get_project_dir();
			$parent_dir  = trailingslashit( $project_dir ) . 'packs';
			$target_dir  = $this->get_pack_dir();
			$temp_dir    = trailingslashit( $parent_dir ) . self::PACK_FOLDER . '-install-' . time();

			if ( ! wp_mkdir_p( $parent_dir ) || ! wp_mkdir_p( $temp_dir ) ) {
				return new WP_Error( 'noesia_synapse_dir_failed', __( 'Could not create the Project Synapse pack directory.', 'noesia-smart-waiter' ) );
			}

			$environment = $this->validate_install_environment( $parent_dir );
			if ( is_wp_error( $environment ) ) {
				$this->delete_dir( $temp_dir );
				return $environment;
			}

			$downloaded = $this->download_pack_zip( $zip_url );
			if ( is_wp_error( $downloaded ) ) {
				$this->delete_dir( $temp_dir );
				return $downloaded;
			}

			global $wp_filesystem;
			WP_Filesystem();

			$unzipped = unzip_file( $downloaded, $temp_dir );
			wp_delete_file( $downloaded );

			if ( is_wp_error( $unzipped ) ) {
				$this->delete_dir( $temp_dir );
				return $unzipped;
			}

			$pack_root = $this->resolve_unzipped_pack_root( $temp_dir );
			if ( '' === $pack_root ) {
				$this->delete_dir( $temp_dir );
				return new WP_Error( 'noesia_synapse_invalid_pack', __( 'The downloaded Project Synapse pack does not include pack.json.', 'noesia-smart-waiter' ) );
			}

			$this->delete_dir( $target_dir );

			if ( ! wp_mkdir_p( $target_dir ) ) {
				$this->delete_dir( $temp_dir );
				return new WP_Error( 'noesia_synapse_target_failed', __( 'Could not prepare the Project Synapse target directory.', 'noesia-smart-waiter' ) );
			}

			if ( ! $this->copy_dir_contents( $pack_root, $target_dir ) ) {
				$this->delete_dir( $temp_dir );
				return new WP_Error( 'noesia_synapse_copy_failed', __( 'Could not install the Project Synapse knowledge pack.', 'noesia-smart-waiter' ) );
			}

			$this->delete_dir( $temp_dir );
			$this->build_runtime_search_index();

			update_option(
				self::OPTION_INSTALLED_PACK,
				array(
					'slug'         => self::PACK_SLUG,
					'version'      => isset( $pack['version'] ) ? sanitize_text_field( (string) $pack['version'] ) : '',
					'installed_at' => current_time( 'mysql' ),
				),
				false
			);

			return $this->get_pack_status();
		}

		/**
		 * Fetch remote manifest.
		 *
		 * @return array<string,mixed>|WP_Error
		 */
		public function fetch_remote_manifest() {
			$response = wp_remote_get(
				self::REMOTE_MANIFEST_URL,
				array(
					'timeout' => 25,
				)
			);

			if ( is_wp_error( $response ) ) {
				return $response;
			}

			$code = (int) wp_remote_retrieve_response_code( $response );
			$body = (string) wp_remote_retrieve_body( $response );

			if ( 200 !== $code || '' === $body ) {
				$message = wp_remote_retrieve_response_message( $response );
				return new WP_Error(
					'noesia_synapse_manifest_failed',
					sprintf(
						/* translators: 1: HTTP code, 2: HTTP message, 3: manifest URL. */
						__( 'Could not load the Project Synapse manifest. HTTP %1$d %2$s. URL: %3$s', 'noesia-smart-waiter' ),
						$code,
						sanitize_text_field( (string) $message ),
						self::REMOTE_MANIFEST_URL
					)
				);
			}

			$manifest = json_decode( $body, true );
			if ( ! is_array( $manifest ) ) {
				return new WP_Error( 'noesia_synapse_manifest_invalid', __( 'The Project Synapse manifest is not valid JSON.', 'noesia-smart-waiter' ) );
			}

			return $manifest;
		}

		/**
		 * Answer a Synapse question.
		 *
		 * @param string                                 $question User question.
		 * @param string                                 $mode     Response mode.
		 * @param string                                 $scene    Selected scene.
		 * @param array<int,array<string,string>>|string $history Short conversation history.
		 * @return array<string,mixed>|WP_Error
		 */
		public function answer_question( $question, $mode, $scene = '', $history = array() ) {
			$question = trim( sanitize_textarea_field( (string) $question ) );
			$mode     = sanitize_key( (string) $mode );
			$scene    = sanitize_key( (string) $scene );
			$history  = $this->normalize_history( $history );

			if ( '' === $question ) {
				return new WP_Error( 'noesia_synapse_empty_question', __( 'Write a question first.', 'noesia-smart-waiter' ) );
			}

			if ( ! $this->is_pack_installed() ) {
				return new WP_Error( 'noesia_synapse_pack_missing', __( 'Install the Project Synapse knowledge pack first.', 'noesia-smart-waiter' ) );
			}

			$settings           = $this->get_settings_summary();
			$retrieval_question = $this->get_retrieval_question( $question, $history );
			$is_general_ai      = $this->is_general_ai_scene( $scene );
			$sources            = $is_general_ai ? array() : $this->search_sources( $retrieval_question, 8, $scene );
			$api_key            = $this->get_openai_api_key();

			if ( ! $is_general_ai && count( $sources ) < 3 && '' !== $api_key ) {
				$expanded_query = $this->expand_retrieval_query( $retrieval_question, $scene, $mode, $api_key );
				if ( '' !== $expanded_query ) {
					$sources = $this->search_sources( $retrieval_question . ' ' . $expanded_query, 8, $scene );
				}
			}

			if ( empty( $settings['has_openai_key'] ) ) {
				return array(
					'answer'           => __( 'Project Synapse is ready, but an OpenAI API key is required to generate the full answer on this WordPress site.', 'noesia-smart-waiter' ),
					'requires_api_key' => true,
					'mode'             => $mode,
					'scene'            => $scene,
				);
			}

			$model = isset( $settings['openai_model'] ) ? sanitize_text_field( (string) $settings['openai_model'] ) : 'gpt-4.1-mini';

			$body = array(
				'model'       => $model,
				'messages'    => $this->build_openai_messages( $question, $mode, $scene, $sources, $history ),
				'temperature' => $is_general_ai ? 0.55 : 0.25,
				'max_tokens'  => 900,
			);

			$response = wp_remote_post(
				'https://api.openai.com/v1/chat/completions',
				array(
					'timeout' => 45,
					'headers' => array(
						'Authorization' => 'Bearer ' . $api_key,
						'Content-Type'  => 'application/json',
					),
					'body'    => wp_json_encode( $body ),
				)
			);

			if ( is_wp_error( $response ) ) {
				return $response;
			}

			$code          = (int) wp_remote_retrieve_response_code( $response );
			$response_body = json_decode( (string) wp_remote_retrieve_body( $response ), true );

			if ( 200 !== $code || ! is_array( $response_body ) ) {
				$message = isset( $response_body['error']['message'] ) ? sanitize_text_field( (string) $response_body['error']['message'] ) : __( 'OpenAI request failed.', 'noesia-smart-waiter' );
				if ( 401 === $code ) {
					$message = __( 'OpenAI rejected the API key. Check that the key is correct and billing is active.', 'noesia-smart-waiter' );
				} elseif ( 429 === $code ) {
					$message = __( 'OpenAI rate limit or billing limit reached. Check the OpenAI account usage and billing.', 'noesia-smart-waiter' );
				} else {
					$message = sprintf(
						/* translators: 1: HTTP code, 2: error message. */
						__( 'OpenAI request failed. HTTP %1$d: %2$s', 'noesia-smart-waiter' ),
						$code,
						$message
					);
				}
				return new WP_Error( 'noesia_synapse_openai_failed', $message );
			}

			$answer = isset( $response_body['choices'][0]['message']['content'] ) ? sanitize_textarea_field( (string) $response_body['choices'][0]['message']['content'] ) : '';

			if ( '' === $answer ) {
				return new WP_Error( 'noesia_synapse_empty_answer', __( 'Project Synapse did not return an answer.', 'noesia-smart-waiter' ) );
			}

			return array(
				'answer' => $answer,
				'model'  => $model,
				'mode'   => $mode,
				'scene'  => $scene,
			);
		}

		/**
		 * Search local source index.
		 *
		 * @param string $query Query.
		 * @param int    $limit Max results.
		 * @param string $scene Selected scene/category.
		 * @return array<int,array<string,mixed>>
		 */
		public function search_sources( $query, $limit = 8, $scene = '' ) {
			$index                = $this->read_search_index();
			$limit                = max( 1, min( 12, absint( $limit ) ) );
			$preferred_categories = $this->get_preferred_source_categories( $scene );

			if ( empty( $index['chunks'] ) || ! is_array( $index['chunks'] ) ) {
				return array();
			}

			$tokens = $this->tokenize( $query, 24 );
			if ( empty( $tokens ) ) {
				$tokens = $this->tokenize( 'meaning grief help faith peace' );
			}

			$scored = array();
			$phrase = $this->lower( preg_replace( '/\s+/u', ' ', trim( (string) $query ) ) );

			foreach ( $index['chunks'] as $chunk ) {
				if ( ! is_array( $chunk ) ) {
					continue;
				}

				$search   = isset( $chunk['search'] ) ? (string) $chunk['search'] : '';
				$text     = isset( $chunk['excerpt'] ) ? (string) $chunk['excerpt'] : ( isset( $chunk['text'] ) ? (string) $chunk['text'] : '' );
				$source   = isset( $chunk['source'] ) ? (string) $chunk['source'] : '';
				$category = isset( $chunk['category'] ) ? (string) $chunk['category'] : '';
				$title    = isset( $chunk['title'] ) ? (string) $chunk['title'] : $this->source_to_title( $source, $category );

				if ( '' === $text ) {
					continue;
				}

				if ( $this->is_boilerplate_source_text( $text ) ) {
					continue;
				}

				$title_l    = $this->lower( $title );
				$category_l = $this->lower( $category );
				$source_l   = $this->lower( $source );
				$haystack   = '' !== $search ? $this->lower( $search ) : $this->lower( $title . ' ' . $category . ' ' . $source . ' ' . $text );
				$score      = 0;
				$matched    = false;

				if ( ! empty( $preferred_categories ) && ! in_array( $category_l, $preferred_categories, true ) ) {
					continue;
				}

				if ( strlen( $phrase ) > 9 && false !== strpos( $haystack, $phrase ) ) {
					$score  += 28;
					$matched = true;
				}

				foreach ( $tokens as $token ) {
					$hits = substr_count( $haystack, $token );
					if ( $hits > 0 ) {
						$score  += min( 10, $hits * 2 );
						$matched = true;
					}

					if ( false !== strpos( $title_l, $token ) ) {
						$score  += 12;
						$matched = true;
					}

					if ( false !== strpos( $category_l, $token ) ) {
						$score  += 10;
						$matched = true;
					}

					if ( false !== strpos( $source_l, $token ) ) {
						$score  += 4;
						$matched = true;
					}
				}

				if ( ! $matched ) {
					continue;
				}

				if ( ! empty( $preferred_categories ) && in_array( $category_l, $preferred_categories, true ) ) {
					$score += 24;
				}

				$scored[] = array(
					'score'    => $score,
					'id'       => isset( $chunk['id'] ) ? sanitize_text_field( (string) $chunk['id'] ) : '',
					'title'    => $title,
					'category' => sanitize_text_field( $category ),
					'source'   => sanitize_text_field( $source ),
					'excerpt'  => $this->make_excerpt( $text, 520 ),
				);
			}

			usort(
				$scored,
				static function ( $a, $b ) {
					return (int) $b['score'] <=> (int) $a['score'];
				}
			);

			return array_slice( $scored, 0, $limit );
		}

		/**
		 * Restrict retrieval to the selected tradition when a tradition tab is active.
		 *
		 * @param string $scene Selected scene.
		 * @return array<int,string>
		 */
		private function get_preferred_source_categories( $scene ) {
			$scene = sanitize_key( (string) $scene );

			$map = array(
				'christianity' => array( 'christianity' ),
				'stoicism'     => array( 'stoicism' ),
				'buddhism'     => array( 'buddhism' ),
				'islam'        => array( 'islam' ),
				'hinduism'     => array( 'hinduism' ),
				'taoism'       => array( 'taoism' ),
				'aether'       => array( 'grief' ),
			);

			return isset( $map[ $scene ] ) ? $map[ $scene ] : array();
		}

		/**
		 * Detect the free general AI scene.
		 *
		 * @param string $scene Scene key.
		 * @return bool
		 */
		private function is_general_ai_scene( $scene ) {
			return 'ask-synapse' === sanitize_key( (string) $scene );
		}

		/**
		 * Get project upload directory.
		 *
		 * @return string
		 */
		public function get_project_dir() {
			$upload = wp_upload_dir();
			$base   = isset( $upload['basedir'] ) ? $upload['basedir'] : WP_CONTENT_DIR . '/uploads';

			return trailingslashit( $base ) . self::MODULE_UPLOAD_DIR;
		}

		/**
		 * Get project upload URL.
		 *
		 * @return string
		 */
		public function get_project_url() {
			$upload = wp_upload_dir();
			$base   = isset( $upload['baseurl'] ) ? $upload['baseurl'] : content_url( 'uploads' );

			return trailingslashit( $base ) . self::MODULE_UPLOAD_DIR;
		}

		/**
		 * Get installed pack directory.
		 *
		 * @return string
		 */
		public function get_pack_dir() {
			return trailingslashit( $this->get_project_dir() ) . 'packs/' . self::PACK_FOLDER;
		}

		/**
		 * Get installed pack URL.
		 *
		 * @return string
		 */
		public function get_pack_url() {
			return trailingslashit( $this->get_project_url() ) . 'packs/' . self::PACK_FOLDER;
		}

		/**
		 * Get index file path.
		 *
		 * @return string
		 */
		public function get_index_file() {
			return $this->get_pack_dir() . '/index/synapse-local-index.json';
		}

		/**
		 * Get optimized runtime index file path.
		 *
		 * @return string
		 */
		public function get_runtime_index_file() {
			return $this->get_pack_dir() . '/index/synapse-runtime-index.json';
		}

		/**
		 * Whether pack is installed.
		 *
		 * @return bool
		 */
		public function is_pack_installed() {
			return file_exists( $this->get_pack_dir() . '/pack.json' ) && file_exists( $this->get_index_file() );
		}

		/**
		 * Get sanctuary asset URLs.
		 *
		 * @return array<string,string>
		 */
		public function get_sanctuary_asset_urls() {
			$urls = array();

			foreach ( $this->get_scene_manifest() as $key => $scene ) {
				$image = isset( $scene['image'] ) ? sanitize_file_name( (string) $scene['image'] ) : '';
				if ( '' === $image ) {
					continue;
				}

				$path = $this->get_pack_dir() . '/assets/sanctuary/' . $image;
				if ( file_exists( $path ) ) {
					$urls[ $key ] = trailingslashit( $this->get_pack_url() ) . 'assets/sanctuary/' . rawurlencode( $image );
				}
			}

			return $urls;
		}

		/**
		 * Get installed at.
		 *
		 * @return string
		 */
		private function get_installed_at() {
			$installed = get_option( self::OPTION_INSTALLED_PACK, array() );
			if ( is_array( $installed ) && isset( $installed['installed_at'] ) ) {
				return sanitize_text_field( (string) $installed['installed_at'] );
			}

			return '';
		}

		/**
		 * Get index chunk count.
		 *
		 * @return int
		 */
		private function get_index_chunk_count() {
			$index = $this->read_json_file( $this->get_runtime_index_file() );
			if ( empty( $index ) ) {
				$index = $this->read_json_file( $this->get_index_file() );
			}

			if ( isset( $index['chunkCount'] ) ) {
				return absint( $index['chunkCount'] );
			}

			return isset( $index['chunks'] ) && is_array( $index['chunks'] ) ? count( $index['chunks'] ) : 0;
		}

		/**
		 * Resolve manifest pack.
		 *
		 * @param array<string,mixed> $manifest Manifest.
		 * @return array<string,mixed>|WP_Error
		 */
		private function resolve_manifest_pack( $manifest ) {
			if ( isset( $manifest['packs'] ) && is_array( $manifest['packs'] ) ) {
				foreach ( $manifest['packs'] as $pack ) {
					if ( ! is_array( $pack ) ) {
						continue;
					}

					$pack_id = '';
					if ( isset( $pack['slug'] ) ) {
						$pack_id = (string) $pack['slug'];
					} elseif ( isset( $pack['id'] ) ) {
						$pack_id = (string) $pack['id'];
					}

					if ( self::PACK_SLUG === $pack_id ) {
						return $pack;
					}
				}
			}

			if ( isset( $manifest['slug'] ) && self::PACK_SLUG === (string) $manifest['slug'] ) {
				return $manifest;
			}

			return new WP_Error( 'noesia_synapse_pack_missing_manifest', __( 'The manifest does not include the Synapse Core pack.', 'noesia-smart-waiter' ) );
		}

		/**
		 * Resolve pack zip URL from manifest pack data.
		 *
		 * @param array<string,mixed> $pack Pack data.
		 * @return string
		 */
		private function resolve_pack_zip_url( $pack ) {
			if ( isset( $pack['zip_url'] ) ) {
				return esc_url_raw( (string) $pack['zip_url'] );
			}

			if ( isset( $pack['download_url'] ) ) {
				return esc_url_raw( (string) $pack['download_url'] );
			}

			if ( isset( $pack['url'] ) ) {
				return esc_url_raw( (string) $pack['url'] );
			}

			if ( isset( $pack['zip'] ) ) {
				$zip = (string) $pack['zip'];
				if ( preg_match( '#^https?://#i', $zip ) ) {
					return esc_url_raw( $zip );
				}

				return esc_url_raw( trailingslashit( dirname( self::REMOTE_MANIFEST_URL ) ) . ltrim( $zip, '/' ) );
			}

			return '';
		}

		/**
		 * Validate the local install environment before touching the pack.
		 *
		 * @param string $parent_dir Parent install directory.
		 * @return true|WP_Error
		 */
		private function validate_install_environment( $parent_dir ) {
			$upload = wp_upload_dir();

			if ( ! empty( $upload['error'] ) ) {
				return new WP_Error(
					'noesia_synapse_uploads_error',
					sprintf(
						/* translators: %s: Upload error. */
						__( 'WordPress uploads are not available: %s', 'noesia-smart-waiter' ),
						sanitize_text_field( (string) $upload['error'] )
					)
				);
			}

			if ( ! is_dir( $parent_dir ) || ! wp_is_writable( $parent_dir ) ) {
				return new WP_Error(
					'noesia_synapse_uploads_unwritable',
					sprintf(
						/* translators: %s: Directory path. */
						__( 'Project Synapse cannot write to this uploads directory: %s', 'noesia-smart-waiter' ),
						$parent_dir
					)
				);
			}

			if ( ! class_exists( 'ZipArchive' ) && ! file_exists( ABSPATH . 'wp-admin/includes/class-pclzip.php' ) ) {
				return new WP_Error( 'noesia_synapse_zip_missing', __( 'This WordPress server does not have ZipArchive or PclZip available, so Project Synapse cannot unzip the knowledge pack.', 'noesia-smart-waiter' ) );
			}

			return true;
		}

		/**
		 * Download the pack zip with a fallback request.
		 *
		 * Some hosts/CDNs block WordPress default download requests with HTTP 403.
		 * The fallback uses a normal browser-like User-Agent and returns a clearer
		 * error that includes the exact URL the site owner must make public.
		 *
		 * @param string $zip_url Zip URL.
		 * @return string|WP_Error Local temp file path on success.
		 */
		private function download_pack_zip( $zip_url ) {
			$zip_url = esc_url_raw( (string) $zip_url );

			if ( '' === $zip_url ) {
				return new WP_Error( 'noesia_synapse_zip_url_missing', __( 'The Project Synapse zip URL is missing.', 'noesia-smart-waiter' ) );
			}

			$downloaded = download_url( $zip_url, 90 );

			if ( ! is_wp_error( $downloaded ) ) {
				return $downloaded;
			}

			$first_error = $downloaded->get_error_message();
			$temp_file   = wp_tempnam( $zip_url );

			if ( ! $temp_file ) {
				return new WP_Error(
					'noesia_synapse_zip_temp_failed',
					sprintf(
						/* translators: 1: URL, 2: original error. */
						__( 'Could not create a temporary file for Project Synapse download. URL: %1$s. Original error: %2$s', 'noesia-smart-waiter' ),
						$zip_url,
						$first_error
					)
				);
			}

			$response = wp_remote_get(
				$zip_url,
				array(
					'timeout'     => 90,
					'redirection' => 5,
					'stream'      => true,
					'filename'    => $temp_file,
					'headers'     => array(
						'Accept'     => 'application/zip, application/octet-stream, */*',
						'User-Agent' => 'Mozilla/5.0 (compatible; Noesia Matrix OS Project Synapse Installer)',
					),
				)
			);

			if ( is_wp_error( $response ) ) {
				wp_delete_file( $temp_file );
				return new WP_Error(
					'noesia_synapse_zip_download_failed',
					sprintf(
						/* translators: 1: URL, 2: original error, 3: fallback error. */
						__( 'Could not download the Project Synapse zip. URL: %1$s. Original error: %2$s. Fallback error: %3$s', 'noesia-smart-waiter' ),
						$zip_url,
						$first_error,
						$response->get_error_message()
					)
				);
			}

			$code    = (int) wp_remote_retrieve_response_code( $response );
			$message = sanitize_text_field( (string) wp_remote_retrieve_response_message( $response ) );

			if ( 200 !== $code ) {
				wp_delete_file( $temp_file );
				return new WP_Error(
					'noesia_synapse_zip_http_failed',
					sprintf(
						/* translators: 1: HTTP code, 2: HTTP message, 3: URL, 4: original error. */
						__( 'Project Synapse zip download is blocked. HTTP %1$d %2$s. URL: %3$s. Make this file public and accessible without login, hotlink protection, or security blocking. Original error: %4$s', 'noesia-smart-waiter' ),
						$code,
						$message,
						$zip_url,
						$first_error
					)
				);
			}

			if ( ! file_exists( $temp_file ) || 1 > filesize( $temp_file ) ) {
				wp_delete_file( $temp_file );
				return new WP_Error(
					'noesia_synapse_zip_empty',
					sprintf(
						/* translators: %s: URL. */
						__( 'The Project Synapse zip downloaded as an empty file. URL: %s', 'noesia-smart-waiter' ),
						$zip_url
					)
				);
			}

			return $temp_file;
		}

		/**
		 * Resolve unzipped pack root.
		 *
		 * @param string $temp_dir Temp dir.
		 * @return string
		 */
		private function resolve_unzipped_pack_root( $temp_dir ) {
			$temp_dir = untrailingslashit( (string) $temp_dir );

			if ( file_exists( $temp_dir . '/pack.json' ) ) {
				return $temp_dir;
			}

			$children = glob( trailingslashit( $temp_dir ) . '*', GLOB_ONLYDIR );
			if ( ! is_array( $children ) ) {
				return '';
			}

			foreach ( $children as $child ) {
				if ( file_exists( trailingslashit( $child ) . 'pack.json' ) ) {
					return untrailingslashit( $child );
				}
			}

			return '';
		}

		/**
		 * Copy directory contents.
		 *
		 * @param string $source Source.
		 * @param string $target Target.
		 * @return bool
		 */
		private function copy_dir_contents( $source, $target ) {
			$source = untrailingslashit( (string) $source );
			$target = untrailingslashit( (string) $target );

			if ( ! is_dir( $source ) || ! wp_mkdir_p( $target ) ) {
				return false;
			}

			$iterator = new RecursiveIteratorIterator(
				new RecursiveDirectoryIterator( $source, FilesystemIterator::SKIP_DOTS ),
				RecursiveIteratorIterator::SELF_FIRST
			);

			foreach ( $iterator as $item ) {
				$relative = substr( $item->getPathname(), strlen( $source ) + 1 );
				$dest     = $target . '/' . $relative;

				if ( $item->isDir() ) {
					if ( ! wp_mkdir_p( $dest ) ) {
						return false;
					}
				} elseif ( ! copy( $item->getPathname(), $dest ) ) {
					return false;
				}
			}

			return true;
		}

		/**
		 * Delete directory, scoped to this module upload directory.
		 *
		 * @param string $dir Directory.
		 * @return bool
		 */
		private function delete_dir( $dir ) {
			$dir = untrailingslashit( (string) $dir );
			if ( '' === $dir || ! is_dir( $dir ) ) {
				return true;
			}

			$base = trailingslashit( wp_normalize_path( $this->get_project_dir() ) );
			$path = trailingslashit( wp_normalize_path( $dir ) );

			if ( 0 !== strpos( $path, $base ) ) {
				return false;
			}

			$filesystem = $this->get_filesystem();
			if ( ! $filesystem ) {
				return false;
			}

			$iterator = new RecursiveIteratorIterator(
				new RecursiveDirectoryIterator( $dir, FilesystemIterator::SKIP_DOTS ),
				RecursiveIteratorIterator::CHILD_FIRST
			);

			foreach ( $iterator as $item ) {
				if ( $item->isDir() ) {
					$filesystem->rmdir( $item->getPathname() );
				} else {
					wp_delete_file( $item->getPathname() );
				}
			}

			return $filesystem->rmdir( $dir );
		}

		/**
		 * Read optimized runtime index, building it when needed.
		 *
		 * @return array<string,mixed>
		 */
		private function read_search_index() {
			$runtime_file = $this->get_runtime_index_file();

			if ( file_exists( $runtime_file ) && is_readable( $runtime_file ) ) {
				$runtime = $this->read_json_file( $runtime_file );
				if ( ! empty( $runtime['chunks'] ) && is_array( $runtime['chunks'] ) ) {
					return $runtime;
				}
			}

			$built = $this->build_runtime_search_index();
			if ( ! is_wp_error( $built ) && file_exists( $runtime_file ) && is_readable( $runtime_file ) ) {
				$runtime = $this->read_json_file( $runtime_file );
				if ( ! empty( $runtime['chunks'] ) && is_array( $runtime['chunks'] ) ) {
					return $runtime;
				}
			}

			return $this->read_json_file( $this->get_index_file() );
		}

		/**
		 * Build a smaller runtime index for retrieval.
		 *
		 * The full source index can be large in memory. This runtime file keeps
		 * searchable terms, metadata, and excerpts so normal requests do not need
		 * to keep every full source chunk in PHP memory.
		 *
		 * @return bool|WP_Error
		 */
		private function build_runtime_search_index() {
			$source_file = $this->get_index_file();

			if ( ! file_exists( $source_file ) || ! is_readable( $source_file ) ) {
				return new WP_Error( 'noesia_synapse_index_missing', __( 'The Project Synapse source index is missing.', 'noesia-smart-waiter' ) );
			}

			$source = $this->read_json_file( $source_file );
			if ( empty( $source['chunks'] ) || ! is_array( $source['chunks'] ) ) {
				return new WP_Error( 'noesia_synapse_index_invalid', __( 'The Project Synapse source index has no chunks.', 'noesia-smart-waiter' ) );
			}

			$chunks = array();

			foreach ( $source['chunks'] as $chunk ) {
				if ( ! is_array( $chunk ) ) {
					continue;
				}

				$text     = isset( $chunk['text'] ) ? wp_strip_all_tags( (string) $chunk['text'] ) : '';
				$text     = preg_replace( '/\s+/u', ' ', $text );
				$text     = is_string( $text ) ? trim( $text ) : '';
				$source_p = isset( $chunk['source'] ) ? sanitize_text_field( (string) $chunk['source'] ) : '';
				$category = isset( $chunk['category'] ) ? sanitize_text_field( (string) $chunk['category'] ) : '';
				$title    = $this->source_to_title( $source_p, $category );

				if ( '' === $text ) {
					continue;
				}

				if ( $this->is_boilerplate_source_text( $text ) ) {
					continue;
				}

				$tokens = $this->tokenize( $title . ' ' . $category . ' ' . $source_p . ' ' . $text, 180 );

				$chunks[] = array(
					'id'       => isset( $chunk['id'] ) ? sanitize_text_field( (string) $chunk['id'] ) : '',
					'title'    => $title,
					'category' => $category,
					'source'   => $source_p,
					'excerpt'  => $this->make_excerpt( $text, 680 ),
					'search'   => implode( ' ', $tokens ),
				);
			}

			$runtime = array(
				'generated_at' => current_time( 'mysql' ),
				'chunkCount'   => count( $chunks ),
				'chunks'       => $chunks,
			);

			$runtime_file = $this->get_runtime_index_file();
			$runtime_dir  = dirname( $runtime_file );

			if ( ! wp_mkdir_p( $runtime_dir ) || ! wp_is_writable( $runtime_dir ) ) {
				return new WP_Error( 'noesia_synapse_runtime_unwritable', __( 'Project Synapse cannot write the optimized runtime index.', 'noesia-smart-waiter' ) );
			}

			$filesystem = $this->get_filesystem();
			if ( ! $filesystem ) {
				return new WP_Error( 'noesia_synapse_runtime_filesystem', __( 'Project Synapse could not initialize the WordPress filesystem.', 'noesia-smart-waiter' ) );
			}

			$written = $filesystem->put_contents( $runtime_file, wp_json_encode( $runtime ), FS_CHMOD_FILE );

			if ( false === $written ) {
				return new WP_Error( 'noesia_synapse_runtime_failed', __( 'Project Synapse could not save the optimized runtime index.', 'noesia-smart-waiter' ) );
			}

			return true;
		}

		/**
		 * Read JSON file.
		 *
		 * @param string $file File path.
		 * @return array<string,mixed>
		 */
		private function read_json_file( $file ) {
			if ( '' === $file || ! file_exists( $file ) || ! is_readable( $file ) ) {
				return array();
			}

			$data = wp_json_file_decode( $file, array( 'associative' => true ) );

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

		/**
		 * Get the WordPress filesystem instance.
		 *
		 * @return WP_Filesystem_Base|null
		 */
		private function get_filesystem() {
			global $wp_filesystem;

			if ( ! class_exists( 'WP_Filesystem_Base' ) ) {
				require_once ABSPATH . 'wp-admin/includes/file.php';
			}

			if ( ! $wp_filesystem instanceof WP_Filesystem_Base ) {
				WP_Filesystem();
			}

			return $wp_filesystem instanceof WP_Filesystem_Base ? $wp_filesystem : null;
		}

		/**
		 * Get OpenAI API key.
		 *
		 * @return string
		 */
		private function get_openai_api_key() {
			$options = get_option( 'noesia_swa_options', array() );
			$options = is_array( $options ) ? $options : array();

			return isset( $options[ self::OPTION_OPENAI_API_KEY ] ) ? trim( sanitize_text_field( (string) $options[ self::OPTION_OPENAI_API_KEY ] ) ) : '';
		}

		/**
		 * Build system prompt.
		 *
		 * @param string $scene Scene key.
		 * @return string
		 */
		private function get_system_prompt( $scene = '' ) {
			if ( $this->is_general_ai_scene( $scene ) ) {
				return 'You are Ask Synapse, the general AI mind inside Project Synapse and Noesia Matrix OS. Answer freely, clearly, and intelligently in the user language. You are not locked to one religion, philosophy, or local knowledge pack. Help with practical questions, ideas, explanations, writing, strategy, technology, personal reflection, and everyday life. Be direct, useful, and human. Do not pretend to be God, a prophet, a dead person, or a religious authority. If the user asks for a source-grounded answer from Christianity, Islam, Buddhism, Hinduism, Taoism, or Stoicism, answer generally if you can and mention that the dedicated tab can give a stricter source-based answer. Use conversation history as real memory for follow-ups.';
			}

			$prompt_file = $this->get_pack_dir() . '/docs/system-rules.md';
			$prompt      = '';

			if ( file_exists( $prompt_file ) && is_readable( $prompt_file ) ) {
				$filesystem = $this->get_filesystem();
				$prompt     = $filesystem ? (string) $filesystem->get_contents( $prompt_file ) : '';
				$prompt     = substr( $prompt, 0, 20000 );
			}

			if ( '' === trim( $prompt ) ) {
				$prompt = 'You are Project Synapse, the human layer of Noesia Matrix OS. Answer in the user language. Use the provided sources first. Never impersonate God, Christ, a prophet, a religious authority, or a dead person. For religion, answer the user question directly from the selected tradition/source first, then add a short interpretation only if useful. Do not turn theological or historical questions into personal therapy. For questions like "what does Christianity say about suicide/adultery/death", explain the tradition and cite/source-label the relevant material before any practical note. If the user expresses immediate intent to self-harm, harm someone, or is in direct danger, include urgent safety steps and emergency/trusted-human support. For grief, do not claim that the dead can hear or answer. AETHER is symbolic release, not contact with the dead. Use conversation history to remember the previous question when the user says yes, continue, explain more, or asks a follow-up.';
			}

			$prompt .= "\n\nRuntime response rules:\n";
			$prompt .= "- Answer in the user's language.\n";
			$prompt .= "- Stay inside the selected scene/tradition unless the user explicitly asks for comparison.\n";
			$prompt .= "- The retrieved local sources are the first authority. Do not invent verses, citations, names, or doctrines.\n";
			$prompt .= "- If the user asks what a religion, philosophy, or text says, answer that source question directly before personal advice.\n";
			$prompt .= "- Do not begin or end source questions with therapy language, diagnostic questions, hotlines, generic support scripts, or advice to seek a specialist.\n";
			$prompt .= "- For Christianity questions about Christ, the Gospel, sin, death, adultery, suicide, mercy, or judgment: answer with Bible/Gospel framing first, then clearly separate later interpretation from direct text.\n";
			$prompt .= "- For suicide/self-harm: if the question is informational, historical, or theological, answer the selected tradition first and do not add a generic support paragraph. If the user expresses current intent, a plan, or immediate danger, then give urgent safety steps and tell them to contact local emergency services or a trusted human now.\n";
			$prompt .= "- Use the conversation history as real memory. If the user says yes, continue, explain that, tell me more, or gives a short follow-up, continue the immediately previous topic.\n";
			$prompt .= '- AETHER is a symbolic release ritual. Do not analyze unless asked. Never claim the dead receive, hear, or answer messages.';

			return $prompt;
		}

		/**
		 * Build OpenAI messages with real conversation memory.
		 *
		 * @param string                          $question Question.
		 * @param string                          $mode     Mode.
		 * @param string                          $scene    Scene.
		 * @param array<int,array>                $sources  Sources.
		 * @param array<int,array<string,string>> $history Conversation history.
		 * @return array<int,array<string,string>>
		 */
		private function build_openai_messages( $question, $mode, $scene, $sources, $history = array() ) {
			$messages = array(
				array(
					'role'    => 'system',
					'content' => $this->get_system_prompt( $scene ),
				),
				array(
					'role'    => 'user',
					'content' => $this->build_context_prompt( $mode, $scene, $sources ),
				),
			);

			foreach ( $history as $item ) {
				if ( empty( $item['question'] ) || empty( $item['answer'] ) ) {
					continue;
				}

				$messages[] = array(
					'role'    => 'user',
					'content' => (string) $item['question'],
				);
				$messages[] = array(
					'role'    => 'assistant',
					'content' => (string) $item['answer'],
				);
			}

			$messages[] = array(
				'role'    => 'user',
				'content' => $this->build_current_question_prompt( $question, $mode, $scene ),
			);

			return $messages;
		}

		/**
		 * Build source context prompt.
		 *
		 * @param string           $mode    Mode.
		 * @param string           $scene   Scene.
		 * @param array<int,array> $sources Sources.
		 * @return string
		 */
		private function build_context_prompt( $mode, $scene, $sources ) {
			$source_lines = array();
			$scene_label  = $this->get_scene_label( $scene );

			if ( $this->is_general_ai_scene( $scene ) ) {
				return "Project Synapse context\nMode: {$mode}\nSelected scene: {$scene_label} ({$scene})\n\nThis is the general Ask Synapse mode. Do not force retrieved sources or tradition rules. Answer as a capable general AI assistant with a calm, premium, human tone.";
			}

			foreach ( $sources as $index => $source ) {
				$source_lines[] = sprintf(
					"[%d] %s / %s\n%s",
					$index + 1,
					isset( $source['category'] ) ? (string) $source['category'] : '',
					isset( $source['source'] ) ? (string) $source['source'] : '',
					isset( $source['excerpt'] ) ? (string) $source['excerpt'] : ''
				);
			}

			return "Project Synapse context\nMode: {$mode}\nSelected scene/tradition: {$scene_label} ({$scene})\n\nRetrieved local sources from this selected scene/tradition:\n" . implode( "\n\n", $source_lines ) . "\n\nUse these sources quietly. Do not dump source chunks in the UI. The final answer should be polished, direct, and human.";
		}

		/**
		 * Build current question prompt.
		 *
		 * @param string $question Question.
		 * @param string $mode     Mode.
		 * @param string $scene    Scene.
		 * @return string
		 */
		private function build_current_question_prompt( $question, $mode, $scene ) {
			$scene_label = $this->get_scene_label( $scene );

			if ( $this->is_general_ai_scene( $scene ) ) {
				return "Current user question:\n{$question}\n\nSelected scene: {$scene_label} ({$scene})\nMode: {$mode}\n\nAnswer in the user language. Give a useful, direct answer. Do not force Bible/Quran/Stoic/Buddhist/Hindu/Taoist sources unless the user asks for that. If the user asks a broad question, answer broadly. If this is a short follow-up, continue the previous topic from the conversation history.";
			}

			$is_source_question  = $this->is_source_question( $question, $scene ) ? 'yes' : 'no';
			$is_immediate_danger = $this->is_immediate_danger_question( $question ) ? 'yes' : 'no';

			return "Current user question:\n{$question}\n\nSelected scene/tradition: {$scene_label} ({$scene})\nMode: {$mode}\nSource-question classification: {$is_source_question}\nImmediate-danger classification: {$is_immediate_danger}\n\nAnswer clearly and directly in the user language. If Source-question classification is yes, answer only what the selected tradition/source says first; do not add generic mental-health/support/specialist paragraphs unless Immediate-danger classification is yes. Do not ask personal follow-up questions before answering. Do not switch to another religion or philosophy unless the user explicitly asks for comparison. If this is a short follow-up, continue the previous topic from the conversation history.";
		}

		/**
		 * Normalize posted conversation history.
		 *
		 * @param array<int,array<string,string>>|string $history History payload.
		 * @return array<int,array<string,string>>
		 */
		private function normalize_history( $history ) {
			if ( is_string( $history ) ) {
				$decoded = json_decode( $history, true );
				$history = is_array( $decoded ) ? $decoded : array();
			}

			if ( ! is_array( $history ) ) {
				return array();
			}

			$normalized = array();

			foreach ( array_slice( $history, -6 ) as $item ) {
				if ( ! is_array( $item ) ) {
					continue;
				}

				$question = isset( $item['question'] ) ? trim( sanitize_textarea_field( (string) $item['question'] ) ) : '';
				$answer   = isset( $item['answer'] ) ? trim( sanitize_textarea_field( (string) $item['answer'] ) ) : '';

				if ( '' === $question || '' === $answer ) {
					continue;
				}

				$normalized[] = array(
					'question' => substr( $question, 0, 1200 ),
					'answer'   => substr( $answer, 0, 1800 ),
				);
			}

			return $normalized;
		}

		/**
		 * Reuse the last real topic when the user sends a short follow-up.
		 *
		 * @param string                          $question Question.
		 * @param array<int,array<string,string>> $history  History.
		 * @return string
		 */
		private function get_retrieval_question( $question, $history ) {
			$question = trim( (string) $question );
			$tokens   = $this->tokenize( $question, 8 );

			if ( count( $tokens ) > 3 || empty( $history ) ) {
				return $question;
			}

			$last = end( $history );
			if ( is_array( $last ) && ! empty( $last['question'] ) ) {
				return (string) $last['question'] . ' ' . $question;
			}

			return $question;
		}

		/**
		 * Get human scene label.
		 *
		 * @param string $scene Scene key.
		 * @return string
		 */
		private function get_scene_label( $scene ) {
			$manifest = $this->get_scene_manifest();

			return isset( $manifest[ $scene ]['label'] ) ? sanitize_text_field( (string) $manifest[ $scene ]['label'] ) : sanitize_text_field( (string) $scene );
		}

		/**
		 * Detect whether the user is asking what a selected source/tradition says.
		 *
		 * @param string $question Question.
		 * @param string $scene    Scene.
		 * @return bool
		 */
		private function is_source_question( $question, $scene ) {
			$text  = $this->lower( (string) $question );
			$scene = sanitize_key( (string) $scene );

			$scene_questions = array(
				'christianity',
				'stoicism',
				'buddhism',
				'islam',
				'hinduism',
				'taoism',
			);

			if ( in_array( $scene, $scene_questions, true ) ) {
				return true;
			}

			$source_markers = array(
				'what does',
				'what say',
				'according to',
				'source',
				'text',
				'bible',
				'gospel',
				'christ',
				'quran',
				'koran',
				'islam',
				'buddha',
				'dhammapada',
				'gita',
				'tao',
				'stoic',
				'τι λεει',
				'τι λέει',
				'συμφωνα με',
				'σύμφωνα με',
				'βιβλος',
				'βίβλος',
				'ευαγγελιο',
				'ευαγγέλιο',
				'χριστος',
				'χριστός',
				'κορανι',
				'κοράνι',
				'ισλαμ',
				'ισλάμ',
				'βουδας',
				'βούδας',
				'γκιτα',
				'γκίτα',
			);

			foreach ( $source_markers as $marker ) {
				if ( false !== strpos( $text, $marker ) ) {
					return true;
				}
			}

			return false;
		}

		/**
		 * Detect first-person imminent danger, not generic theology/source questions.
		 *
		 * @param string $question Question.
		 * @return bool
		 */
		private function is_immediate_danger_question( $question ) {
			$text = $this->lower( (string) $question );

			$intent_markers = array(
				'i want to kill myself',
				'i am going to kill myself',
				'i will kill myself',
				'i am thinking about killing myself',
				'i might kill myself',
				'i want to die now',
				'i am going to die',
				'i cannot stay alive',
				'i can not stay alive',
				'i have a plan',
				'θέλω να αυτοκτονήσω',
				'θελω να αυτοκτονησω',
				'θα αυτοκτονήσω',
				'θα αυτοκτονησω',
				'σκέφτομαι να αυτοκτονήσω',
				'σκεφτομαι να αυτοκτονησω',
				'θέλω να πεθάνω τώρα',
				'θελω να πεθανω τωρα',
				'θα πεθάνω σήμερα',
				'θα πεθανω σημερα',
				'δεν αντέχω να ζήσω',
				'δεν αντεχω να ζησω',
				'έχω σχέδιο να αυτοκτονήσω',
				'εχω σχεδιο να αυτοκτονησω',
			);

			foreach ( $intent_markers as $marker ) {
				if ( false !== strpos( $text, $marker ) ) {
					return true;
				}
			}

			return false;
		}

		/**
		 * Tokenize query.
		 *
		 * @param string $text Text.
		 * @param int    $max  Max tokens.
		 * @return array<int,string>
		 */
		private function tokenize( $text, $max = 18 ) {
			$text   = $this->lower( (string) $text );
			$tokens = preg_split( '/[^\p{L}\p{N}]+/u', $text );

			if ( ! is_array( $tokens ) ) {
				return array();
			}

			$tokens = array_values(
				array_unique(
					array_filter(
						$tokens,
						static function ( $token ) {
							return is_string( $token ) && strlen( $token ) > 2;
						}
					)
				)
			);

			return array_slice( $tokens, 0, max( 1, absint( $max ) ) );
		}

		/**
		 * Ask the configured LLM for a small English retrieval bridge.
		 *
		 * The pack is English-first, but users can ask in any language. This
		 * optional bridge avoids hardcoded language maps and only returns search
		 * words, not an answer.
		 *
		 * @param string $question Question.
		 * @param string $scene    Scene.
		 * @param string $mode     Mode.
		 * @param string $api_key  OpenAI API key.
		 * @return string
		 */
		private function expand_retrieval_query( $question, $scene, $mode, $api_key ) {
			$model = $this->get_settings_summary()['openai_model'];

			$response = wp_remote_post(
				'https://api.openai.com/v1/chat/completions',
				array(
					'timeout' => 18,
					'headers' => array(
						'Authorization' => 'Bearer ' . $api_key,
						'Content-Type'  => 'application/json',
					),
					'body'    => wp_json_encode(
						array(
							'model'       => $model,
							'messages'    => array(
								array(
									'role'    => 'system',
									'content' => 'Return only 8 to 16 English retrieval keywords for searching an English knowledge pack. Do not answer the user. No punctuation except spaces.',
								),
								array(
									'role'    => 'user',
									'content' => "Scene: {$scene}\nMode: {$mode}\nQuestion:\n{$question}",
								),
							),
							'temperature' => 0,
							'max_tokens'  => 80,
						)
					),
				)
			);

			if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
				return '';
			}

			$body = json_decode( (string) wp_remote_retrieve_body( $response ), true );
			if ( ! is_array( $body ) || empty( $body['choices'][0]['message']['content'] ) ) {
				return '';
			}

			$keywords = sanitize_text_field( (string) $body['choices'][0]['message']['content'] );
			$cleaned  = preg_replace( '/[^\p{L}\p{N}\s-]+/u', ' ', $keywords );

			return is_string( $cleaned ) ? substr( $cleaned, 0, 320 ) : '';
		}

		/**
		 * Lowercase text.
		 *
		 * @param string $text Text.
		 * @return string
		 */
		private function lower( $text ) {
			if ( function_exists( 'mb_strtolower' ) ) {
				return mb_strtolower( (string) $text, 'UTF-8' );
			}

			return strtolower( (string) $text );
		}

		/**
		 * Make excerpt.
		 *
		 * @param string $text   Text.
		 * @param int    $length Length.
		 * @return string
		 */
		private function make_excerpt( $text, $length ) {
			$text   = preg_replace( '/\s+/u', ' ', wp_strip_all_tags( (string) $text ) );
			$text   = is_string( $text ) ? trim( $text ) : '';
			$length = max( 120, absint( $length ) );

			if ( strlen( $text ) <= $length ) {
				return $text;
			}

			return substr( $text, 0, $length ) . '...';
		}

		/**
		 * Skip public-domain license/donation boilerplate from retrieval.
		 *
		 * @param string $text Source text.
		 * @return bool
		 */
		private function is_boilerplate_source_text( $text ) {
			$text = $this->lower( (string) $text );

			$markers = array(
				'project gutenberg ebook',
				'project gutenberg license',
				'www.gutenberg.org',
				'send donations',
				'foundation information',
				'terms of this agreement',
				'permission to distribute',
			);

			foreach ( $markers as $marker ) {
				if ( false !== strpos( $text, $marker ) ) {
					return true;
				}
			}

			return false;
		}

		/**
		 * Source title from path.
		 *
		 * @param string $source   Source.
		 * @param string $category Category.
		 * @return string
		 */
		private function source_to_title( $source, $category ) {
			$source = trim( (string) $source );

			if ( '' === $source ) {
				return ucwords( str_replace( '-', ' ', (string) $category ) );
			}

			$name = basename( $source );
			$name = preg_replace( '/\.(txt|md|json)$/i', '', $name );
			$name = str_replace( array( '-', '_' ), ' ', (string) $name );

			return sanitize_text_field( ucwords( $name ) );
		}
	}
}
