<?php

namespace AutomateWoo\Jobs;

use AutomateWoo\Exceptions\InvalidArgument;
use AutomateWoo\Traits\ArrayValidator;
use Exception;

defined( 'ABSPATH' ) || exit;

/**
 * AbstractBatchedActionSchedulerJob class.
 *
 * Enables a job to be processed in recurring scheduled batches with queued events.
 *
 * Notes:
 * - Uses ActionScheduler's very scalable async actions feature which will run async batches in loop back requests until all batches are done
 * - Items may be processed concurrently by AS, but batches will be created one after the other, not concurrently
 * - The job will not start if it is already running
 *
 * @since 5.1.0
 */
abstract class AbstractBatchedActionSchedulerJob extends AbstractActionSchedulerJob implements BatchedActionSchedulerJobInterface {

	use ArrayValidator;

	/**
	 * Whether multiple instances of the job should be allowed to run concurrently.
	 *
	 * If the job is scheduled to run frequently, e.g. in less than 10 minute intervals, it's a good idea
	 * to disallow concurrency to prevent race conditions.
	 *
	 * @var bool
	 */
	protected $allow_concurrent = false;

	/**
	 * Whether to check for an already-scheduled `process_item` action before scheduling a new one.
	 *
	 * Opt-in per job, since the check costs one AS table query per item. Only useful for jobs
	 * where a duplicate `process_item` action would throw (e.g. when `process_item` looks up a
	 * row that the first action already deleted) and thus trip the failure-rate guard.
	 *
	 * @var bool
	 */
	protected $require_idempotent_scheduling = false;

	/**
	 * Get a new batch of items.
	 *
	 * If no items are returned the job will stop.
	 *
	 * @param int   $batch_number The batch number increments for each new batch in the job cycle.
	 * @param array $args         The args for this instance of the job. Args are already validated.
	 *
	 * @return string[]|int[]|array[]
	 * @throws Exception If an error occurs. The exception will be logged by ActionScheduler.
	 */
	abstract protected function get_batch( int $batch_number, array $args );

	/**
	 * Process a single item.
	 *
	 * @param string|int|array $item A single item from the get_batch() method. Expects a validated item.
	 * @param array            $args The args for this instance of the job. Args are already validated.
	 *
	 * @throws Exception If an error occurs. The exception will be logged by ActionScheduler.
	 */
	abstract protected function process_item( $item, array $args );

	/**
	 * Validate an item to be processed by the job.
	 *
	 * @param mixed $item
	 *
	 * @throws InvalidArgument If the item is not valid.
	 */
	abstract protected function validate_item( $item );

	/**
	 * Can the job start.
	 *
	 * @return bool Returns true if the job can start.
	 *
	 * @throws Exception An exception may be thrown in child method.
	 */
	protected function can_start(): bool {
		if ( false === $this->allow_concurrent && $this->is_running() ) {
			return false;
		}

		return true;
	}

	/**
	 * Init the batch schedule for the job.
	 *
	 * The job name is used to generate the schedule event name.
	 */
	public function init() {
		add_action( $this->get_create_batch_hook(), [ $this, 'handle_create_batch_action' ], 10, 2 );
		add_action( $this->get_process_item_hook(), [ $this, 'handle_process_item_action' ], 10, 2 );
	}

	/**
	 * Get the hook name for the "create batch" action.
	 *
	 * @return string
	 */
	protected function get_create_batch_hook() {
		return $this->get_hook_base_name() . 'create_batch';
	}

	/**
	 * Get the name of an action to attach the job's start method to.
	 * By default, it's 'automatewoo/jobs/{job_name}/start' but it can be overridden in Job implementation.
	 *
	 * @return string
	 */
	public function get_start_hook() {
		return $this->get_hook_base_name() . 'start';
	}

	/**
	 * Enqueue the "create_batch" action provided it doesn't already exist.
	 *
	 * To make minimize the resource use of starting the job the batch creation is handled async.
	 *
	 * @param array $args Optionally set args to be available during this instance of the job.
	 *
	 * @throws InvalidArgument If args are invalid.
	 * @throws Exception An exception may be thrown from child class.
	 */
	public function start( array $args = [] ) {
		$this->validate_args( $args );

		if ( $this->can_start() ) {
			$this->schedule_create_batch_action( 1, $args );
		}
	}

	/**
	 * Handles batch creation action hook.
	 *
	 * @hooked automatewoo/jobs/{$job_name}/create_batch
	 *
	 * Schedules an action to run immediately for each item in the batch.
	 * When `$require_idempotent_scheduling` is enabled the scheduler checks for
	 * an existing `process_item` action first, to prevent duplicate scheduling.
	 *
	 * @since 6.3.2 Added opt-in idempotent scheduling to prevent duplicate process_item actions.
	 *
	 * @param int   $batch_number The batch number increments for each new batch in the job cycle.
	 * @param array $args         The args for this instance of the job.
	 *
	 * @throws Exception If an error occurs.
	 * @throws JobException If the job failure rate is too high.
	 * @throws InvalidArgument If args or an item is invalid.
	 */
	public function handle_create_batch_action( int $batch_number, array $args ) {
		$this->monitor->validate_failure_rate( $this );
		$this->validate_args( $args );

		$items             = $this->get_batch( $batch_number, $args );
		$already_scheduled = 0;

		foreach ( $items as $item ) {
			$this->validate_item( $item );

			if ( $this->require_idempotent_scheduling
				&& $this->action_scheduler->has_scheduled_action( $this->get_process_item_hook(), [ $item, $args ] )
			) {
				++$already_scheduled;
				continue;
			}

			$this->action_scheduler->schedule_immediate( $this->get_process_item_hook(), [ $item, $args ] );
		}

		if ( empty( $items ) ) {
			// If no more items the job is complete
			$this->handle_complete( $batch_number, $args );
		} elseif ( $this->require_idempotent_scheduling && $already_scheduled === count( $items ) ) {
			// All items in this batch are already scheduled, treat as complete to avoid infinite loops.
			$this->handle_complete( $batch_number, $args );
		} else {
			// If items, schedule another "create_batch" action to handle remaining items
			$this->schedule_create_batch_action( $batch_number + 1, $args );
		}
	}

	/**
	 * Get job batch size.
	 *
	 * @return int
	 */
	protected function get_batch_size() {
		return 15;
	}

	/**
	 * Get the query offset based on a given batch number and the specified batch size.
	 *
	 * @param int $batch_number
	 *
	 * @return int
	 */
	protected function get_query_offset( int $batch_number ): int {
		return $this->get_batch_size() * ( $batch_number - 1 );
	}

	/**
	 * Handles processing single item action hook.
	 *
	 * @hooked automatewoo/jobs/{$job_name}/process_item
	 *
	 * @param mixed $item A single job item from the current batch.
	 * @param array $args The args for this instance of the job.
	 *
	 * @throws Exception If an error occurs.
	 * @throws InvalidArgument If args or an item is invalid.
	 */
	public function handle_process_item_action( $item, array $args ) {
		$this->validate_args( $args );
		$this->validate_item( $item );
		$this->process_item( $item, $args );
	}

	/**
	 * Schedule a new "create batch" action to run immediately.
	 *
	 * @param int   $batch_number The batch number for the new batch.
	 * @param array $args         The args for this instance of the job.
	 */
	protected function schedule_create_batch_action( int $batch_number, array $args ) {
		$this->action_scheduler->schedule_immediate( $this->get_create_batch_hook(), [ $batch_number, $args ] );
	}

	/**
	 * Check if this job is running.
	 *
	 * The job is considered to be running if a "create_batch" action is currently pending or in-progress.
	 *
	 * @return bool
	 */
	protected function is_running(): bool {
		return false !== $this->action_scheduler->has_scheduled_action( $this->get_create_batch_hook() );
	}

	/**
	 * Validate the job args.
	 *
	 * @param array $args The args for this instance of the job.
	 */
	protected function validate_args( array $args ) {
		// Optionally over-ride this method in child class.
	}

	/**
	 * Called when the job is completed.
	 *
	 * @param int   $final_batch_number The final batch number when the job was completed.
	 *                                  If equal to 1 then no items were processed by the job.
	 * @param array $args               The args for this instance of the job.
	 */
	protected function handle_complete( int $final_batch_number, array $args ) {
		// Optionally over-ride this method in child class.
	}
}
