<?php
/**
 * Performance Settings — sanitize + defaults + reschedule helpers.
 *
 * Centralizes user-configurable Niwa polling/cron intervals so site owners
 * on smaller/shared hosts can tune them without editing code. Backend cron
 * intervals (Telegram webhook fallback poll, background tick, scheduled tasks,
 * frontend chat queue, memory embeddings) and browser-side AJAX poll intervals (admin
 * history poll, embeddings status poll) all live in one option.
 *
 * Architecture mirrors Cart Recovery: separate value/sanitize class kept
 * away from the UI renderer, with static `defaults()` and `sanitize()`
 * entry points so callers can read defaults consistently without booting
 * the admin UI.
 *
 * @package Niwa_AI
 * @since   0.12.78
 */

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

class Niwa_AI_Performance_Settings {

	/**
	 * Cron hooks managed by this settings page. Map of option key → WP-Cron
	 * hook name. The option key (left) is what users see/save; the hook
	 * name (right) is what `wp_schedule_event` registers.
	 *
	 * @var array<string, string>
	 */
	const CRON_HOOK_MAP = array(
		'telegram_poll_seconds'        => 'niwa_ai_admin_autonomy_poll_telegram',
		'scheduled_tasks_seconds'      => 'niwa_ai_admin_autonomy_process_scheduled_tasks',
		'background_tick_seconds'      => 'niwa_ai_admin_autonomy_background_tick',
		'frontend_chat_queue_seconds'  => 'niwa_ai_process_frontend_chat_queue',
		'memory_embeddings_seconds'    => 'niwa_ai_process_memory_embeddings',
	);

	/**
	 * Allowed interval buckets per key, in seconds. Keeps users from
	 * accidentally setting "1s" or "1d" pathological values. WP-Cron
	 * resolution is roughly 60s on real-world hosts so anything < 30s
	 * is best-effort.
	 *
	 * @var array<string, int[]>
	 */
	const ALLOWED_BUCKETS = array(
		'telegram_poll_seconds'        => array( 30, 60, 120, 300, 600 ),
		'scheduled_tasks_seconds'      => array( 30, 60, 120, 300 ),
		'background_tick_seconds'      => array( 60, 300, 600, 900, 1800, 3600 ),
		'frontend_chat_queue_seconds'  => array( 60, 120, 300, 600 ),
		'memory_embeddings_seconds'    => array( 300, 900, 1800, 3600 ),
		'admin_history_poll_ms'        => array( 30000, 60000, 120000, 300000 ),
		'embeddings_status_poll_ms'    => array( 3000, 5000, 10000, 30000 ),
	);

	/**
	 * Built-in defaults. These match what was hardcoded in the plugin
	 * prior to this settings page, so existing installs preserve their
	 * behavior until the user opts in to different values.
	 *
	 * @return array<string, int>
	 */
	public static function defaults() {
		return array(
			'telegram_poll_seconds'        => 30,
			'scheduled_tasks_seconds'      => 30,
			'background_tick_seconds'      => 60,
			'frontend_chat_queue_seconds'  => 60,
			'memory_embeddings_seconds'    => 300,
			'admin_history_poll_ms'        => 60000,
			'embeddings_status_poll_ms'    => 5000,
		);
	}

	/**
	 * Load the current effective settings (stored option merged with
	 * defaults). Safe to call extremely early — won't trigger autoload
	 * because the option is registered with `autoload=no` semantics.
	 *
	 * @return array<string, int>
	 */
	public static function get() {
		$stored = get_option( Niwa_AI_Constants::OPT_PERFORMANCE_SETTINGS, array() );
		if ( ! is_array( $stored ) ) {
			$stored = array();
		}
		return self::normalize( array_merge( self::defaults(), $stored ) );
	}

	/**
	 * Sanitize raw input from the settings form. Snaps each value to the
	 * nearest allowed bucket; unknown keys are dropped silently.
	 *
	 * @param mixed $input Raw $_POST payload for the option.
	 * @return array<string, int>
	 */
	public static function sanitize( $input ) {
		if ( ! is_array( $input ) ) {
			return self::defaults();
		}
		$merged = array_merge( self::defaults(), $input );
		$out    = self::normalize( $merged );

		self::reschedule_all_jobs( $out );

		return $out;
	}

	/**
	 * Coerce a candidate value to the nearest allowed bucket for the key.
	 *
	 * @param array<string, mixed> $values Input values.
	 * @return array<string, int>
	 */
	private static function normalize( array $values ) {
		$defaults = self::defaults();
		$out      = array();
		foreach ( $defaults as $key => $default_value ) {
			$candidate = isset( $values[ $key ] ) ? intval( $values[ $key ] ) : $default_value;
			$out[ $key ] = self::snap_to_bucket( $key, $candidate, $default_value );
		}
		return $out;
	}

	/**
	 * Snap a candidate to the closest allowed bucket. If candidate is
	 * already in the bucket list it's returned as-is.
	 *
	 * @param string $key Key in ALLOWED_BUCKETS.
	 * @param int    $candidate Candidate value.
	 * @param int    $default_value Fallback if key is unknown.
	 * @return int
	 */
	private static function snap_to_bucket( $key, $candidate, $default_value ) {
		if ( ! isset( self::ALLOWED_BUCKETS[ $key ] ) ) {
			return $default_value;
		}
		$allowed = self::ALLOWED_BUCKETS[ $key ];
		if ( in_array( $candidate, $allowed, true ) ) {
			return $candidate;
		}
		// Snap to nearest.
		$best = $allowed[0];
		$best_diff = abs( $candidate - $best );
		foreach ( $allowed as $bucket ) {
			$diff = abs( $candidate - $bucket );
			if ( $diff < $best_diff ) {
				$best       = $bucket;
				$best_diff  = $diff;
			}
		}
		return $best;
	}

	/**
	 * Reschedule all cron jobs whose interval changed. Called from
	 * `sanitize()` so the act of saving the form is enough to update
	 * the WP-Cron event table — no extra button press.
	 *
	 * @param array<string, int> $settings Sanitized settings.
	 * @return void
	 */
	public static function reschedule_all_jobs( array $settings ) {
		foreach ( self::CRON_HOOK_MAP as $opt_key => $hook ) {
			if ( ! isset( $settings[ $opt_key ] ) ) {
				continue;
			}
			$interval = intval( $settings[ $opt_key ] );
			if ( $interval < 30 ) {
				continue;
			}
			$schedule_slug = self::schedule_slug_for_interval( $interval );
			if ( '' === $schedule_slug ) {
				continue;
			}

			if ( class_exists( 'Niwa_AI_Cron_Maintenance' ) ) {
				Niwa_AI_Cron_Maintenance::replace_single_recurring_event(
					$hook,
					$schedule_slug,
					min( 60, $interval )
				);
				continue;
			}

			$next = wp_next_scheduled( $hook );
			if ( $next ) {
				wp_clear_scheduled_hook( $hook );
			}
			if ( ! wp_next_scheduled( $hook ) ) {
				wp_schedule_event( time() + min( 60, $interval ), $schedule_slug, $hook );
			}
		}
	}

	/**
	 * Resolve the appropriate cron schedule slug for a Niwa background hook,
	 * honoring user-configured intervals from Performance Settings. Falls
	 * back to a sensible default slug if the hook isn't tracked in
	 * Performance Settings, so legacy callers don't break.
	 *
	 * This is the canonical way to look up the right schedule slug whenever
	 * code outside `reschedule_all_jobs()` needs to call `wp_schedule_event`
	 * for a Niwa hook. Previously, callers hardcoded slugs like
	 * `niwa_ai_one_minute` directly, which silently overrode user-configured
	 * Performance Settings every time the plugin booted and re-applied its
	 * "ensure scheduled" pass — making the Performance settings page appear
	 * to do nothing for those hooks.
	 *
	 * @param string $hook WP-Cron hook name (e.g. `niwa_ai_admin_autonomy_background_tick`).
	 * @param string $fallback_slug Schedule slug to use if Performance Settings doesn't track this hook. Defaults to the historic hardcoded value where applicable.
	 * @return string Schedule slug suitable for `wp_schedule_event`.
	 */
	public static function resolve_schedule_for_hook( $hook, $fallback_slug = 'niwa_ai_one_minute' ) {
		$settings = self::get();
		foreach ( self::CRON_HOOK_MAP as $opt_key => $hook_name ) {
			if ( $hook_name !== $hook ) {
				continue;
			}
			if ( ! isset( $settings[ $opt_key ] ) ) {
				break;
			}
			$slug = self::schedule_slug_for_interval( (int) $settings[ $opt_key ] );
			if ( '' !== $slug ) {
				return $slug;
			}
		}
		return $fallback_slug;
	}

	/**
	 * Map an interval (seconds) to the closest registered cron schedule
	 * slug. Niwa registers `niwa_ai_thirty_seconds`/`_one_minute`/
	 * `_five_minutes` plus WP built-ins (`hourly`, `twicedaily`, `daily`).
	 *
	 * @param int $seconds Desired interval.
	 * @return string Schedule slug, or '' if no slug fits.
	 */
	public static function schedule_slug_for_interval( $seconds ) {
		$seconds = max( 30, intval( $seconds ) );
		if ( $seconds <= 30 ) {
			return 'niwa_ai_thirty_seconds';
		}
		if ( $seconds <= 60 ) {
			return 'niwa_ai_one_minute';
		}
		if ( $seconds <= 120 ) {
			return 'niwa_ai_two_minutes';
		}
		if ( $seconds <= 300 ) {
			return 'niwa_ai_five_minutes';
		}
		if ( $seconds <= 600 ) {
			return 'niwa_ai_ten_minutes';
		}
		if ( $seconds <= 900 ) {
			return 'niwa_ai_fifteen_minutes';
		}
		if ( $seconds <= 1800 ) {
			return 'niwa_ai_thirty_minutes';
		}
		return 'hourly';
	}

	public static function schedule_slug_for_max_interval( $seconds ) {
		$seconds = max( 30, intval( $seconds ) );
		if ( $seconds >= HOUR_IN_SECONDS && 0 === $seconds % HOUR_IN_SECONDS ) {
			return 'hourly';
		}

		$definitions = self::extended_schedule_definitions();
		$best_slug   = '';
		$best_value  = 0;
		foreach ( $definitions as $slug => $definition ) {
			$interval = isset( $definition['interval'] ) ? intval( $definition['interval'] ) : 0;
			if ( $interval > 0 && $interval <= $seconds && 0 === $seconds % $interval && $interval >= $best_value ) {
				$best_slug  = (string) $slug;
				$best_value = $interval;
			}
		}

		if ( '' !== $best_slug ) {
			return $best_slug;
		}

		return 'niwa_ai_thirty_seconds';
	}

	public static function interval_for_schedule_slug( $slug ) {
		$slug = (string) $slug;
		if ( 'hourly' === $slug ) {
			return HOUR_IN_SECONDS;
		}

		$definitions = self::extended_schedule_definitions();
		return isset( $definitions[ $slug ]['interval'] ) ? intval( $definitions[ $slug ]['interval'] ) : 0;
	}

	/**
	 * All custom schedule slugs we may emit. Used by the cron_schedules
	 * filter in niwa-ai.php to ensure WP-Cron knows about every slug
	 * `schedule_slug_for_interval()` can return.
	 *
	 * @return array<string, array{interval:int, display:string}>
	 */
	public static function extended_schedule_definitions() {
		return array(
			'niwa_ai_thirty_seconds'  => array( 'interval' => 30,   'display' => 'Niwa AI every 30 seconds' ),
			'niwa_ai_one_minute'      => array( 'interval' => 60,   'display' => 'Niwa AI every 1 minute' ),
			'niwa_ai_two_minutes'     => array( 'interval' => 120,  'display' => 'Niwa AI every 2 minutes' ),
			'niwa_ai_five_minutes'    => array( 'interval' => 300,  'display' => 'Niwa AI every 5 minutes' ),
			'niwa_ai_ten_minutes'     => array( 'interval' => 600,  'display' => 'Niwa AI every 10 minutes' ),
			'niwa_ai_fifteen_minutes' => array( 'interval' => 900,  'display' => 'Niwa AI every 15 minutes' ),
			'niwa_ai_thirty_minutes'  => array( 'interval' => 1800, 'display' => 'Niwa AI every 30 minutes' ),
		);
	}
}
