<?php
/**
 * Async apply orchestration for POST /jobs/{id}/apply (see docs/SPEC.md §8).
 *
 * @package EmberPrice
 */

namespace EmberPrice\Rest;

use EmberPrice\Apply\BatchApplier;
use EmberPrice\Core\Jobs;
use EmberPrice\Core\Rows;
use EmberPrice\Core\Suppliers;

defined( 'ABSPATH' ) || exit;

/**
 * Walks a job's auto/confirmed rows in batches of 100 via Action Scheduler:
 * each batch run writes its rows' prices to WooCommerce and, if more
 * remain, schedules itself again for the next offset; the last batch flips
 * the job to `applied`. Lives in Rest (not Apply) for the same reason
 * JobMatchScheduler does -- it's the orchestrator wiring Core (jobs/rows)
 * to Apply (BatchApplier) via a WordPress hook.
 *
 * Doesn't do its own "sync lookup tables" / "purge object cache" pass at
 * the end (SPEC §6 step 2's last sentence): `BatchApplier`'s per-row
 * `$product->save()` already syncs that product's `wc_product_meta_lookup`
 * row (WooCommerce keeps it in sync for any updated prop in
 * `WC_Product_Data_Store_CPT`), and its per-row `wc_delete_product_transients()`
 * already bumps the global product transient version
 * (`WC_Cache_Helper::get_transient_version( 'product', true )` is called
 * inside it). `wc_update_product_lookup_tables()` is a separate, heavy,
 * whole-catalog maintenance tool (schedules a full regen + posts an admin
 * notice) meant for fixing corruption, not something to trigger on every
 * job -- confirmed by reading WooCommerce core, not assumed.
 */
final class JobApplyScheduler {

	/**
	 * Action Scheduler hook name for one batch run.
	 */
	public const HOOK = 'embp_process_apply_batch';

	/**
	 * Action Scheduler group, mostly for the admin UI (Tools > Scheduled Actions).
	 */
	private const GROUP = 'emberprice';

	private const BATCH_SIZE          = 100;
	private const STALE_AFTER_SECONDS = 120;

	/**
	 * Rows eligible to be applied -- the same set preview_job()/export_job() use.
	 */
	private const ELIGIBLE_STATUSES = array( 'auto', 'confirmed' );

	/**
	 * Job data access.
	 *
	 * @var Jobs
	 */
	private Jobs $jobs;

	/**
	 * Row data access.
	 *
	 * @var Rows
	 */
	private Rows $rows;

	/**
	 * Resolves each job's supplier default_rule.
	 *
	 * @var Suppliers
	 */
	private Suppliers $suppliers;

	/**
	 * Per-row/per-batch price-writing logic.
	 *
	 * @var BatchApplier
	 */
	private BatchApplier $applier;

	/**
	 * Constructor.
	 *
	 * @param Jobs|null         $jobs      Defaults to a new instance.
	 * @param Rows|null         $rows      Defaults to a new instance.
	 * @param Suppliers|null    $suppliers Defaults to a new instance.
	 * @param BatchApplier|null $applier   Defaults to a new instance.
	 */
	public function __construct(
		?Jobs $jobs = null,
		?Rows $rows = null,
		?Suppliers $suppliers = null,
		?BatchApplier $applier = null
	) {
		$this->jobs      = $jobs ?? new Jobs();
		$this->rows      = $rows ?? new Rows();
		$this->suppliers = $suppliers ?? new Suppliers();
		$this->applier   = $applier ?? new BatchApplier();
	}

	/**
	 * Wires the Action Scheduler hook to process_batch(). Call once, from
	 * the composition root.
	 */
	public function register_hook(): void {
		add_action( self::HOOK, array( $this, 'process_batch' ), 10, 2 );
	}

	/**
	 * Kicks off apply for a job: records the fixed eligible-row total
	 * (computed once, up front) for progress reporting, then schedules the
	 * first batch, unless one is already pending/running.
	 *
	 * @param int $job_id Job id.
	 */
	public function schedule( int $job_id ): void {
		if ( false === as_next_scheduled_action( self::HOOK, array( $job_id, 0 ), self::GROUP ) ) {
			$action_id = as_schedule_single_action( time(), self::HOOK, array( $job_id, 0 ), self::GROUP );
			$this->record_progress(
				$job_id,
				0,
				$this->rows->count_by_job_and_statuses( $job_id, self::ELIGIBLE_STATUSES ),
				0,
				(int) $action_id
			);

			if ( 0 === (int) $action_id ) {
				$this->jobs->mark_failed( $job_id, 'apply', __( 'Price application could not be scheduled. Check Action Scheduler and WP-Cron.', 'emberprice' ) );
			}
		}
	}

	/**
	 * Replaces a stale pending apply action at the last completed offset.
	 *
	 * @param int $job_id Job id.
	 */
	public function retry( int $job_id ): void {
		$job          = $this->jobs->find( $job_id );
		$apply_totals = null !== $job ? (array) ( $job['totals']['apply'] ?? array() ) : array();
		$offset       = (int) ( $apply_totals['done'] ?? 0 );
		$total        = (int) ( $apply_totals['total'] ?? $this->rows->count_by_job_and_statuses( $job_id, self::ELIGIBLE_STATUSES ) );
		$failed       = (int) ( $apply_totals['failed'] ?? 0 );

		as_unschedule_all_actions( self::HOOK, array( $job_id, $offset ), self::GROUP );
		$action_id = as_schedule_single_action( time(), self::HOOK, array( $job_id, $offset ), self::GROUP );
		$this->record_progress( $job_id, $offset, $total, $failed, (int) $action_id );
	}

	/**
	 * Action Scheduler callback: applies up to BATCH_SIZE rows starting at
	 * $offset, then either schedules the next batch or finishes the job.
	 *
	 * @param int $job_id Job id.
	 * @param int $offset Rows already processed by earlier batches.
	 */
	public function process_batch( int $job_id, int $offset ): void {
		$job = $this->jobs->find( $job_id );

		if ( null === $job || 'applying' !== $job['status'] ) {
			return;
		}

		try {
			$batch = $this->rows->get_batch_by_statuses( $job_id, self::ELIGIBLE_STATUSES, $offset, self::BATCH_SIZE );

			if ( empty( $batch ) ) {
				$this->finish( $job_id );
				return;
			}

			$supplier     = $this->suppliers->find( (int) $job['supplier_id'] );
			$default_rule = null !== $supplier ? (array) $supplier['default_rule'] : array();
			$result       = $this->applier->apply_batch( $batch, $default_rule );

			$apply_totals = (array) ( $job['totals']['apply'] ?? array() );
			$total        = (int) ( $apply_totals['total'] ?? 0 );
			$done         = (int) ( $apply_totals['done'] ?? 0 ) + count( $batch );
			$failed       = (int) ( $apply_totals['failed'] ?? 0 ) + $result['failed'];
			$next_offset  = $offset + count( $batch );

			if ( $next_offset >= $total ) {
				$this->record_progress( $job_id, $done, $total, $failed );
				$this->finish( $job_id );
				return;
			}

			$action_id = as_schedule_single_action( time(), self::HOOK, array( $job_id, $next_offset ), self::GROUP );
			$this->record_progress( $job_id, $done, $total, $failed, (int) $action_id );
		} catch ( \Throwable $exception ) {
			$this->jobs->mark_failed( $job_id, 'apply', __( 'Price application stopped unexpectedly. Roll back the job before trying again.', 'emberprice' ) );
		}
	}

	/**
	 * Progress snapshot for GET /jobs/{id}/progress, or null if the job
	 * doesn't exist. `done` counts every row processed, successful or not;
	 * `failed` is how many of those failed (SPEC's "reporte" of partial
	 * failures).
	 *
	 * @param int $job_id Job id.
	 *
	 * @return array{stage: string, done: int, total: int, failed: int}|null
	 */
	public function progress( int $job_id ): ?array {
		$job = $this->jobs->find( $job_id );

		if ( null === $job ) {
			return null;
		}

		$apply_totals = (array) ( $job['totals']['apply'] ?? array() );
		$heartbeat    = (string) ( $apply_totals['heartbeat'] ?? '' );
		$stalled      = 'applying' === $job['status']
			&& '' !== $heartbeat
			&& time() - (int) strtotime( $heartbeat . ' UTC' ) > self::STALE_AFTER_SECONDS;

		return array(
			'stage'   => $job['status'],
			'done'    => (int) ( $apply_totals['done'] ?? 0 ),
			'total'   => (int) ( $apply_totals['total'] ?? 0 ),
			'failed'  => (int) ( $apply_totals['failed'] ?? 0 ),
			'stalled' => $stalled,
			'message' => $stalled ? __( 'Price application is not progressing. WP-Cron or Action Scheduler may be unavailable.', 'emberprice' ) : '',
			'error'   => $job['totals']['error'] ?? null,
		);
	}

	/**
	 * Flips the job to `applied` -- even with partial row failures, per SPEC.
	 *
	 * @param int $job_id Job id.
	 */
	private function finish( int $job_id ): void {
		$job    = $this->jobs->find( $job_id );
		$failed = null !== $job ? (int) ( $job['totals']['apply']['failed'] ?? 0 ) : 0;

		if ( $failed > 0 ) {
			$this->jobs->mark_failed(
				$job_id,
				'apply',
				sprintf(
					/* translators: %d: failed rows. */
					__( 'Price application completed with %d failed row(s). Roll back before correcting the import.', 'emberprice' ),
					$failed
				)
			);
			return;
		}

		$this->jobs->update_status( $job_id, 'applied' );
	}

	/**
	 * Merges the apply progress into the job's `totals`, preserving the
	 * row-status counts preview_job() already stored there.
	 *
	 * @param int $job_id Job id.
	 * @param int $done   Rows processed so far.
	 * @param int $total  Fixed eligible-row count.
	 * @param int $failed Of $done, how many failed.
	 * @param int $action_id Current Action Scheduler id.
	 */
	private function record_progress( int $job_id, int $done, int $total, int $failed, ?int $action_id = null ): void {
		$job      = $this->jobs->find( $job_id );
		$totals   = null !== $job ? (array) $job['totals'] : array();
		$previous = (array) ( $totals['apply'] ?? array() );

		$totals['apply'] = array(
			'done'      => $done,
			'total'     => $total,
			'failed'    => $failed,
			'action_id' => null !== $action_id ? $action_id : (int) ( $previous['action_id'] ?? 0 ),
			'heartbeat' => current_time( 'mysql', true ),
		);

		$this->jobs->update_totals( $job_id, $totals );
	}
}
