<?php

namespace Limb_Chatbot\Includes\Services\Knowledge\Dataset_Builders;

use Limb_Chatbot\Includes\Data_Objects\AI_Model;
use Limb_Chatbot\Includes\Data_Objects\Dataset;
use Limb_Chatbot\Includes\Data_Objects\Job;
use Limb_Chatbot\Includes\Data_Objects\Setting;
use Limb_Chatbot\Includes\Exceptions\Error_Codes;
use Limb_Chatbot\Includes\Exceptions\Exception;
use Limb_Chatbot\Includes\Factories\Knowledge_Mapper_Factory;
use Limb_Chatbot\Includes\Interfaces\Dataset_Builder_Interface;
use Limb_Chatbot\Includes\Services\Helper;
use Limb_Chatbot\Includes\Services\Knowledge\Post_Content_Config_Service;

/**
 * Builds or reuses informational datasets and synchronizes indexing metadata.
 *
 * This builder keeps all source-specific behavior in one place while preparing
 * dataset records for downstream knowledge generation/indexing jobs.
 *
 * @since 1.0.0
 */
class Informational_Dataset_Builder implements Dataset_Builder_Interface {

	/**
	 * Dataset meta key: selected indexing AI model ID.
	 */
	private const META_INDEX_AI_MODEL_ID = 'index_ai_model_id';
	/**
	 * Dataset meta key: indexing config ID.
	 */
	private const META_INDEX_CONFIG_ID = 'index_config_id';
	/**
	 * Dataset meta key: resolved vector index ID.
	 */
	private const META_VECTOR_INDEX_ID = 'vector_index_id';
	/**
	 * Dataset meta key: vector index config ID.
	 */
	private const META_VECTOR_INDEX_CONFIG_ID = 'vector_index_config_id';
	/**
	 * Dataset meta key: vector index backend type.
	 */
	private const META_VECTOR_INDEX_TYPE = 'vector_index_type';
	/**
	 * Dataset meta key: chatbot UUID (or null for default chatbot).
	 */
	private const META_CHATBOT_UUID = 'chatbot_uuid';
	/**
	 * Dataset meta key: embedding/vector dimension.
	 */
	private const META_DIMENSION = 'dimension';
	/**
	 * Dataset meta key: allowed roles for knowledge access.
	 */
	private const META_USER_ROLES = 'user_roles';
	/**
	 * Special access role: allows all users.
	 */
	private const ROLE_EVERYONE = 'everyone';
	/**
	 * Plugin-native role: users with at least one lead.
	 */
	private const ROLE_CONTACTS = 'contact';
	/**
	 * Plugin-native role: any chatbot user.
	 */
	private const ROLE_VISITORS = 'visitor';

	/**
	 * Initialize or prepare a dataset for knowledge gathering.
	 *
	 * - Ensures a dataset exists for the given source.
	 * - Marks it as not synced for re-indexing.
	 * - Associates AI model and configuration metadata.
	 * - Registers the dataset in the indexing queue.
	 *
	 * @param  array  $data  {
	 *
	 * @return Dataset|null The prepared dataset or null on failure.
	 */
	public function build( array $data, Job $job ): ?Dataset {
		try {
			$model_id               = $job->get_config_value( 'indexing_ai_model_id' );
			$config_id              = $job->get_config_value( 'indexing_config_id' );
			$vector_index_type      = $job->get_config_value( 'indexing_vector_index_type' );
			$dimension              = $job->get_config_value( 'indexing_dimension' );
			$vector_index_config_id = $job->get_config_value( 'indexing_vector_index_config_id' );
			$chatbot_uuid           = $this->resolve_chatbot_uuid( $job );
			$resolved_vector_id     = $job->get_config_value( Job::CONFIG_RESOLVED_VECTOR_INDEX_ID );

			$dataset_name            = $this->build_dataset_name( $data, $job );
			$resolved_roles          = $this->resolve_effective_roles( $job );
			$post_type               = (string) ( $data['source_sub_type'] ?? $job->get_config_value( 'post_type' ) );
			$resolved_content_config = $this->resolve_effective_post_content_config( $data, $job, $post_type );

			$dataset = $this->check_dataset_existence(
				$data,
				$resolved_vector_id,
				$model_id,
				$resolved_roles,
				$resolved_content_config
			);
			if ( empty( $dataset ) ) {
				$dataset = $this->create_dataset( $data, $dataset_name );
			}

			if ( ! empty( $vector_index_type ) && ! empty( $dimension ) && empty( $resolved_vector_id ) ) {
				throw new Exception(
					Error_Codes::MISSING_VALUE,
					__( 'Resolved vector index id is missing from job configuration.', 'limb-chatbot' )
				);
			}

			$this->sync_indexing_meta(
				$dataset,
				$model_id,
				$config_id,
				$resolved_vector_id,
				$vector_index_config_id,
				$vector_index_type,
				$chatbot_uuid,
				$dimension
			);
			$resolved_roles = $this->sync_dataset_user_roles_meta( $dataset, $resolved_roles );
			$this->sync_group_user_roles_setting( $dataset, $resolved_roles );
			$this->sync_post_content_config_meta( $dataset, $data, $job, $resolved_content_config, $post_type );
			$this->sync_source_specific_meta( $dataset, $job, $data );

			return $dataset;
		} catch ( \Exception $e ) {
			Helper::log( $e );

			return null;
		}
	}

	/**
	 * Normalize chatbot UUID from job configuration.
	 *
	 * @param  Job  $job
	 *
	 * @return string|null
	 */
	private function resolve_chatbot_uuid( Job $job ) {
		return $job->get_chatbot_uuid() === Job::CHATBOT_DEFAULT ? null : $job->get_chatbot_uuid();
	}

	/**
	 * Build dataset display name via knowledge mapper input.
	 *
	 * @param  array  $data
	 * @param  Job  $job
	 *
	 * @return string
	 */
	private function build_dataset_name( array $data, Job $job ): string {
		$mapper = ( new Knowledge_Mapper_Factory() )->make( $data['source_type'] );
		$source = $this->get_source( $job, $data );
		$source = Helper::resolve_source_object( $data['source_type'], $source );

		return $mapper->get_entry_input( $source );
	}

	/**
	 * Resolve raw source payload based on source type.
	 *
	 * @param  Job  $job
	 * @param  array  $data
	 *
	 * @return mixed
	 */
	private function get_source( Job $job, array $data ) {
		if ( $data['source_type'] === Dataset::SOURCE_QA ) {
			if ( ! empty( $data['q_a'] ) && is_array( $data['q_a'] ) ) {
				return $data['q_a'];
			}
			$qa_config = $job->get_config_value( 'q_a' );
			if ( is_array( $qa_config ) && Helper::is_assoc( $qa_config ) ) {
				return $qa_config;
			}

			return [];
		} elseif ( $data['source_type'] === Dataset::SOURCE_TEXT ) {
			return $job->get_config_value( 'text' );
		} elseif ( $data['source_type'] === Dataset::SOURCE_URL ) {
			// For URL source, the source comes from the data array (set by fetcher)
			// Each URL becomes a separate dataset, so source is already in $data['source']
			return $data['source'];
		} else {
			return $data['source'];
		}
	}

	/**
	 * Resolve effective role set from job config.
	 *
	 * @param  Job  $job
	 *
	 * @return string[]
	 */
	private function resolve_effective_roles( Job $job ): array {
		$incoming_roles = $job->get_config_value( self::META_USER_ROLES );
		if ( null === $incoming_roles ) {
			$incoming_roles = $job->get_config_value( 'user_role' );
		}
		$incoming_roles = $this->normalize_wordpress_roles( $incoming_roles );
		if ( empty( $incoming_roles ) ) {
			return [ self::ROLE_EVERYONE ];
		}

		return $incoming_roles;
	}

	/**
	 * Normalize WordPress role input to a unique list of valid role slugs.
	 *
	 * @param  mixed  $roles
	 *
	 * @return string[]
	 */
	private function normalize_wordpress_roles( $roles ): array {
		if ( null === $roles || false === $roles || '' === $roles ) {
			return [];
		}

		if ( is_string( $roles ) ) {
			$roles = str_contains( $roles, ',' ) ? array_map( 'trim', explode( ',', $roles ) ) : [ $roles ];
		}

		if ( ! is_array( $roles ) ) {
			return [];
		}

		$normalized = array_map(
			static function ( $role ): string {
				return is_string( $role ) ? sanitize_key( $role ) : '';
			},
			$roles
		);
		$normalized = array_map( [ $this, 'normalize_role_alias' ], $normalized );
		$normalized = array_values( array_filter( array_unique( $normalized ) ) );
		if ( in_array( self::ROLE_EVERYONE, $normalized, true ) ) {
			return [ self::ROLE_EVERYONE ];
		}
		$plugin_native_roles = [
			self::ROLE_CONTACTS,
			self::ROLE_VISITORS,
		];

		$wp_roles = wp_roles();
		if ( ! $wp_roles || ! isset( $wp_roles->roles ) || ! is_array( $wp_roles->roles ) ) {
			return array_values(
				array_filter(
					$normalized,
					static function ( string $role ) use ( $plugin_native_roles ): bool {
						return in_array( $role, $plugin_native_roles, true );
					}
				)
			);
		}

		$allowed_role_keys = array_merge( array_keys( $wp_roles->roles ), $plugin_native_roles );

		return array_values(
			array_filter(
				$normalized,
				static function ( string $role ) use ( $allowed_role_keys ): bool {
					return in_array( $role, $allowed_role_keys, true );
				}
			)
		);
	}

	/**
	 * Resolve effective post content config for current build.
	 *
	 * @param  array  $data
	 * @param  Job  $job
	 * @param  string  $post_type
	 *
	 * @return array
	 */
	private function resolve_effective_post_content_config( array $data, Job $job, string $post_type ): array {
		if ( ( $data['source_type'] ?? '' ) !== Dataset::SOURCE_POST ) {
			return [];
		}

		$content_config_service = new Post_Content_Config_Service();

		return $content_config_service->resolve_for_post_type( $post_type, $job->get_config() ?? [] );
	}

	/**
	 * Find an existing compatible informational dataset for the source.
	 *
	 * Existing dataset is considered compatible only if vector index and AI provider
	 * match current job configuration.
	 *
	 * @param  array  $data
	 * @param  mixed  $vector_index_id
	 * @param  mixed  $ai_model_id
	 *
	 * @return Dataset|null
	 */
	private function check_dataset_existence(
		array $data,
		$vector_index_id,
		$ai_model_id,
		array $expected_user_roles = [],
		array $expected_post_content_config = []
	): ?Dataset {
		$dataset = null;
		if ( ! in_array( $data['source_type'], [ Dataset::SOURCE_QA, Dataset::SOURCE_TEXT, Dataset::SOURCE_URL ] ) ) {
			$criteria = [
				'source_type'     => $data['source_type'],
				'source_sub_type' => $data['source_sub_type'] ?? null,
				'source'          => $data['source'],
				'type'            => Dataset::TYPE_INFORMATIONAL_KNOWLEDGE,
			];
			$datasets = Dataset::where( $criteria );
			if ( ! $datasets->is_empty() ) {
				foreach ( $datasets->get() as $item ) {
					if ( $item instanceof Dataset ) {
						$dataset_vector_index_id = $item->get_meta_value( 'vector_index_id' );
						if ( null !== $vector_index_id && $dataset_vector_index_id != $vector_index_id ) {
							$dataset = null;
						} else {
							$ai_model = AI_Model::find( $ai_model_id );
							if ( $item->ai_provider_id() != $ai_model->get_ai_provider_id() ) {
								$dataset = null;
							} else {
								$user_roles_match = $this->arrays_are_equivalent(
									$this->normalize_wordpress_roles( $item->get_meta_value( self::META_USER_ROLES ) ),
									$expected_user_roles
								);
								if ( ! $user_roles_match ) {
									$dataset = null;
									continue;
								}

								if ( Dataset::SOURCE_POST === $item->get_source_type() ) {
									$stored_content_config      = $this->normalize_post_content_config_for_comparison( [
										Post_Content_Config_Service::NATIVE_FIELDS_KEY => $item->get_meta_value( Post_Content_Config_Service::NATIVE_FIELDS_KEY ) ?? [],
										Post_Content_Config_Service::TAXONOMIES_KEY    => $item->get_meta_value( Post_Content_Config_Service::TAXONOMIES_KEY ) ?? [],
									] );
									$expected_config_normalized = $this->normalize_post_content_config_for_comparison( $expected_post_content_config );
									if ( ! $this->arrays_are_equivalent( $stored_content_config,
										$expected_config_normalized ) ) {
										$dataset = null;
										continue;
									}
								}

								$dataset = $item;
								break;
							}
						}
					}
				}
			}
		}

		return $dataset;
	}

	/**
	 * Compare arrays regardless of element ordering.
	 *
	 * @param  array  $a
	 * @param  array  $b
	 *
	 * @return bool
	 */
	private function arrays_are_equivalent( array $a, array $b ): bool {
		sort( $a );
		sort( $b );

		return wp_json_encode( $a ) === wp_json_encode( $b );
	}

	/**
	 * Normalize post content config before deep array comparison.
	 *
	 * @param  array  $content_config
	 *
	 * @return array
	 */
	private function normalize_post_content_config_for_comparison( array $content_config ): array {
		$content_config = [
			Post_Content_Config_Service::NATIVE_FIELDS_KEY => $content_config[ Post_Content_Config_Service::NATIVE_FIELDS_KEY ] ?? [],
			Post_Content_Config_Service::TAXONOMIES_KEY    => $content_config[ Post_Content_Config_Service::TAXONOMIES_KEY ] ?? [],
		];

		foreach ( $content_config as $key => $value ) {
			$value = is_array( $value ) ? $value : [];
			sort( $value );
			$content_config[ $key ] = array_values( $value );
		}

		ksort( $content_config );

		return $content_config;
	}

	/**
	 * Create a new informational dataset record.
	 *
	 * @param  array  $data
	 * @param  string  $dataset_name
	 *
	 * @return Dataset
	 */
	private function create_dataset( array $data, string $dataset_name ): Dataset {
		$dataset = new Dataset( $data );
		$dataset->set_status( Dataset::STATUS_PENDING );
		$dataset->set_name( $dataset_name );
		$dataset->set_type( Dataset::TYPE_INFORMATIONAL_KNOWLEDGE );
		$dataset->save();

		return $dataset;
	}

	/**
	 * Sync common indexing metadata for the dataset.
	 *
	 * @param  Dataset  $dataset
	 * @param  mixed  $model_id
	 * @param  mixed  $config_id
	 * @param  mixed  $vector_index_id
	 * @param  mixed  $vector_index_config_id
	 * @param  mixed  $vector_index_type
	 * @param  mixed  $chatbot_uuid
	 * @param  mixed  $dimension
	 *
	 * @return void
	 */
	private function sync_indexing_meta(
		Dataset $dataset,
		$model_id,
		$config_id,
		$vector_index_id,
		$vector_index_config_id,
		$vector_index_type,
		$chatbot_uuid,
		$dimension
	): void {
		$dataset->mark_not_synced();
		$dataset->update_meta( self::META_INDEX_AI_MODEL_ID, $model_id );
		$dataset->update_meta( self::META_INDEX_CONFIG_ID, $config_id );
		$dataset->update_meta( self::META_VECTOR_INDEX_ID, $vector_index_id );
		$dataset->update_meta( self::META_VECTOR_INDEX_CONFIG_ID, $vector_index_config_id );
		$dataset->update_meta( self::META_VECTOR_INDEX_TYPE, $vector_index_type );
		$dataset->update_meta( self::META_CHATBOT_UUID, $chatbot_uuid );
		$dataset->update_meta( self::META_DIMENSION, $dimension );
	}

	/**
	 * Merge and persist allowed role slugs on dataset metadata.
	 *
	 * Accepts both scalar and array job config input, normalizes to role slugs,
	 * then appends only missing roles to existing vector index metadata.
	 *
	 * @param  Dataset  $dataset
	 * @param  Job  $job
	 *
	 * @return void
	 */
	private function sync_dataset_user_roles_meta( Dataset $dataset, array $resolved_roles ): array {
		$dataset->update_meta( self::META_USER_ROLES, $resolved_roles );

		return $resolved_roles;
	}

	/**
	 * Keep group-level user role setting synchronized with generated dataset roles.
	 *
	 * @param  Dataset  $dataset
	 * @param  array  $roles
	 *
	 * @return void
	 */
	private function sync_group_user_roles_setting( Dataset $dataset, array $roles ): void {
		$group_slug  = $this->build_group_slug( $dataset->get_source_type(), $dataset->get_source_sub_type() );
		$setting_key = 'lbaic.utilities.chatbot.user_roles_for_' . $group_slug;
		$current     = Setting::find( $setting_key );
		$current_val = $current ? $this->normalize_wordpress_roles( $current->get_value() ) : [];
		$target_val  = $this->normalize_wordpress_roles( $roles );

		if ( ! $this->arrays_are_equivalent( $current_val, $target_val ) ) {
			Setting::update( [ 'key' => $setting_key ], [ 'value' => $target_val ] );
		}
	}

	/**
	 * Build dataset group slug using the same conventions as Dataset_Group_Repository.
	 *
	 * @param  string|null  $source_type
	 * @param  string|null  $source_sub_type
	 *
	 * @return string
	 */
	private function build_group_slug( ?string $source_type, ?string $source_sub_type ): string {
		$source_type     = (string) $source_type;
		$source_sub_type = null === $source_sub_type ? null : (string) $source_sub_type;

		if ( Dataset::SOURCE_FILE === $source_type ) {
			return 'media';
		}

		if ( Dataset::SOURCE_TEXT === $source_type ) {
			return 'texts';
		}

		if ( in_array( $source_type,
				[ Dataset::SOURCE_POST, Dataset::SOURCE_CPT ],
				true ) && null !== $source_sub_type && '' !== $source_sub_type ) {
			return sprintf( '%s:%s', $source_type, rawurlencode( $source_sub_type ) );
		}

		if ( null !== $source_sub_type && '' !== $source_sub_type ) {
			return sprintf( '%s:%s', $source_type, rawurlencode( $source_sub_type ) );
		}

		return $source_type;
	}

	/**
	 * Sync resolved post content configuration into dataset metadata.
	 *
	 * @param  Dataset  $dataset
	 * @param  array  $data
	 * @param  Job  $job
	 *
	 * @return void
	 */
	private function sync_post_content_config_meta(
		Dataset $dataset,
		array $data,
		Job $job,
		array $resolved_content_config = [],
		string $post_type = ''
	): void {
		if ( Dataset::SOURCE_POST !== $dataset->get_source_type() ) {
			return;
		}

		if ( '' === $post_type ) {
			$post_type = (string) ( $data['source_sub_type'] ?? $job->get_config_value( 'post_type' ) );
		}
		if ( empty( $resolved_content_config ) ) {
			$content_config_service  = new Post_Content_Config_Service();
			$resolved_content_config = $content_config_service->resolve_for_post_type( $post_type,
				$job->get_config() ?? [] );
		}

		$dataset->update_meta( Post_Content_Config_Service::CONFIG_KEY, $resolved_content_config );
		$dataset->update_meta( Post_Content_Config_Service::NATIVE_FIELDS_KEY,
			$resolved_content_config[ Post_Content_Config_Service::NATIVE_FIELDS_KEY ] ?? [] );
		$dataset->update_meta( Post_Content_Config_Service::TAXONOMIES_KEY,
			$resolved_content_config[ Post_Content_Config_Service::TAXONOMIES_KEY ] ?? [] );

		$setting_key = 'lbaic.utilities.chatbot.content_config_for_' . $post_type;
		$current     = Setting::find( $setting_key );
		if ( ! $current || ! $this->arrays_are_equivalent( (array) $current->get_value(),
				(array) $resolved_content_config ) ) {
			Setting::update( [ 'key' => $setting_key ], [ 'value' => $resolved_content_config ] );
		}
	}

	/**
	 * Sync additional metadata required by specific source types.
	 *
	 * @param  Dataset  $dataset
	 * @param  Job  $job
	 *
	 * @return void
	 */
	private function sync_source_specific_meta( Dataset $dataset, Job $job, array $data = [] ): void {
		if ( $dataset->get_source_type() == Dataset::SOURCE_QA ) {
			$qa_item = $data['q_a'] ?? $job->get_config_value( 'q_a' );
			if ( is_array( $qa_item ) && ! Helper::is_assoc( $qa_item ) ) {
				$qa_item = [];
			}
			$question = trim( (string) ( $qa_item['question'] ?? $qa_item['input'] ?? '' ) );
			$answer   = trim( (string) ( $qa_item['answer'] ?? $qa_item['output'] ?? '' ) );
			$variations = is_array( $qa_item['question_variations'] ?? null ) ? $qa_item['question_variations'] : [];
			$variations = array_values(
				array_filter(
					array_unique(
						array_map(
							static function ( $variation ): string {
								return trim( (string) $variation );
							},
							$variations
						)
					),
					static function ( string $variation ): bool {
						return '' !== $variation;
					}
				)
			);
			$dataset->update_meta( 'q_a', [
				'question'            => $question,
				'question_variations' => $variations,
				'answer'              => $answer,
			] );
		} elseif ( $dataset->get_source_type() == Dataset::SOURCE_TEXT ) {
			$dataset->update_meta( 'text', $job->get_config_value( 'text' ) );
		} elseif ( $dataset->get_source_type() == Dataset::SOURCE_URL ) {
			// URL is stored in the source field, no need to store in meta.
			// Keep meta copy for reference.
			$dataset->update_meta( 'url', $dataset->get_source() );
		}
	}

	/**
	 * Normalize known role aliases to canonical slugs.
	 *
	 * @param  string  $role
	 *
	 * @return string
	 */
	private function normalize_role_alias( string $role ): string {
		return $role;
	}
}