<?php

namespace Limb_Chatbot\Includes\Services\Jobs\Handlers;

use Limb_Chatbot\Includes\Data_Objects\Dataset;
use Limb_Chatbot\Includes\Data_Objects\Dataset_Meta;
use Limb_Chatbot\Includes\Data_Objects\Job;
use Limb_Chatbot\Includes\Data_Objects\Setting;
use Limb_Chatbot\Includes\Data_Objects\Task;
use Limb_Chatbot\Includes\Exceptions\Error_Codes;
use Limb_Chatbot\Includes\Exceptions\Exception;
use Limb_Chatbot\Includes\Repositories\Dataset_Repository;
use Limb_Chatbot\Includes\Repositories\Vector_Index_Repository;
use Limb_Chatbot\Includes\Services\Dataset_Service;
use Limb_Chatbot\Includes\Services\Helper;
use Limb_Chatbot\Includes\Services\Job\Abstract_Job_Handler;
use Limb_Chatbot\Includes\Services\Vector_Index_Service;

/**
 * Dataset Delete Job Handler
 *
 * Handles dataset deletion jobs. Migrated from WP_Background_Process system.
 * Deletes datasets and their associated data (entries, metadata, vectors).
 *
 * @since 1.1.0
 */
class Dataset_Delete extends Abstract_Job_Handler {
	/**
	 * Job stats key for tracking CPT slugs that had actionable CPT datasets deleted.
	 *
	 * @since 1.1.0
	 */
	private const STATS_AFFECTED_ACTIONABLE_CPT_SUB_TYPES = 'affected_actionable_cpt_sub_types';

	/**
	 * Dataset service instance.
	 *
	 * @var Dataset_Service
	 * @since 1.1.0
	 */
	private Dataset_Service $dataset_service;

	/**
	 * Vector index service instance.
	 *
	 * @var Vector_Index_Service
	 * @since 1.1.0
	 */
	private Vector_Index_Service $vector_index_service;

	/**
	 * Dataset list/query repository (REST-equivalent filters).
	 *
	 * @var Dataset_Repository
	 * @since 1.1.0
	 */
	private Dataset_Repository $dataset_repository;

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

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

	/**
	 * Non-empty `config.sub_type` selects datasets by `Dataset.source_sub_type`
	 * when `dataset_ids` is omitted, empty, or not the string `all`.
	 *
	 * @param array $config Job configuration.
	 *
	 * @return string Trimmed dataset source sub-type or empty string.
	 */
	private function config_dataset_sub_type( array $config ): string {
		if ( ! isset( $config['sub_type'] ) ) {
			return '';
		}

		return trim( (string) $config['sub_type'] );
	}

	/**
	 * Whether deletion targets all datasets matching `config.sub_type` (source_sub_type).
	 *
	 * @param array $config Job configuration.
	 */
	private function deletes_by_config_dataset_sub_type( array $config ): bool {
		$dataset_ids = $config['dataset_ids'] ?? null;
		if ( $dataset_ids === 'all' ) {
			return false;
		}
		if ( is_array( $dataset_ids ) && count( $dataset_ids ) > 0 ) {
			return false;
		}

		return $this->config_dataset_sub_type( $config ) !== '';
	}

	/**
	 * Dataset IDs for the config sub_type delete mode.
	 *
	 * @param array $config Job configuration.
	 *
	 * @return int[]
	 */
	private function dataset_ids_for_config_sub_type( array $config ): array {
		$sub_type = $this->config_dataset_sub_type( $config );
		if ( $sub_type === '' ) {
			return [];
		}

		$dataset_ids = Dataset::where( [ 'source_sub_type' => $sub_type ], - 1, - 1 )->pluck( 'id' );

		return is_array( $dataset_ids ) ? $dataset_ids : [];
	}

	/**
	 * Human-friendly label for config sub type (CPT slug).
	 *
	 * @param array $config Job configuration.
	 *
	 * @return string
	 */
	private function config_sub_type_label( array $config ): string {
		$sub_type = $this->config_dataset_sub_type( $config );
		if ( $sub_type === '' ) {
			return __( 'datasets', 'limb-chatbot' );
		}

		$post_type = get_post_type_object( $sub_type );
		if ( $post_type && ! empty( $post_type->labels->name ) ) {
			return (string) $post_type->labels->name;
		}

		return str_replace( '_', ' ', $sub_type );
	}

	/**
	 * Resolves dataset IDs when `config.dataset_ids` is `all` (optional `config.filters` like REST list).
	 *
	 * @param array $config Job configuration.
	 * @param Job $job Job instance.
	 *
	 * @return int[]
	 */
	private function resolve_dataset_ids_for_all_flag( array $config, Job $job ): array {
		$filters = $config['filters'] ?? null;
		if ( is_array( $filters ) && [] !== $filters ) {
			$type   = $job->get_sub_type() === 'informational' ? Dataset::TYPE_INFORMATIONAL_KNOWLEDGE : Dataset::TYPE_ACTIONABLE_KNOWLEDGE;
			$sub    = $this->config_dataset_sub_type( $config );
			$params = array_merge(
				[
					'type'            => $type,
					'source_type'     => 'cpt',
					'source_sub_type' => '' !== $sub ? $sub : 'product',
				],
				$filters
			);
			unset( $params['dataset_ids'], $params['include'] );

			return $this->dataset_repository->get_matching_dataset_ids( $params );
		}

		$type  = $job->get_sub_type() === 'informational' ? Dataset::TYPE_INFORMATIONAL_KNOWLEDGE : Dataset::TYPE_ACTIONABLE_KNOWLEDGE;
		$where = [ 'type' => $type ];
		$sub   = $this->config_dataset_sub_type( $config );
		if ( '' !== $sub ) {
			$where['source_sub_type'] = $sub;
		}
		$ids = Dataset::where( $where, - 1, - 1 )->pluck( 'id' );

		return is_array( $ids ) ? array_map( 'intval', array_values( $ids ) ) : [];
	}

	/**
	 * Validate job configuration.
	 *
	 * Validates that dataset IDs are provided and exist in the database.
	 * Extracted from Handler::validate_params()
	 *
	 * @param array $config Job configuration.
	 * @param string|null $chatbot_uuid Chatbot UUID (optional, not used for deletion).
	 *
	 * @return bool True if valid.
	 * @throws Exception If validation fails.
	 * @since 1.1.0
	 */
	public function validate( array $config, ?string $chatbot_uuid = null ): bool {
		if ( $this->deletes_by_config_dataset_sub_type( $config ) ) {
			$ids = $this->dataset_ids_for_config_sub_type( $config );
			if ( empty( $ids ) ) {
				throw new Exception(
					Error_Codes::NOT_FOUND,
					sprintf(
					/* translators: %s is a CPT/dataset type label, e.g. Products. */
						__( 'No synced %s found to delete.', 'limb-chatbot' ),
						$this->config_sub_type_label( $config )
					)
				);
			}

			return true;
		}

		$dataset_ids = $config['dataset_ids'] ?? [];

		// Validate dataset IDs are provided
		if ( empty( $dataset_ids ) || ( ! is_array( $dataset_ids ) && $dataset_ids != 'all' ) ) {
			throw new Exception(
				Error_Codes::EMPTY_VALUE,
				__( 'Dataset IDs are required for dataset deletion.', 'limb-chatbot' )
			);
		}

		if ( is_array( $dataset_ids ) ) {
			// Validate datasets exist
			$datasets = Dataset::where( [ 'id' => $dataset_ids ] );
			if ( $datasets->is_empty() ) {
				throw new Exception(
					Error_Codes::NOT_FOUND,
					__( 'Datasets not found', 'limb-chatbot' )
				);
			}
		}

		return true;
	}

	/**
	 * Get total number of datasets to delete.
	 *
	 * Calculates total without actually fetching datasets.
	 * Essential for large deletion operations 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 {
		$dataset_ids = $config['dataset_ids'] ?? [];
		if ( $dataset_ids == 'all' ) {
			$total = count( $this->resolve_dataset_ids_for_all_flag( $config, $job ) );
		} elseif ( $this->deletes_by_config_dataset_sub_type( $config ) ) {
			$total = Dataset::count( [ 'source_sub_type' => $this->config_dataset_sub_type( $config ) ] );
		} else {
			if ( empty( $dataset_ids ) || ! is_array( $dataset_ids ) ) {
				throw new Exception(
					Error_Codes::EMPTY_VALUE,
					__( 'There is no dataset to delete', 'limb-chatbot' )
				);
			}

			$total = count( $dataset_ids );
		}

		if ( empty( $total ) ) {
			if ( $this->deletes_by_config_dataset_sub_type( $config ) ) {
				throw new Exception(
					Error_Codes::NOT_FOUND,
					sprintf(
					/* translators: %s is a CPT/dataset type label, e.g. Products. */
						__( 'No synced %s found to delete.', 'limb-chatbot' ),
						$this->config_sub_type_label( $config )
					)
				);
			}

			throw new Exception(
				Error_Codes::EMPTY_VALUE,
				__( 'Total is empty', 'limb-chatbot' )
			);
		}

		return (int) $total;
	}

	/**
	 * Generate a batch of tasks for dataset deletion.
	 *
	 * Creates one task per dataset ID. Each task will delete a single dataset.
	 *
	 * @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.
	 * @since 1.1.0
	 */
	public function generate_task_batch( Job $job, array $config, int $offset, int $limit ): int {
		$dataset_ids = $config['dataset_ids'] ?? [];
		if ( $dataset_ids == 'all' ) {
			$dataset_ids = $this->resolve_dataset_ids_for_all_flag( $config, $job );
		} elseif ( $this->deletes_by_config_dataset_sub_type( $config ) ) {
			$dataset_ids = $this->dataset_ids_for_config_sub_type( $config );
		}
		if ( empty( $dataset_ids ) || ! is_array( $dataset_ids ) ) {
			return 0;
		}

		// Get the slice of dataset IDs for this batch
		$batch_ids = array_slice( $dataset_ids, $offset, $limit );

		// Enrich task payload with dataset metadata that we may need on completion
		// (because the datasets themselves will be deleted by the time `complete()` runs).
		$datasets_by_id = [];
		$batch_datasets = Dataset::where( [ 'id' => $batch_ids ], - 1, - 1 );
		if ( $batch_datasets && method_exists( $batch_datasets, 'each' ) ) {
			$batch_datasets->each( static function ( Dataset $dataset ) use ( &$datasets_by_id ) {
				$datasets_by_id[ (int) $dataset->get_id() ] = $dataset;
			} );
		}

		$task_count = 0;

		foreach ( $batch_ids as $dataset_id ) {
			// Create task payload with dataset ID
			$payload = [
				'dataset_id' => (int) $dataset_id,
			];

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

		return $task_count;
	}

	/**
	 * Process a single task (delete a dataset).
	 *
	 * Deletes a dataset and all its associated data:
	 * - Checks if dataset is last in storage and removes vector index if so
	 * - Clears dataset entries using Dataset_Service
	 * - Deletes dataset metadata
	 * - Deletes the dataset itself
	 *
	 * Extracted from Process::process()
	 *
	 * @param Task $task Task to process.
	 *
	 * @return bool True on success, false on failure.
	 * @throws Exception If processing fails.
	 * @since 1.1.0
	 */
	public function process_task( Task $task ): bool {
		$payload    = $task->get_payload();
		$dataset_id = $payload['dataset_id'] ?? null;

		if ( empty( $dataset_id ) ) {
			// Invalid payload, skip
			return true;
		}

		// Find the dataset
		$dataset = Dataset::find( $dataset_id );

		if ( ! $dataset instanceof Dataset ) {
			// Dataset not found, skip (may have been deleted already)
			return true;
		}
		try {
			// Check if dataset is last in storage and remove vector index if so
			$vector_index_id = $dataset->is_last_in_storage();
			if ( $vector_index_id ) {
				$this->vector_index_service->remove( $vector_index_id );
			}
		} catch (\Exception $e){
			//do nothing
			Helper::log($e);
		}

		// Clear dataset entries (deletes all entries and their vectors)
		$this->dataset_service->clear( $dataset );

		// Delete dataset metadata
		Dataset_Meta::delete( [ 'dataset_id' => $dataset_id ] );

		// Delete the dataset itself
		Dataset::delete( [ 'id' => $dataset_id ] );

		return true;
	}

	/**
	 * Determine if an exception is critical.
	 *
	 * Uses parent's default implementation which checks common critical codes.
	 *
	 * @param Exception $exception Exception that occurred.
	 *
	 * @return bool True if critical.
	 * @since 1.1.0
	 */
	public function is_critical_error( Exception $exception ): bool {
		// Use parent's default implementation
		return parent::is_critical_error( $exception );
	}

	/**
	 * Job completion hook.
	 *
	 * If we deleted all actionable CPT datasets for a given CPT slug (source_sub_type),
	 * remove that CPT from the chatbot `search_cpts` setting.
	 *
	 * This keeps the searchable CPT list aligned with what is actually indexed.
	 *
	 * @param Job $job
	 *
	 * @return void
	 * @since 1.1.0
	 */
	public function complete( Job $job ): void {
		$config = $job->get_config() ?? [];

		// Only apply this cleanup for actionable delete jobs.
		if ( $job->get_sub_type() === 'informational' ) {
			return;
		}

		$affected = [];

		// Bulk delete mode: config.sub_type means "delete all datasets for this CPT".
		$config_sub_type = sanitize_key( $this->config_dataset_sub_type( $config ) );
		if ( '' !== $config_sub_type ) {
			$affected[] = $config_sub_type;
		}

		// Explicit dataset_ids mode: we track affected CPT slugs during task processing.
		$stats   = $job->get_stats() ?? [];
		$tracked = $stats[ self::STATS_AFFECTED_ACTIONABLE_CPT_SUB_TYPES ] ?? [];
		if ( is_array( $tracked ) ) {
			$affected = array_merge( $affected, $tracked );
		}

		// Fallback: if the job completed without tracking stats (older code, or a crash/resume),
		// attempt to infer affected CPTs from task payloads.
		if ( empty( $affected ) ) {
			$tasks = Task::where( [ 'job_id' => $job->get_id() ], - 1, - 1 );
			if ( $tasks && method_exists( $tasks, 'each' ) ) {
				$tasks->each( static function ( Task $task ) use ( &$affected ) {
					$payload = $task->get_payload();
					if ( ! is_array( $payload ) ) {
						return;
					}
					$type = sanitize_key( (string) ( $payload['source_type'] ?? '' ) );
					if ( 'cpt' !== $type ) {
						return;
					}
					$dataset_type = (int) ( $payload['dataset_type'] ?? 0 );
					if ( $dataset_type !== Dataset::TYPE_ACTIONABLE_KNOWLEDGE ) {
						return;
					}
					$sub_type = sanitize_key( (string) ( $payload['source_sub_type'] ?? '' ) );
					if ( '' !== $sub_type ) {
						$affected[] = $sub_type;
					}
				} );
			}
		}

		$affected = array_values( array_unique( array_filter( array_map( 'sanitize_key', $affected ) ) ) );
		if ( empty( $affected ) ) {
			return;
		}

		$setting_key = 'lbaic.utilities.chatbot.search_cpts';
		$setting     = Setting::find( $setting_key );
		if ( ! $setting instanceof Setting ) {
			return;
		}

		$search_cpts = $setting->get_value();
		if ( ! is_array( $search_cpts ) || empty( $search_cpts ) ) {
			return;
		}

		$search_cpts = array_values( array_unique( array_filter( array_map( static function ( $item ) {
			return sanitize_key( (string) $item );
		}, $search_cpts ) ) ) );

		$changed = false;
		foreach ( $affected as $source_sub_type ) {
			if ( ! in_array( $source_sub_type, $search_cpts, true ) ) {
				continue;
			}

			$remaining = (int) Dataset::count( [
				'type'            => Dataset::TYPE_ACTIONABLE_KNOWLEDGE,
				'source_type'     => 'cpt',
				'source_sub_type' => $source_sub_type,
			] );

			if ( $remaining > 0 ) {
				continue;
			}

			$search_cpts = array_values( array_diff( $search_cpts, [ $source_sub_type ] ) );
			$changed     = true;
		}

		if ( $changed ) {
			Setting::update( [ 'key' => $setting_key ], [ 'value' => $search_cpts ] );
		}
	}
}
