<?php
/**
 * Background Business Identity job.
 *
 * Learning the site used to be driven from the browser: the Setup Wizard looped
 * over the REST sync endpoints itself, so closing the tab (or a reload, or a
 * flaky connection) abandoned the work half-done — on a brand-new account, at
 * the worst possible moment. The work now runs on cron, one page per tick, and
 * finishes whether or not anyone is watching. The wizard just polls the status.
 *
 * @package    SiteStaffr
 * @subpackage SiteStaffr/includes
 */

if ( ! defined( 'WPINC' ) ) {
	die;
}

class SiteStaffr_Identity_Job {

	const STATE_OPTION = 'sitestaffr_identity_job';
	const TICK_HOOK    = 'sitestaffr_identity_job_tick';

	/** Chunks embedded per middleware call (mirrors the old browser loop). */
	const BATCH_SIZE = 10;

	/** A job with no progress for this long is treated as dead and can be restarted. */
	const STALL_SECONDS = 600;

	/**
	 * Current job state (never null; an absent job reads as idle).
	 *
	 * @return array
	 */
	public static function get_state() {
		$state = get_option( self::STATE_OPTION, array() );
		if ( ! is_array( $state ) || empty( $state['status'] ) ) {
			return array( 'status' => 'idle' );
		}
		return $state;
	}

	/**
	 * Whether a job is currently in flight (and has not stalled).
	 *
	 * @return bool
	 */
	public static function is_running() {
		$state = self::get_state();
		if ( ! in_array( $state['status'], array( 'queued', 'syncing', 'generating' ), true ) ) {
			return false;
		}
		$updated = isset( $state['updated_at'] ) ? (int) $state['updated_at'] : 0;
		return ( time() - $updated ) < self::STALL_SECONDS;
	}

	/**
	 * Persist state and stamp it (the stamp is how a stalled job is detected).
	 *
	 * @param array $changes Keys to merge into the current state.
	 * @return array The stored state.
	 */
	private static function put_state( array $changes ) {
		$state               = array_merge( self::get_state(), $changes );
		$state['updated_at'] = time();
		update_option( self::STATE_OPTION, $state, false );
		return $state;
	}

	/**
	 * Queue the job and start it server-side immediately.
	 *
	 * @param bool $force Restart even if a job is already in flight.
	 * @return array Job state.
	 */
	public static function start( $force = false ) {
		if ( ! $force && self::is_running() ) {
			return self::get_state();
		}

		$candidates = self::get_candidates();
		if ( is_wp_error( $candidates ) ) {
			return self::put_state( array(
				'status'     => 'failed',
				'error'      => $candidates->get_error_message(),
				'error_code' => $candidates->get_error_code(),
			) );
		}

		if ( empty( $candidates ) ) {
			return self::put_state( array(
				'status'     => 'failed',
				'error'      => __( 'Your website does not have enough text content to learn from yet.', 'sitestaffr' ),
				'error_code' => 'no_content',
			) );
		}

		$state = self::put_state( array(
			'status'      => 'queued',
			'started_at'  => time(),
			'queue'       => array_values( $candidates ),
			'total_posts' => count( $candidates ),
			'posts_done'  => 0,
			'synced'      => 0,
			'skipped'     => 0,
			'current'     => '',
			'error'       => '',
			'error_code'  => '',
		) );

		self::schedule_tick();

		return $state;
	}

	/**
	 * Schedule the next tick and nudge cron so the work starts now, not on the
	 * next visitor's page load.
	 *
	 * @return void
	 */
	private static function schedule_tick() {
		if ( ! wp_next_scheduled( self::TICK_HOOK ) ) {
			wp_schedule_single_event( time(), self::TICK_HOOK );
		}
		spawn_cron();
	}

	/**
	 * Self-heal an in-flight job whose cron event went missing.
	 *
	 * This job runs during first-time setup, when a site may have no visitors at
	 * all — and some hosts drop or disable WP-Cron events outright. Every admin
	 * page load re-arms an unfinished job, so the owner clicking around wp-admin
	 * is enough to carry it home; work is never lost, only delayed.
	 *
	 * @return void
	 */
	public static function ensure_scheduled() {
		if ( ! self::is_running() ) {
			return;
		}
		if ( wp_next_scheduled( self::TICK_HOOK ) ) {
			return;
		}
		self::schedule_tick();
	}

	/**
	 * Process one unit of work: sync the next page, or — when the queue is empty —
	 * generate and store the identity.
	 *
	 * @return void
	 */
	public static function tick() {
		$state = self::get_state();
		if ( ! in_array( $state['status'], array( 'queued', 'syncing', 'generating' ), true ) ) {
			return;
		}

		// One page (or one generation) per tick; the middleware round-trips are slow
		// and this runs unattended, so give it room — but stay inside the 120s ceiling
		// the rest of the plugin uses.
		ignore_user_abort( true );
		if ( function_exists( 'set_time_limit' ) ) {
			set_time_limit( 120 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged
		}

		$queue = isset( $state['queue'] ) && is_array( $state['queue'] ) ? $state['queue'] : array();

		if ( ! empty( $queue ) ) {
			$post = array_shift( $queue );
			self::put_state( array(
				'status'  => 'syncing',
				'queue'   => $queue,
				'current' => isset( $post['title'] ) ? (string) $post['title'] : '',
			) );

			$synced = self::sync_post( isset( $post['id'] ) ? (int) $post['id'] : 0 );

			$state = self::get_state();
			self::put_state( array(
				'posts_done' => (int) $state['posts_done'] + 1,
				'synced'     => (int) $state['synced'] + ( $synced ? 1 : 0 ),
				'skipped'    => (int) $state['skipped'] + ( $synced ? 0 : 1 ),
			) );

			self::schedule_tick();
			return;
		}

		// Everything is synced — write the identity from what the agent just learned.
		self::put_state( array( 'status' => 'generating', 'current' => '' ) );

		$admin  = new SiteStaffr_Admin( 'sitestaffr', SITESTAFFR_VERSION );
		$result = $admin->generate_and_store_business_identity();

		if ( is_wp_error( $result ) ) {
			self::put_state( array(
				'status'     => 'failed',
				'error'      => $result->get_error_message(),
				'error_code' => $result->get_error_code(),
			) );
			return;
		}

		self::put_state( array(
			'status'      => 'done',
			'finished_at' => time(),
			'error'       => '',
			'error_code'  => '',
		) );
	}

	/**
	 * Pages the agent should learn from.
	 *
	 * @return array|WP_Error List of {id,title}.
	 */
	private static function get_candidates() {
		$admin = new SiteStaffr_Admin( 'sitestaffr', SITESTAFFR_VERSION );
		if ( ! method_exists( $admin, 'get_auto_sync_candidates_for_rest' ) ) {
			return new WP_Error( 'unavailable', __( 'Content scan is unavailable.', 'sitestaffr' ) );
		}

		$remaining = null;
		try {
			$remaining = $admin->get_remaining_description_generations();
		} catch ( \Throwable $e ) {
			$remaining = null; // Rate check unavailable — do not block on it.
		}
		if ( null !== $remaining && $remaining <= 0 ) {
			return new WP_Error(
				'scan_rate_limited',
				__( 'You have reached the daily limit for description generations. Please try again tomorrow.', 'sitestaffr' )
			);
		}

		$candidates = $admin->get_auto_sync_candidates_for_rest();

		return is_array( $candidates ) ? $candidates : array();
	}

	/**
	 * Chunk + embed one post, in-process (the same REST handlers the browser used
	 * to call, invoked directly — no HTTP hop, no logged-in session needed).
	 *
	 * @param int $post_id Post to sync.
	 * @return bool Whether the post produced any embedded chunks.
	 */
	private static function sync_post( $post_id ) {
		if ( $post_id <= 0 ) {
			return false;
		}

		$api = new SiteStaffr_REST_API();

		$prepare = new WP_REST_Request( 'POST', '/sitestaffr/v1/knowledge/prepare' );
		$prepare->set_param( 'post_id', $post_id );
		$prepare_data = $api->knowledge_prepare_sync( $prepare );
		$prepare_body = is_object( $prepare_data ) && method_exists( $prepare_data, 'get_data' )
			? $prepare_data->get_data()
			: array();

		$total = isset( $prepare_body['total'] ) ? (int) $prepare_body['total'] : 0;
		if ( empty( $prepare_body['success'] ) || $total <= 0 ) {
			return false;
		}

		$done = 0;
		while ( $done < $total ) {
			$end     = min( $done + self::BATCH_SIZE, $total );
			$indices = range( $done, $end - 1 );

			$embed = new WP_REST_Request( 'POST', '/sitestaffr/v1/knowledge/embed-batch' );
			$embed->set_param( 'post_id', $post_id );
			$embed->set_param( 'chunk_indices', $indices );
			$embed->set_param( 'is_last', ( $end >= $total ) );
			$embed->set_param( 'text_only', true );
			$api->knowledge_embed_batch( $embed );

			$done = $end;
		}

		return true;
	}
}
