<?php
/**
 * Index Settings Service
 *
 * Manages AI Store Index configuration stored in database profiles table.
 *
 * @package AIPT\Api\StoreIndex
 * @since 2.5.0
 * @updated 2.5.0 Refactored for profile-based storage
 */

namespace AIPT\Api\StoreIndex;

/**
 * IndexSettings class
 *
 * Handles settings CRUD operations for a specific Store Index profile.
 */
class IndexSettings {

	/**
	 * Profile ID.
	 *
	 * @var int
	 */
	private $profile_id;

	/**
	 * Static cache for profile data, keyed by profile ID.
	 *
	 * This ensures that all IndexSettings instances for the same profile share
	 * the same cache, preventing stale data issues when one instance updates
	 * and another reads without knowing about the update.
	 *
	 * @var array<int, array|null>
	 */
	private static $profile_cache = array();

	/**
	 * Table name for profiles.
	 *
	 * @var string
	 */
	private $table_name;

	/**
	 * Default settings values.
	 *
	 * @var array
	 */
	private static $defaults = array(
		'enabled'            => false,
		'max_products'       => 10000,
		'categories'         => array(),
		'stock_filter'       => 'all',
		'date_filter_days'   => 0,
		'tags'               => array(),
		'batch_size'         => 100,
		'filter_mode'        => 'all',

		'scheduled_enabled'  => false,
		'schedule_frequency' => 'daily',

		'included_fields'    => null, 
	
	);

	/**
	 * Default status values.
	 *
	 * @var array
	 */
	private static $status_defaults = array(
		'status'                   => 'idle',
		'last_build'               => null,
		'product_count'            => 0,
		'category_count'           => 0,
		'file_size'                => 0,
		'trimmed_count'            => 0,

		'shard_count'              => 0,
		'error'                    => null,

		'error_code'               => null,

		'status_version'           => 0,

		'phase'                    => 'idle',
		'build_progress'           => 0,
		'progress_percent'         => 0,
		'processed_product_count'  => 0,
		'estimated_total_products' => 0,
		'effective_filter_summary' => array(),
		'build_job_id'             => 0,
		'last_processed_id'        => 0, 
	);

	/**
	 * Validation rules for settings.
	 *
	 * @var array
	 */
	private static $validation_rules = array(
		'max_products'       => array(
			'min' => 1,
			'max' => 50000,
		),
		'date_filter_days'   => array(
			'min' => 0,
			'max' => 365,
		),
		'batch_size'         => array(
			'min' => 50,
			'max' => 500,
		),
		'stock_filter'       => array( 'allowed' => array( 'all', 'instock', 'outofstock' ) ),
		'filter_mode'        => array( 'allowed' => array( 'all', 'any' ) ),

		'schedule_frequency' => array( 'allowed' => array( 'hourly', 'two_hourly', 'six_hourly', 'daily' ) ),
	);

	/**
	 * Constructor.
	 *
	 * @param int $profile_id Profile ID. If 0, uses the active profile.
	 */
	public function __construct( int $profile_id = 0 ) {
		global $wpdb;
		$this->table_name = $wpdb->prefix . 'aipt_store_index_profiles';

		if ( 0 === $profile_id ) {

			$profile_manager = new ProfileManager();
			$active          = $profile_manager->getActive();

			if ( $active ) {
				$this->profile_id = (int) $active['id'];
			} else {

				$default          = $profile_manager->getDefault();
				$this->profile_id = $default ? (int) $default['id'] : 1;
			}
		} else {
			$this->profile_id = $profile_id;
		}
	}

	/**
	 * Get the profile ID.
	 *
	 * @return int
	 */
	public function getProfileId(): int {
		return $this->profile_id;
	}

	/**
	 * Load and cache profile data from database.
	 *
	 * Uses static cache keyed by profile_id to ensure all instances
	 * for the same profile share the same cached data.
	 *
	 * @return array|null Profile data or null if not found.
	 */
	private function loadProfile(): ?array {

		if ( array_key_exists( $this->profile_id, self::$profile_cache ) ) {
			return self::$profile_cache[ $this->profile_id ];
		}

		global $wpdb;

		$sql = "SELECT * FROM {$this->table_name} WHERE id = %d";

		$profile = $wpdb->get_row($wpdb->prepare($sql, $this->profile_id), ARRAY_A);

		if ( ! $profile ) {
			self::$profile_cache[ $this->profile_id ] = null;
			return null;
		}

		self::$profile_cache[ $this->profile_id ] = array(
			'id'         => (int) $profile['id'],
			'name'       => $profile['name'],
			'is_default' => (bool) $profile['is_default'],
			'is_active'  => (bool) $profile['is_active'],
			'settings'   => json_decode( $profile['settings'], true ) ?? array(),
			'status'     => json_decode( $profile['status'], true ) ?? array(),

			'updated_at' => isset( $profile['updated_at'] ) ? (string) $profile['updated_at'] : null,
		);

		return self::$profile_cache[ $this->profile_id ];
	}

	/**
	 * Clear the cached profile data for this profile.
	 *
	 * Clears the static cache entry for this profile_id, affecting
	 * all IndexSettings instances that share this profile.
	 *
	 * @return void
	 */
	public function clearCache(): void {
		unset( self::$profile_cache[ $this->profile_id ] );
	}

	/**
	 * Clear all cached profile data.
	 *
	 * Use sparingly - typically only needed in tests or
	 * when performing bulk operations across all profiles.
	 *
	 * @return void
	 */
	public static function clearAllCache(): void {
		self::$profile_cache = array();
	}

	/**
	 * Check if the feature is enabled.
	 *
	 * @return bool
	 */
	public function isEnabled(): bool {
		return (bool) $this->get( 'enabled', false );
	}

	/**
	 * Get all settings as array.
	 *
	 * @return array Settings with all keys and their values.
	 */
	public function getAll(): array {
		$profile = $this->loadProfile();
		if ( ! $profile ) {
			return self::$defaults;
		}

		$settings = array();
		foreach ( self::$defaults as $key => $default ) {
			$settings[ $key ] = $this->get( $key, $default );
		}

		return $settings;
	}

	/**
	 * Get a specific setting value.
	 *
	 * Global settings (enabled, max_products, batch_size, scheduled_enabled, schedule_frequency)
	 * are always read from the default profile to ensure consistency across all profiles.
	 *
	 * @param string $key     Setting key.
	 * @param mixed  $default Default value if not set.
	 * @return mixed Setting value.
	 */
	public function get( string $key, $default = null ) {
		if ( null === $default && isset( self::$defaults[ $key ] ) ) {
			$default = self::$defaults[ $key ];
		}

		if ( in_array( $key, ProfileManager::getGlobalSettingsKeys(), true ) ) {
			return $this->getGlobalSetting( $key, $default );
		}

		if ( 'included_fields' === $key && null === $default ) {
			$default = ProfileManager::getDefaultIncludedFields();
		}

		$profile = $this->loadProfile();
		if ( ! $profile ) {
			return $default;
		}

		$value = $profile['settings'][ $key ] ?? $default;

		if ( in_array( $key, array( 'categories', 'tags', 'included_fields' ), true ) && ! is_array( $value ) ) {
			$value = ! empty( $value ) ? (array) $value : array();
		}

		if ( in_array( $key, array( 'enabled', 'scheduled_enabled' ), true ) ) {
			$value = (bool) $value;
		}

		if ( in_array( $key, array( 'max_products', 'date_filter_days', 'batch_size' ), true ) ) {
			$value = (int) $value;
		}

		return $value;
	}

	/**
	 * Get a global setting value from the default profile.
	 *
	 * Global settings are stored in the default profile and shared across all profiles.
	 * This ensures consistency for settings like 'enabled', 'max_products', etc.
	 *
	 * @param string $key     Setting key.
	 * @param mixed  $default Default value if not set.
	 * @return mixed Setting value with proper type casting.
	 */
	private function getGlobalSetting( string $key, $default = null ) {
		$profile_manager = new ProfileManager();
		$global_settings = $profile_manager->getGlobalSettings();

		$value = $global_settings[ $key ] ?? $default;

		if ( in_array( $key, array( 'enabled', 'scheduled_enabled' ), true ) ) {
			$value = (bool) $value;
		}

		if ( in_array( $key, array( 'max_products', 'batch_size' ), true ) ) {
			$value = (int) $value;
		}

		return $value;
	}

	/**
	 * Update settings from array.
	 *
	 * Global settings (enabled, max_products, batch_size, scheduled_enabled, schedule_frequency)
	 * are automatically routed to ProfileManager to ensure consistency across all profiles.
	 *
	 * @param array $settings Partial or full settings array.
	 * @return bool True on success.
	 * @throws \InvalidArgumentException If validation fails.
	 */
	public function update( array $settings ): bool {

		$this->validate( $settings );

		$profile = $this->loadProfile();
		if ( ! $profile ) {
			return false;
		}

		/**
		 * Fires before index settings are written to the database.
		 *
		 * Allows premium features to react to settings changes (e.g., scheduling).
		 * At this point, $this->get() still returns OLD values, while $settings
		 * contains the NEW values being saved. This allows handlers to compare
		 * old vs new and take appropriate action.
		 *
		 * Core code fires this filter; only premium responds to handle scheduling.
		 *
		 * @since 2.5.0
		 *
		 * @param array         $settings       The settings being updated (new values).
		 * @param IndexSettings $index_settings This IndexSettings instance (old values via get()).
		 */
		$settings = apply_filters( 'aipt_index_settings_update', $settings, $this );

		$global_settings_keys = ProfileManager::getGlobalSettingsKeys();
		$global_settings      = array();
		$profile_settings     = array();

		foreach ( $settings as $key => $value ) {

			if ( ! array_key_exists( $key, self::$defaults ) ) {
				continue;
			}

			if ( in_array( $key, $global_settings_keys, true ) ) {
				$global_settings[ $key ] = $this->sanitize( $key, $value );
			} else {
				$profile_settings[ $key ] = $this->sanitize( $key, $value );
			}
		}

		if ( ! empty( $global_settings ) ) {
			try {
				$profile_manager       = new ProfileManager();
				$global_update_success = $profile_manager->updateGlobalSettings( $global_settings );
				if ( ! $global_update_success ) {

					return false; 
				}
			} catch ( \RuntimeException $e ) {

				return false; 
			} catch ( \InvalidArgumentException $e ) {

				return false; 
			}
		}

		$result = true;
		if ( ! empty( $profile_settings ) ) {
			$current_settings = $profile['settings'];

			foreach ( $profile_settings as $key => $value ) {
				$current_settings[ $key ] = $value;
			}

			global $wpdb;

			$db_result = $wpdb->update(
				$this->table_name,
				array(
					'settings'   => wp_json_encode( $current_settings ),
					'updated_at' => current_time( 'mysql' ),
				),
				array( 'id' => $this->profile_id ),
				array( '%s', '%s' ),
				array( '%d' )
			);

			$result = false !== $db_result;
		}

		$this->clearCache();

		return $result;
	}

	/**
	 * Get index status metadata.
	 *
	 * @return array Status with keys: status, last_build, product_count, etc.
	 */
	public function getStatus(): array {
		$profile = $this->loadProfile();
		if ( ! $profile ) {
			return array_merge(
				self::$status_defaults,
				array(
					'enabled'     => false,
					'is_building' => false,
				)
			);
		}

		$status = $profile['status'];

		return array(
			'enabled'                  => $this->isEnabled(),
			'status'                   => $status['status'] ?? 'idle',
			'last_build'               => $status['last_build'] ?? null,
			'product_count'            => (int) ( $status['product_count'] ?? 0 ),
			'category_count'           => (int) ( $status['category_count'] ?? 0 ),
			'file_size'                => (int) ( $status['file_size'] ?? 0 ),
			'trimmed_count'            => (int) ( $status['trimmed_count'] ?? 0 ),

			'shard_count'              => (int) ( $status['shard_count'] ?? 0 ),
			'error'                    => $status['error'] ?? null,

			'error_code'               => isset( $status['error_code'] ) ? (string) $status['error_code'] : null,
			'build_progress'           => (int) ( $status['build_progress'] ?? 0 ),
			'is_building'              => ( $status['status'] ?? 'idle' ) === 'building',

			'progress_percent'         => (int) ( $status['progress_percent'] ?? 0 ),
			'processed_product_count'  => (int) ( $status['processed_product_count'] ?? 0 ),
			'estimated_total_products' => (int) ( $status['estimated_total_products'] ?? 0 ),
			'effective_filter_summary' => isset( $status['effective_filter_summary'] ) && is_array( $status['effective_filter_summary'] )
				? $status['effective_filter_summary']
				: array(),

			'build_job_id'             => (int) ( $status['build_job_id'] ?? 0 ),
			'last_processed_id'        => (int) ( $status['last_processed_id'] ?? 0 ),

			'meta'                     => $this->buildStatusMeta( $profile, $status ),
		);
	}

	/**
	 * Build the `meta` sub-object surfaced by getStatus().
	 *
	 * Kept in its own method so the ShardManager probe (which can be
	 * relatively expensive when the map is first read in a request) is
	 * lazy / isolated from the rest of the getStatus shape, and so unit
	 * tests can override it via reflection.
	 *
	 * @param array $profile Raw profile row (from loadProfile()).
	 * @param array $status  Raw status JSON.
	 * @return array
	 */
	protected function buildStatusMeta( array $profile, array $status ): array {
		$map_version  = null;
		$index_health = null;

		$shard_manager = new ShardManager( $this->profile_id );
		$map           = $shard_manager->loadMap();
		if ( is_array( $map ) ) {
			$map_version = isset( $map['meta']['map_version'] )
				? (int) $map['meta']['map_version']
				: null;
		} else {
			$index_health = $shard_manager->getLastLoadError();
		}

		return array(
			'status_version' => (int) ( $status['status_version'] ?? 0 ),
			'updated_at'     => isset( $profile['updated_at'] ) ? (string) $profile['updated_at'] : null,
			'map_version'    => $map_version,
			'profile_id'     => (int) $this->profile_id,
			'job_id'         => (int) ( $status['build_job_id'] ?? 0 ),
			'error_code'     => isset( $status['error_code'] ) ? (string) $status['error_code'] : null,
			'index_health'   => $index_health, 
			'phase'          => isset( $status['phase'] ) ? (string) $status['phase'] : 'idle',
			'built_empty'    => ( $status['status'] ?? '' ) === 'built_empty',
			'effective_filter_summary' => isset( $status['effective_filter_summary'] ) && is_array( $status['effective_filter_summary'] )
				? $status['effective_filter_summary']
				: array(),
		);
	}

	/**
	 * Update status metadata.
	 *
	 * @param array $status Partial or full status array.
	 * @return void
	 */
	public function updateStatus( array $status ): void {
		$profile = $this->loadProfile();
		if ( ! $profile ) {
			return;
		}

		$allowed_keys = array(
			'status',
			'last_build',
			'product_count',
			'category_count',
			'file_size',
			'trimmed_count',

			'shard_count',
			'error',

			'error_code',

			'status_version',

			'phase',
			'build_progress',
			'build_job_id',

			'progress_percent',
			'processed_product_count',
			'estimated_total_products',
			'effective_filter_summary',
			'last_processed_id', 
		);

		$current_status = $profile['status'];

		foreach ( $status as $key => $value ) {
			if ( in_array( $key, $allowed_keys, true ) ) {
				$current_status[ $key ] = $value;
			}
		}

		if ( ! array_key_exists( 'status_version', $status ) ) {
			$current_status['status_version'] = (int) ( $current_status['status_version'] ?? 0 ) + 1;
		}

		global $wpdb;

		$wpdb->update(
			$this->table_name,
			array(
				'status'     => wp_json_encode( $current_status ),
				'updated_at' => current_time( 'mysql' ),
			),
			array( 'id' => $this->profile_id ),
			array( '%s', '%s' ),
			array( '%d' )
		);

		$this->clearCache();
	}

	/**
	 * Get included fields for this profile.
	 *
	 * Always includes mandatory fields (title, categories) regardless of settings.
	 *
	 * @return array List of field names to include in the index.
	 */
	public function getIncludedFields(): array {
		$fields = $this->get( 'included_fields', self::$defaults['included_fields'] );

		return ProfileManager::ensureMandatoryFields( $fields );
	}

	/**
	 * Check if a field is included in the index.
	 *
	 * @param string $field Field name.
	 * @return bool True if field is included.
	 */
	public function isFieldIncluded( string $field ): bool {
		$included = $this->getIncludedFields();
		return in_array( $field, $included, true );
	}

	/**
	 * Validate settings against rules.
	 *
	 * @param array $settings Settings to validate.
	 * @throws \InvalidArgumentException If validation fails.
	 */
	private function validate( array $settings ): void {
		foreach ( $settings as $key => $value ) {
			if ( ! isset( self::$validation_rules[ $key ] ) ) {
				continue;
			}

			$rules = self::$validation_rules[ $key ];

			if ( isset( $rules['min'] ) && isset( $rules['max'] ) ) {
				$int_value = (int) $value;
				if ( $int_value < $rules['min'] || $int_value > $rules['max'] ) {
					throw new \InvalidArgumentException(
						sprintf(
							/* translators: 1: Setting key name, 2: Minimum value, 3: Maximum value */
							__( '%1$s must be between %2$d and %3$d', 'ai-product-tools' ),
							$key,
							$rules['min'],
							$rules['max']
						)
					);
				}
			}

			if ( isset( $rules['allowed'] ) ) {
				if ( ! in_array( $value, $rules['allowed'], true ) ) {
					throw new \InvalidArgumentException(
						sprintf(
							/* translators: 1: Setting key name, 2: Allowed values list */
							__( '%1$s must be one of: %2$s', 'ai-product-tools' ),
							$key,
							implode( ', ', $rules['allowed'] )
						)
					);
				}
			}

		}

		if ( isset( $settings['categories'] ) && ! empty( $settings['categories'] ) ) {

			$settings['categories'] = apply_filters( 'aipt_index_validate_categories', $settings['categories'] );

			foreach ( $settings['categories'] as $term_id ) {
				if ( ! term_exists( (int) $term_id, 'product_cat' ) ) {
					throw new \InvalidArgumentException(
						sprintf(
							/* translators: %d: Invalid category term ID */
							__( 'Invalid category ID: %d', 'ai-product-tools' ),
							$term_id
						)
					);
				}
			}
		}

		if ( isset( $settings['tags'] ) && ! empty( $settings['tags'] ) ) {

			$settings['tags'] = apply_filters( 'aipt_index_validate_tags', $settings['tags'] );

			foreach ( $settings['tags'] as $term_id ) {
				if ( ! term_exists( (int) $term_id, 'product_tag' ) ) {
					throw new \InvalidArgumentException(
						sprintf(
							/* translators: %d: Invalid tag term ID */
							__( 'Invalid tag ID: %d', 'ai-product-tools' ),
							$term_id
						)
					);
				}
			}
		}

		if ( isset( $settings['included_fields'] ) ) {

			$settings['included_fields'] = apply_filters( 'aipt_index_validate_included_fields', $settings['included_fields'] );

			$available = ProfileManager::getAvailableFields();
			foreach ( $settings['included_fields'] as $field ) {
				if ( ! in_array( $field, $available, true ) ) {
					throw new \InvalidArgumentException(
						sprintf(
							/* translators: %s: Invalid field name */
							__( 'Invalid field: %s', 'ai-product-tools' ),
							$field
						)
					);
				}
			}
		}

	}

	/**
	 * Sanitize a setting value based on its key.
	 *
	 * @param string $key   Setting key.
	 * @param mixed  $value Value to sanitize.
	 * @return mixed Sanitized value.
	 */
	private function sanitize( string $key, $value ) {
		switch ( $key ) {
			case 'enabled':
			case 'scheduled_enabled':
				return (bool) $value;

			case 'max_products':
			case 'date_filter_days':
			case 'batch_size':
				return absint( $value );

			case 'categories':
			case 'tags':
				return array_map( 'absint', (array) $value );

			case 'included_fields':
				$available = ProfileManager::getAvailableFields();
				return array_values( array_intersect( (array) $value, $available ) );

			case 'stock_filter':
			case 'filter_mode':
			case 'schedule_frequency':
				return sanitize_text_field( $value );

			default:
				return $value;
		}
	}

	/**
	 * Unschedule all index-related jobs (used on disable/deactivation).
	 *
	 * @return void
	 */
	public function unscheduleAllJobs(): void {
		if ( ! function_exists( 'as_unschedule_all_actions' ) ) {
			return;
		}

		as_unschedule_all_actions(
			'aipt_scheduled_index_rebuild',
			array( 'profile_id' => $this->profile_id ),
			'aipt-store-index'
		);
		as_unschedule_all_actions(
			'aipt_build_store_index',
			array( 'profile_id' => $this->profile_id ),
			'aipt-store-index'
		);
	}

	/**
	 * Get default settings values.
	 *
	 * @return array
	 */
	public static function getDefaults(): array {
		return self::$defaults;
	}

	/**
	 * Get default included fields.
	 *
	 * Delegates to ProfileManager::getDefaultIncludedFields() since the
	 * defaults array stores null to allow runtime resolution.
	 *
	 * @return array Default included fields.
	 */
	public static function getDefaultIncludedFields(): array {
		return ProfileManager::getDefaultIncludedFields();
	}
}
