<?php
/**
 * Background indexer — server-side, queue-driven indexing for the OpenAI and
 * Hugging Face engines via Action Scheduler (bundled with WooCommerce).
 *
 * The browser-driven "Index now" loop ties indexing to an open admin tab and
 * runs one batch at a time. For large catalogs that means babysitting the tab
 * for several minutes. This class moves the work to Action Scheduler: the
 * admin clicks once, can close the tab, and indexing continues in the
 * background — one chunk per scheduled action, each chaining the next until
 * the catalog is done. Transient provider errors (rate limits, cold starts,
 * timeouts) reschedule the same chunk with a delay instead of failing the run.
 *
 * Local (in-browser) indexing can't run here — it needs transformers.js in the
 * browser — so this path is gated to server-side providers.
 *
 * @package FindAstra
 */

defined( 'ABSPATH' ) || exit;

final class FindAstra_Background_Indexer {

	public const HOOK            = 'findastra_index_chunk';
	public const GROUP           = 'findastra';
	public const PROGRESS_OPTION = 'findastra_bg_index';
	public const DEFAULT_BATCH   = 50;
	public const MAX_RETRIES     = 3;
	public const RETRY_DELAY     = 30; // seconds

	public function register(): void {
		add_action( self::HOOK, array( $this, 'run_chunk' ), 10, 2 );
	}

	/** Action Scheduler present? (bundled with WooCommerce.) */
	public static function available(): bool {
		return function_exists( 'as_enqueue_async_action' )
			&& function_exists( 'as_schedule_single_action' )
			&& function_exists( 'as_unschedule_all_actions' )
			&& function_exists( 'as_has_scheduled_action' );
	}

	/**
	 * Products embedded per scheduled chunk. Filterable; clamped 1–500. The
	 * provider then chunks THIS into API calls of its own BATCH size, so a
	 * chunk of 50 is one OpenAI request (BATCH 96) or two HF requests (BATCH 32).
	 */
	public function batch_size( FindAstra_Provider_Base $provider ): int {
		$n = (int) apply_filters( 'findastra_index_batch_size', self::DEFAULT_BATCH, $provider );
		return max( 1, min( 500, $n ) );
	}

	/**
	 * Begin a background index for the active server-side provider. Cancels any
	 * job already in flight, optionally truncates, seeds progress, and enqueues
	 * the first chunk.
	 *
	 * @return array<string,mixed> progress() payload, or an {ok:false,error} shape.
	 */
	public function start( bool $reset = true ): array {
		$provider = FindAstra_Provider_Factory::current();
		if ( ! $provider->is_server_side() ) {
			return array( 'ok' => false, 'error' => 'not_server_side' );
		}
		if ( ! $provider->is_configured() ) {
			return array( 'ok' => false, 'error' => 'not_configured' );
		}
		if ( ! self::available() ) {
			return array( 'ok' => false, 'error' => 'no_scheduler' );
		}

		$this->cancel( false );

		if ( $reset ) {
			( new FindAstra_Vector_Store() )->truncate();
		}

		$total = ( new FindAstra_Indexer() )->count_total_products();

		$now = time();
		$this->set_state( array(
			'status'    => 'running',
			'cursor'    => 0,
			'indexed'   => 0,
			'remaining' => $total,
			'total'     => $total,
			'provider'  => $provider->key(),
			'model'     => $provider->model(),
			'message'   => '',
			'started'   => $now,
			'updated'   => $now,
		) );

		as_enqueue_async_action( self::HOOK, array( 0, 0 ), self::GROUP );

		return $this->progress();
	}

	/**
	 * Process one chunk. Runs inside an Action Scheduler job.
	 *
	 * @param int $cursor Last product ID processed (exclusive lower bound).
	 * @param int $retry  Retry counter for the current cursor.
	 */
	public function run_chunk( $cursor = 0, $retry = 0 ): void {
		$cursor = (int) $cursor;
		$retry  = (int) $retry;

		$state = $this->get_state();
		if ( 'running' !== ( $state['status'] ?? '' ) ) {
			return; // cancelled / finished elsewhere — drop the job.
		}

		$provider = FindAstra_Provider_Factory::current();
		if ( ! $provider->is_server_side() || ! $provider->is_configured() ) {
			$this->fail( __( 'Search engine is no longer configured for background indexing.', 'findastra' ) );
			return;
		}

		$store   = new FindAstra_Vector_Store();
		$indexer = new FindAstra_Indexer();
		$batch   = $indexer->next_batch( $cursor, $this->batch_size( $provider ), $provider, $store );

		$next   = (int) $batch['next_cursor'];
		$remain = (int) $batch['remaining'];

		if ( ! empty( $batch['items'] ) ) {
			$texts   = array_map( static fn( $i ) => $i['text'], $batch['items'] );
			$vectors = $provider->embed( $texts );

			if ( $vectors instanceof WP_Error ) {
				if ( $retry < self::MAX_RETRIES ) {
					// Transient (rate-limit / cold-start / timeout): back off and
					// retry the SAME cursor instead of dropping the catalog.
					as_schedule_single_action( time() + self::RETRY_DELAY, self::HOOK, array( $cursor, $retry + 1 ), self::GROUP );
					$state['message'] = sprintf(
						/* translators: 1: attempt number, 2: max attempts, 3: provider error message */
						__( 'Provider error (retry %1$d/%2$d): %3$s', 'findastra' ),
						$retry + 1,
						self::MAX_RETRIES,
						$vectors->get_error_message()
					);
					$state['updated'] = time();
					$this->set_state( $state );
					return;
				}
				$this->fail( $vectors->get_error_message() );
				return;
			}

			if ( count( $vectors ) === count( $batch['items'] ) ) {
				$accepted = 0;
				foreach ( $batch['items'] as $idx => $item ) {
					$ok = $store->upsert(
						(int) $item['product_id'],
						$vectors[ $idx ],
						$provider->key(),
						$provider->model(),
						(string) $item['text_hash'],
						$item['language'] ?? ''
					);
					if ( $ok ) {
						++$accepted;
					}
				}
				$state['indexed'] = (int) ( $state['indexed'] ?? 0 ) + $accepted;
			}
		}

		$state['cursor']    = $next;
		$state['remaining'] = $remain;
		$state['message']   = '';
		$state['updated']   = time();

		// next_batch always advances past the IDs it saw; the `$next > $cursor`
		// guard is belt-and-suspenders so a stuck cursor can never enqueue
		// itself forever.
		if ( $remain > 0 && $next > $cursor ) {
			$this->set_state( $state );
			as_enqueue_async_action( self::HOOK, array( $next, 0 ), self::GROUP );
		} else {
			$this->finish( 'done', array(), $state );
		}
	}

	public function cancel( bool $mark = true ): void {
		if ( function_exists( 'as_unschedule_all_actions' ) ) {
			as_unschedule_all_actions( self::HOOK, array(), self::GROUP );
		}
		if ( $mark ) {
			$state = $this->get_state();
			if ( 'running' === ( $state['status'] ?? '' ) ) {
				$state['status']  = 'cancelled';
				$state['updated'] = time();
				$this->set_state( $state );
			}
		}
	}

	/** @return array<string,mixed> */
	public function progress(): array {
		$state = $this->get_state();
		if ( empty( $state ) ) {
			$state = array( 'status' => 'idle', 'indexed' => 0, 'remaining' => 0, 'total' => 0 );
		}
		$total     = (int) ( $state['total'] ?? 0 );
		$remaining = (int) ( $state['remaining'] ?? 0 );
		$processed = max( 0, $total - $remaining );

		$state['processed']    = $processed;
		$state['percent']      = $total > 0 ? (int) floor( $processed * 100 / $total ) : 0;
		$state['available']    = self::available();
		$state['is_scheduled'] = self::available() ? (bool) as_has_scheduled_action( self::HOOK, null, self::GROUP ) : false;
		return $state;
	}

	private function finish( string $status, array $extra = array(), ?array $state = null ): void {
		$state            = ( null !== $state ) ? $state : $this->get_state();
		$state['status']  = $status;
		if ( 'done' === $status ) {
			$state['remaining'] = 0;
		}
		$state['updated'] = time();
		foreach ( $extra as $k => $v ) {
			$state[ $k ] = $v;
		}
		$this->set_state( $state );
	}

	private function fail( string $message ): void {
		$state            = $this->get_state();
		$state['status']  = 'error';
		$state['message'] = $message;
		$state['updated'] = time();
		$this->set_state( $state );
	}

	/** @return array<string,mixed> */
	private function get_state(): array {
		$state = get_option( self::PROGRESS_OPTION, array() );
		return is_array( $state ) ? $state : array();
	}

	private function set_state( array $state ): void {
		update_option( self::PROGRESS_OPTION, $state, false );
	}
}
