<?php

namespace Limb_Chatbot\Includes\Services\Jobs\Handlers;

use Limb_Chatbot\Includes\Data_Objects\AI_Model;
use Limb_Chatbot\Includes\Data_Objects\Chatbot;
use Limb_Chatbot\Includes\Data_Objects\Config;
use Limb_Chatbot\Includes\Data_Objects\Dataset;
use Limb_Chatbot\Includes\Data_Objects\Dataset_Entry;
use Limb_Chatbot\Includes\Data_Objects\File;
use Limb_Chatbot\Includes\Data_Objects\Job;
use Limb_Chatbot\Includes\Data_Objects\Task;
use Limb_Chatbot\Includes\Data_Objects\Vector_Index;
use Limb_Chatbot\Includes\Exceptions\Error_Codes;
use Limb_Chatbot\Includes\Exceptions\Exception;
use Limb_Chatbot\Includes\Factories\Dataset_Builder_Factory;
use Limb_Chatbot\Includes\Factories\Source_Fetcher_Factory;
use Limb_Chatbot\Includes\Factories\Source_Validator_Factory;
use Limb_Chatbot\Includes\Interfaces\Multitask_Handler_Interface;
use Limb_Chatbot\Includes\Services\Dataset_Service;
use Limb_Chatbot\Includes\Services\File_Upload_Service;
use Limb_Chatbot\Includes\Services\Knowledge\Indexing_Service;
use Limb_Chatbot\Includes\Services\Knowledge\Knowledge_Generator;
use Limb_Chatbot\Includes\Services\Knowledge\Post_Content_Config_Service;
use Limb_Chatbot\Includes\Services\Job\Abstract_Job_Handler;
use Limb_Chatbot\Includes\Vector_Dbs\Local\Local;
use Limb_Chatbot\Includes\Vector_Dbs\Pinecone\Pinecone;

/**
 * Dataset Generating Job Handler
 *
 * Handles dataset generation jobs. Extracts logic from the old background process system.
 *
 * @since 1.1.0
 */
class Dataset_Generating extends Abstract_Job_Handler implements Multitask_Handler_Interface {

	/**
	 * Batch size for generating child tasks.
	 *
	 * @var int
	 * @since 1.1.0
	 */
	public int $child_task_batch_size = 100;
	/**
	 * Chunk size for fetching objects in batches.
	 *
	 * @var int
	 * @since 1.1.0
	 */
	private int $chunk_size = 5;
	/**
	 * Dataset service instance.
	 *
	 * @var Dataset_Service
	 * @since 1.1.0
	 */
	private Dataset_Service $dataset_service;

	/**
	 * Constructor.
	 *
	 * @since 1.1.0
	 */
	public function __construct() {
		parent::__construct();
		$this->dataset_service = new Dataset_Service();
	}

	/**
	 * Get the job type this handler manages.
	 *
	 * @return string
	 * @since 1.1.0
	 */
	public function get_job_type(): string {
		return Job::TYPE_DATASET_GENERATING;
	}

	/**
	 * Prepare and transform job configuration before validation.
	 *
	 * For file-source jobs, detects WordPress media attachment IDs (integers) in the
	 * 'files' array and imports them into the plugin's file system via
	 * File_Upload_Service::import_from_media_library(). The attachment IDs are then
	 * replaced with the resulting plugin File UUIDs.
	 *
	 * @param  array        $config        Job configuration.
	 * @param  string|null  $chatbot_uuid  Chatbot UUID.
	 *
	 * @return array Prepared configuration with resolved file UUIDs.
	 * @throws Exception If media import fails.
	 * @since 1.1.3
	 */
	public function prepare_config( array $config, ?string $chatbot_uuid = null ): array {
		$source = $config['source'] ?? null;

		if ( $source !== Dataset::SOURCE_FILE || empty( $config['files'] ) || ! is_array( $config['files'] ) ) {
			return $config;
		}

		$config['files'] = $this->resolve_file_references( $config['files'] );

		return $config;
	}

	/**
	 * Resolve file references from a mixed array of WP attachment IDs and File UUIDs.
	 *
	 * Integer entries are treated as WordPress media attachment IDs and imported into
	 * the plugin's file storage via File_Upload_Service. String entries are validated
	 * as existing File UUIDs.
	 *
	 * @param  array  $file_refs  Mixed array of integer attachment IDs and/or string UUIDs.
	 *
	 * @return array Array of resolved File UUIDs.
	 * @throws Exception If any attachment import fails or any UUID is invalid.
	 * @since 1.1.3
	 */
	private function resolve_file_references( array $file_refs ): array {
		$attachment_ids = array();
		$resolved_uuids = array();

		foreach ( $file_refs as $ref ) {
			if ( is_int( $ref ) || ( is_string( $ref ) && ctype_digit( $ref ) ) ) {
				$attachment_ids[] = (int) $ref;
			} else {
				// Already a UUID string — validate it exists
				$file = File::find_by_uuid( (string) $ref );
				if ( ! $file instanceof File ) {
					throw new Exception(
						Error_Codes::VALIDATION_INVALID_VALUE,
						sprintf(
							/* translators: %s: file UUID */
							__( 'File with UUID "%s" not found.', 'limb-chatbot' ),
							esc_html( (string) $ref )
						)
					);
				}
				$resolved_uuids[] = $file->get_uuid();
			}
		}

		// Import any WP media attachment IDs into plugin file storage.
		if ( ! empty( $attachment_ids ) ) {
			$upload_service = new File_Upload_Service(
				Limb_Chatbot()->get_files_dir() . Dataset::FILES_KNOWLEDGE_SUB_DIR
			);
			$import_result = $upload_service->import_from_media_library( $attachment_ids );

			$total_errors = $import_result->get_property( 'total_errors' );
			if ( $total_errors > 0 ) {
				$errors = $import_result->get_property( 'errors' );
				throw new Exception(
					Error_Codes::VALIDATION_INVALID_VALUE,
					sprintf(
						/* translators: %s: comma-separated error messages */
						__( 'Failed to import media files: %s', 'limb-chatbot' ),
						implode( '; ', array_values( $errors ) )
					)
				);
			}

			foreach ( $import_result->items as $file ) {
				if ( $file instanceof File ) {
					$resolved_uuids[] = $file->get_uuid();
				}
			}
		}

		if ( empty( $resolved_uuids ) ) {
			throw new Exception(
				Error_Codes::VALIDATION_INVALID_VALUE,
				__( 'No valid files were resolved for the job.', 'limb-chatbot' )
			);
		}

		return $resolved_uuids;
	}

	/**
	 * Validate job configuration.
	 *
	 * Extracted from Handler::validate_params()
	 *
	 * @param  array  $config  Job configuration.
	 *
	 * @return bool True if valid.
	 * @throws Exception If validation fails.
	 * @since 1.1.0
	 */
	public function validate( array $config, ?string $chatbot_uuid = null ): bool {
		$source       = $config['source'] ?? null;
		$chatbot_uuid = $chatbot_uuid === Job::CHATBOT_DEFAULT ? null : $chatbot_uuid;

		// Validate chatbot if provided
		if ( ! empty( $chatbot_uuid ) ) {
			$chatbot = Chatbot::find_by_uuid( $chatbot_uuid );
			if ( empty( $chatbot ) ) {
				throw new Exception( Error_Codes::VALIDATION_INVALID_VALUE,
					__( 'The specified chatbot does not exist.', 'limb-chatbot' ) );
			}
		} else {
			$chatbot = Chatbot::make();
		}

		// Validate source
		if ( empty( $source ) ) {
			throw new Exception( 'missing_source', __( 'Source is required for dataset generation.', 'limb-chatbot' ) );
		}

		$validator = ( new Source_Validator_Factory() )->make( $source );
		$validator->validate( $config );

		$this->validate_indexing_params( $config, $chatbot );
		( new Post_Content_Config_Service() )->validate_post_job_config( $config );

		return true;
	}

	/**
	 * After validation: resolve informational vector index once and persist its id on the job config.
	 *
	 * @param  array        $config        Job configuration.
	 * @param  string|null  $chatbot_uuid  Chatbot UUID.
	 *
	 * @return array
	 * @throws Exception
	 */
	public function finalize_job_config( array $config, ?string $chatbot_uuid = null ): array {
		return self::resolve_informational_vector_index_config( $config, $chatbot_uuid );
	}

	/**
	 * Resolve or create the chatbot vector index for informational dataset jobs; skips CPT (actionable) sources.
	 *
	 * Reuses an existing index when {@see Chatbot::get_vector_index_for_chatbot()} returns one.
	 * Also used by post auto-sync when building an in-memory job.
	 *
	 * @param  array        $config        Job configuration (must pass indexing_* fields when storage is used).
	 * @param  string|null  $chatbot_uuid  Same UUID as job creation (default chatbot = null).
	 *
	 * @return array Config including {@see Job::CONFIG_RESOLVED_VECTOR_INDEX_ID} when applicable.
	 * @throws Exception {@see Error_Codes::JOB_VECTOR_INDEX_RESOLUTION_FAILED} on setup failure.
	 */
	public static function resolve_informational_vector_index_config( array $config, ?string $chatbot_uuid = null ): array {
		if ( isset( $config['source'] ) && Dataset::SOURCE_CPT === $config['source'] ) {
			return $config;
		}

		$id_key = Job::CONFIG_RESOLVED_VECTOR_INDEX_ID;
		if ( ! empty( $config[ $id_key ] ) ) {
			return $config;
		}

		$dimension             = $config['indexing_dimension'] ?? null;
		$vector_index_type     = $config['indexing_vector_index_type'] ?? null;
		$vector_index_config_id = $config['indexing_vector_index_config_id'] ?? null;

		if ( empty( $vector_index_type ) || empty( $dimension ) ) {
			return $config;
		}

		$chatbot_uuid = $chatbot_uuid === Job::CHATBOT_DEFAULT ? null : $chatbot_uuid;
		$chatbot      = ! empty( $chatbot_uuid ) ? Chatbot::find_by_uuid( $chatbot_uuid ) : Chatbot::make();
		if ( ! empty( $chatbot_uuid ) && empty( $chatbot ) ) {
			throw new Exception(
				Error_Codes::VALIDATION_INVALID_VALUE,
				__( 'The specified chatbot does not exist.', 'limb-chatbot' )
			);
		}

		try {
			$vector_index = $chatbot->get_vector_index_for_chatbot( $vector_index_config_id, $vector_index_type, $dimension );
			if ( ! $vector_index ) {
				$vector_index = $chatbot->create_vector_index_for_chatbot(
					$vector_index_config_id,
					$vector_index_type,
					$dimension
				);
			}
			if ( ! $vector_index instanceof Vector_Index ) {
				throw new Exception(
					Error_Codes::TECHNICAL_ERROR,
					__( 'Technical error happened during storage creation.', 'limb-chatbot' )
				);
			}
		} catch ( \Exception $e ) {
			$friendly = self::normalize_vector_index_resolution_exception( $e );
			throw new Exception(
				Error_Codes::JOB_VECTOR_INDEX_RESOLUTION_FAILED,
				$friendly->getMessage(),
				$friendly->get_error_data(),
				$friendly->get_http_status()
			);
		}

		$config[ $id_key ] = $vector_index->get_id();

		return $config;
	}

	/**
	 * @param  \Exception  $e
	 *
	 * @return Exception
	 */
	private static function normalize_vector_index_resolution_exception( \Exception $e ): Exception {
		if ( $e instanceof Exception ) {
			$message = trim( (string) $e->getMessage() );
			$lower   = strtolower( $message );
			$is_quota_like = str_contains( $lower, 'limit' )
				|| str_contains( $lower, 'quota' )
				|| str_contains( $lower, 'capacity' )
				|| str_contains( $lower, 'exceed' )
				|| str_contains( $lower, 'too many' );

			if ( $is_quota_like ) {
				return new Exception(
					Error_Codes::PINECONE_ERROR,
					__( 'Pinecone storage limit reached. Please upgrade your Pinecone plan or reduce index usage, then retry.', 'limb-chatbot' ),
					$e->get_error_data(),
					$e->get_http_status()
				);
			}

			if ( Error_Codes::PINECONE_ERROR === $e->get_error_code() ) {
				return new Exception(
					Error_Codes::PINECONE_ERROR,
					__( 'Unable to create Pinecone storage. Please verify your Pinecone configuration and try again.', 'limb-chatbot' ),
					$e->get_error_data(),
					$e->get_http_status()
				);
			}

			return $e;
		}

		return new Exception(
			Error_Codes::TECHNICAL_ERROR,
			__( 'Technical error happened during storage creation.', 'limb-chatbot' )
		);
	}

	private function validate_indexing_params( array $config, ?Chatbot $chatbot = null ) {
		$dimension              = $config['indexing_dimension'] ?? null;
		$vector_index_type      = $config['indexing_vector_index_type'] ?? null;
		$vector_index_config_id = $config['indexing_vector_index_config_id'] ?? null;
		$ai_model_id            = $config['indexing_ai_model_id'] ?? null;
		$config_id              = $config['indexing_config_id'] ?? null;

		if ( ! empty( $vector_index_type ) && ! empty( $dimension ) ) {
			if ( ! in_array( $vector_index_type, [ Pinecone::$id, Local::$id ], true ) ) {
				throw new Exception(
					Error_Codes::VALIDATION_INVALID_VALUE,
					__( 'Unsupported storage type.', 'limb-chatbot' )
				);
			}

			// Validate vector index config ID (required for non-local storage)
			if ( $vector_index_type !== Local::$id && empty( $vector_index_config_id ) ) {
				throw new Exception(
					Error_Codes::VALIDATION_INVALID_VALUE,
					__( 'Storage config ID is missing.', 'limb-chatbot' )
				);
			}
		}

		if ( ! empty( $ai_model_id ) ) {
			$ai_model = AI_Model::find( $ai_model_id );
			if ( empty( $ai_model ) ) {
				throw new Exception(
					Error_Codes::VALIDATION_INVALID_VALUE,
					__( 'Unknown AI model ID.', 'limb-chatbot' )
				);
			}

			// Ensure the AI model supports the requested dimension
			if ( ! $ai_model->is_supported_dimension( $dimension ) ) {
				throw new Exception(
					Error_Codes::VALIDATION_INVALID_VALUE,
					sprintf(
						__( 'Model %s does not support dimension %s.', 'limb-chatbot' ),
						'<strong>' . $ai_model->get_name() . '</strong>',
						'<strong>' . $dimension . '</strong>'
					)
				);
			}
		} else {
			$exception = new Exception(
				Error_Codes::VALIDATION_INVALID_VALUE,
				__( 'No AI Model is saved in ', 'limb-chatbot' )
			);
			$exception->attach_link( 'admin.php?page=lbaic-dashboard&menu=knowledge-settings', 'Knowledge Settings' );
			throw $exception;
		}

		// Validate config
		if ( ! empty( $config_id ) && ! Config::count( [ 'id' => $config_id ] ) ) {
			$exception = new Exception(
				Error_Codes::VALIDATION_INVALID_VALUE,
				__( 'API Key missing. Add it in ', 'limb-chatbot' )
			);
			$exception->attach_link( admin_url( 'admin.php?page=lbaic-dashboard&menu=chatbot&tab=ai-settings' ), __( 'Chatbot -> AI Settings', 'limb-chatbot' ) );
			throw $exception;
		} elseif ( empty( $config_id ) ) {
			$exception = new Exception(
				Error_Codes::VALIDATION_INVALID_VALUE,
				__( 'No API Key is saved in ', 'limb-chatbot' )
			);
			$exception->attach_link( 'admin.php?page=lbaic-dashboard&menu=knowledge-settings', 'Knowledge Settings' );
			throw $exception;
		}

		return true;
	}

	/**
	 * Get total number of tasks that will be generated.
	 *
	 * Calculates total without actually fetching/generating tasks.
	 * Essential for large datasets to prevent timeouts.
	 *
	 * @param  array  $config  Job configuration.
	 *
	 * @return int Total task count.
	 * @throws Exception If calculation fails.
	 * @since 1.1.0
	 */
	public function get_total( array $config, Job $job): int {
		$source = $config['source'];

		// Get total count from validator
		$validator = ( new Source_Validator_Factory() )->make( $source );
		$total     = $validator->get_total( $config );

		return max( 0, (int) $total );
	}

	/**
	 * Generate a batch of tasks (chunked generation).
	 *
	 * Generates tasks in chunks to prevent timeouts with large datasets.
	 * For example, if generating 100k products, creates 100 tasks at a time.
	 *
	 * @param  Job  $job  Job instance.
	 * @param  array  $config  Job configuration.
	 * @param  int  $offset  Starting offset for this batch.
	 * @param  int  $limit  Maximum number of tasks to generate.
	 *
	 * @return int Number of tasks actually created.
	 * @throws Exception If task generation fails.
	 * @since 1.1.0
	 */
	public function generate_task_batch( Job $job, array $config, int $offset, int $limit ): int {
		$source = $config['source'];

		// Calculate pagination based on offset and limit
		// Our chunk_size is how many we fetch per page from the source
		$per_page = $this->chunk_size;

		// Calculate which pages we need to fetch
		$start_page = floor( $offset / $per_page ) + 1;
		$end_offset = $offset + $limit;
		$end_page   = ceil( $end_offset / $per_page );

		// Fetch objects and create tasks
		$fetcher       = ( new Source_Fetcher_Factory() )->make( $source );
		$task_count    = 0;
		$items_to_skip = $offset % $per_page; // Skip items if offset doesn't align with page start

		for ( $paged = $start_page; $paged <= $end_page; $paged ++ ) {
			// Don't generate more tasks than requested
			if ( $task_count >= $limit ) {
				break;
			}

			$fetch_params = [
				'paged'    => $paged,
				'per_page' => $per_page,
			];

			// Fetch objects for this page
			$objects = $fetcher->fetch( $fetch_params, $config );

			foreach ( $objects as $index => $object ) {
				// Skip items if we're starting mid-page
				if ( $paged === $start_page && $index < $items_to_skip ) {
					continue;
				}

				// Stop if we've created enough tasks
				if ( $task_count >= $limit ) {
					break 2; // Break out of both loops
				}

				// Create task for this object
				if ( $this->create_task( $job->get_id(), $object ) ) {
					$task_count ++;
				}
			}
		}

		return $task_count;
	}

	/**
	 * Process a single task.
	 *
	 * If task has a parent (child task), it's an indexing task.
	 * Otherwise, it's a parent task that will generate sub-tasks.
	 *
	 * @param  Task  $task  Task to process.
	 *
	 * @return bool True on success.
	 * @throws Exception If processing fails.
	 * @since 1.1.0
	 */
	public function process_task( Task $task ): bool {
		// Check if this is a child task (indexing task)
		if ( $task->get_parent_task_id() ) {
			// This is a child task - index the entry
			$indexing_service = new Indexing_Service();

			return $indexing_service->index_entry( $task );
		}

		// This is a parent task - generate sub-tasks (entries)
		// The parent task processing is handled by generate_sub_tasks
		// which is called by the multitask processor
		return true;
	}

	/**
	 * Generate sub-tasks for a parent task.
	 *
	 * Generates dataset entries and creates child tasks for indexing each entry.
	 * Supports batching to prevent memory exhaustion on large datasets.
	 *
	 * @param  Task  $task  Parent task.
	 *
	 * @return bool True if all sub-tasks are generated, false if more batches needed.
	 * @throws Exception If generation fails.
	 * @since 1.1.0
	 */
	public function generate_sub_tasks( Task $task ): bool {
		$item = $task->get_payload();
		$job  = $task->job();

		// Build dataset from item
		$builder = ( new Dataset_Builder_Factory() )->make( $item );
		$dataset = $builder->build( $item, $job );

		if ( ! $dataset instanceof Dataset ) {
			// Not a valid dataset, but not an error - just skip
			return true;
		}
		if ( $job !== null ) {
			$dataset->update_meta( 'job_id', $job->get_id() );
		}

		// Drop generation-stage error entries; keep other staged entries if present.
		$errors = $dataset->get_errors();
		if ( is_array( $errors ) ) {
			$errors = array_values(
				array_filter(
					$errors,
					static function ( $entry ): bool {
						return ! is_array( $entry ) || ( $entry['stage'] ?? '' ) !== 'generation';
					}
				)
			);
		} else {
			$errors = [];
		}
		$dataset->update_meta( 'errors', wp_json_encode( $errors ) );

		// Clear existing entries if any
		if ( $dataset->has_entries() ) {
			$this->dataset_service->clear( $dataset );
		}

		// Generate knowledge entries (only once, on first batch)
		$dataset = ( new Knowledge_Generator() )->generate( $dataset, $job );

		// Get indexing configuration from dataset
		$index_config_id   = $dataset->get_meta_value( 'index_config_id' );
		$vector_index_id   = $dataset->get_meta_value( 'vector_index_id' );
		$dimension         = $dataset->get_meta_value( 'dimension' );
		$index_ai_model_id = $dataset->get_meta_value( 'index_ai_model_id' );
		// Only create indexing tasks if indexing configuration exists
		if ( empty( $index_config_id ) || empty( $index_ai_model_id ) || empty( $vector_index_id ) || empty( $dimension ) ) {
			return true;
		}
		$dataset_entries = Dataset_Entry::where( [ 'dataset_id' => $dataset->get_id() ], - 1, - 1 );

		foreach ( $dataset_entries as $entry ) {
			if ( $entry instanceof Dataset_Entry ) {
				// Create child task payload with entry ID and indexing config
				$child_payload = [
					'dataset_entry_id' => $entry->get_id(),
					'config_id'        => $index_config_id,
					'ai_model_id'      => $index_ai_model_id,
					'vector_index_id'  => $vector_index_id,
					'dimension'        => $dimension,
				];

				$this->create_sub_task( $job->get_id(), $task, $child_payload );
			}
		}

		return true;
	}
}

