<?php
/**
 * Background (Action Scheduler) bulk re-evaluation of a smart category across
 * the whole catalogue, processed page by page to stay within memory/time limits.
 *
 * @package SmartCategories
 */

defined( 'ABSPATH' ) || exit;

/**
 * Class SCWC_Scheduler
 */
class SCWC_Scheduler {

	const HOOK_PROCESS_TERM = 'scwc_process_term';
	const HOOK_PROCESS_PAGE = 'scwc_process_term_page';
	const HOOK_REFRESH      = 'scwc_daily_refresh';
	const GROUP             = 'scwc';

	/**
	 * Register Action Scheduler callbacks.
	 */
	public static function init() {
		add_action( self::HOOK_PROCESS_TERM, array( __CLASS__, 'process_term' ), 10, 1 );
		add_action( self::HOOK_PROCESS_PAGE, array( __CLASS__, 'process_page' ), 10, 2 );
		add_action( self::HOOK_REFRESH, array( __CLASS__, 'refresh' ) );
		add_action( 'init', array( __CLASS__, 'maybe_schedule_refresh' ) );
	}

	/**
	 * Ensure the recurring refresh job is scheduled. This keeps time-based rules
	 * (e.g. "created in the last 30 days", "no sales for 60 days") accurate even
	 * when products are never touched: without it a product would only leave such
	 * a category the next time it happens to be saved.
	 */
	public static function maybe_schedule_refresh() {
		if ( ! function_exists( 'as_has_scheduled_action' ) || ! function_exists( 'as_schedule_recurring_action' ) ) {
			return;
		}

		// The refresh pipeline supersedes the old standalone sales-aggregation job.
		// Deliberately kept in the FREE build too (literal hook name, no engine
		// reference): unscheduling a leftover Pro cron is cleanup, not a feature.
		if ( as_has_scheduled_action( 'scwc_aggregate_sales' ) ) {
			as_unschedule_all_actions( 'scwc_aggregate_sales' );
		}

		/**
		 * Filter the refresh interval in seconds (minimum 15 minutes).
		 *
		 * @param int $interval Seconds between full re-evaluations. Default daily.
		 */
		$interval = max( 15 * MINUTE_IN_SECONDS, (int) apply_filters( 'scwc_refresh_interval', DAY_IN_SECONDS ) );
		$stored   = (int) get_option( 'scwc_refresh_interval', 0 );

		if ( $stored !== $interval && as_has_scheduled_action( self::HOOK_REFRESH ) ) {
			as_unschedule_all_actions( self::HOOK_REFRESH );
		}

		if ( ! as_has_scheduled_action( self::HOOK_REFRESH ) ) {
			as_schedule_recurring_action( time() + 5 * MINUTE_IN_SECONDS, $interval, self::HOOK_REFRESH, array(), self::GROUP );
			update_option( 'scwc_refresh_interval', $interval, false );
		}
	}

	/**
	 * Recurring refresh: pull fresh sales aggregates first, then re-evaluate
	 * every enabled smart category so time/sales-based rules stay accurate.
	 */
	public static function refresh() {
		self::schedule_all();
	}

	/**
	 * Default batch size (products per page).
	 *
	 * @return int
	 */
	public static function batch_size() {
		return (int) apply_filters( 'scwc_batch_size', 100 );
	}

	/**
	 * Whether Action Scheduler is available.
	 *
	 * @return bool
	 */
	public static function available() {
		return function_exists( 'as_enqueue_async_action' );
	}

	/**
	 * Queue a full re-evaluation of a term, starting from page 1.
	 *
	 * @param int $term_id Term ID.
	 * @return void
	 */
	public static function schedule_term( $term_id ) {
		$term_id = (int) $term_id;
		if ( ! self::available() ) {
			// Fallback: run synchronously (small stores / no Action Scheduler).
			self::run_now( $term_id );
			return;
		}
		self::schedule_page( $term_id, 1 );
	}

	/**
	 * Populate a single term for an interactive action (create / "Refresh now").
	 *
	 * Runs synchronously so the admin sees the correct product count immediately —
	 * Action Scheduler can lag for minutes (or never fire) on low-traffic sites,
	 * which would otherwise leave the overview counter stuck at 0. For very large
	 * catalogs it falls back to the async queue to avoid request timeouts.
	 *
	 * @param int $term_id Term ID.
	 * @return bool True when it ran synchronously (count is now accurate).
	 */
	public static function sync_or_schedule( $term_id ) {
		$term_id = (int) $term_id;

		/**
		 * Product-count ceiling under which a single-term (re)evaluation runs
		 * synchronously in the request instead of via Action Scheduler.
		 *
		 * @param int $threshold Default 5000.
		 */
		$threshold = (int) apply_filters( 'scwc_sync_threshold', 5000 );
		$counts    = wp_count_posts( 'product' );
		$total     = (int) ( isset( $counts->publish ) ? $counts->publish : 0 );

		if ( ! self::available() || $total <= $threshold ) {
			self::run_now( $term_id );
			return true;
		}

		self::schedule_term( $term_id );
		return false;
	}

	/**
	 * Queue all enabled smart categories.
	 *
	 * @return void
	 */
	public static function schedule_all() {
		foreach ( SCWC_Term_Rules::get_smart_term_ids() as $term_id ) {
			self::schedule_term( $term_id );
		}
	}

	/**
	 * Enqueue processing of a single page.
	 *
	 * @param int $term_id Term ID.
	 * @param int $paged   Page number.
	 * @return void
	 */
	private static function schedule_page( $term_id, $paged ) {
		if ( self::available() ) {
			as_enqueue_async_action( self::HOOK_PROCESS_PAGE, array( (int) $term_id, (int) $paged ), self::GROUP );
		}
	}

	/**
	 * Action callback: (re)start processing for a term.
	 *
	 * @param int $term_id Term ID.
	 * @return void
	 */
	public static function process_term( $term_id ) {
		self::schedule_page( (int) $term_id, 1 );
	}

	/**
	 * Action callback: process one page of products for a term, then chain the next.
	 *
	 * @param int $term_id Term ID.
	 * @param int $paged   Page number.
	 * @return void
	 */
	public static function process_page( $term_id, $paged ) {
		$term_id = (int) $term_id;
		$paged   = max( 1, (int) $paged );

		if ( ! SCWC_Term_Rules::is_enabled( $term_id ) ) {
			return;
		}

		$tree = SCWC_Term_Rules::get_rules( $term_id );
		if ( empty( $tree['children'] ) ) {
			// Rules were cleared while the term is still enabled. The engine fails
			// closed (matches nothing), so detach every product we auto-assigned —
			// otherwise they would be stranded in the category forever.
			SCWC_Assigner::cleanup_term( $term_id );
			do_action( 'scwc_term_processed', $term_id );
			return;
		}

		$per_page = self::batch_size();
		$ids      = self::get_product_page( $paged, $per_page );

		foreach ( $ids as $product_id ) {
			$product = wc_get_product( $product_id );
			if ( ! $product ) {
				continue;
			}
			if ( SCWC_Rule_Engine::matches( $product, $tree ) ) {
				SCWC_Assigner::assign( $term_id, $product_id );
			} else {
				SCWC_Assigner::unassign( $term_id, $product_id );
			}
		}

		if ( count( $ids ) >= $per_page ) {
			self::schedule_page( $term_id, $paged + 1 );
		} else {
			/**
			 * Fires when a smart category finishes a full re-evaluation pass.
			 *
			 * @param int $term_id Term ID.
			 */
			do_action( 'scwc_term_processed', $term_id );
		}
	}

	/**
	 * Synchronous full re-evaluation (used by WP-CLI or as a fallback).
	 *
	 * @param int $term_id Term ID.
	 * @return int Number of products evaluated.
	 */
	public static function run_now( $term_id ) {
		$term_id = (int) $term_id;
		$tree    = SCWC_Term_Rules::get_rules( $term_id );
		if ( empty( $tree['children'] ) ) {
			// Rules cleared but term still enabled: detach everything we auto-added
			// (fail-closed, matching the engine) instead of leaving ghost members.
			SCWC_Assigner::cleanup_term( $term_id );
			do_action( 'scwc_term_processed', $term_id );
			return 0;
		}

		$per_page  = self::batch_size();
		$paged     = 1;
		$evaluated = 0;

		do {
			$ids = self::get_product_page( $paged, $per_page );
			foreach ( $ids as $product_id ) {
				$product = wc_get_product( $product_id );
				if ( ! $product ) {
					continue;
				}
				if ( SCWC_Rule_Engine::matches( $product, $tree ) ) {
					SCWC_Assigner::assign( $term_id, $product_id );
				} else {
					SCWC_Assigner::unassign( $term_id, $product_id );
				}
				++$evaluated;
			}
			++$paged;
		} while ( count( $ids ) >= $per_page );

		do_action( 'scwc_term_processed', $term_id );
		return $evaluated;
	}

	/**
	 * Fetch a page of product IDs to evaluate.
	 *
	 * @param int $paged    Page number.
	 * @param int $per_page Page size.
	 * @return int[]
	 */
	private static function get_product_page( $paged, $per_page ) {
		$statuses = apply_filters( 'scwc_evaluated_post_statuses', array( 'publish' ) );

		$query = new WP_Query(
			array(
				'post_type'              => 'product',
				'post_status'            => $statuses,
				'fields'                 => 'ids',
				'posts_per_page'         => $per_page,
				'paged'                  => $paged,
				'orderby'                => 'ID',
				'order'                  => 'ASC',
				'no_found_rows'          => true,
				'update_post_meta_cache' => false,
				'update_post_term_cache' => false,
				'cache_results'          => false,
			)
		);

		return array_map( 'intval', $query->posts );
	}
}
