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

namespace EmberPrice\Rest;

use EmberPrice\Apply\RollbackApplier;
use EmberPrice\Core\Jobs;
use EmberPrice\Core\Snapshots;

defined( 'ABSPATH' ) || exit;

/**
 * Walks a job's snapshots in batches of 100 via Action Scheduler: each batch
 * run restores its snapshots' prices and, if more remain, schedules itself
 * again for the next offset; the last batch flips the job to `rolled_back`.
 * Lives in Rest (not Apply) for the same reason JobApplyScheduler does --
 * it's the orchestrator wiring Core (jobs/snapshots) to Apply
 * (RollbackApplier) via a WordPress hook.
 */
final class JobRollbackScheduler {

	/**
	 * Action Scheduler hook name for one batch run.
	 */
	public const HOOK = 'embp_process_rollback_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;

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

	/**
	 * Snapshot data access.
	 *
	 * @var Snapshots
	 */
	private Snapshots $snapshots;

	/**
	 * Per-snapshot/per-batch price-restoring logic.
	 *
	 * @var RollbackApplier
	 */
	private RollbackApplier $applier;

	/**
	 * Constructor.
	 *
	 * @param Jobs|null            $jobs      Defaults to a new instance.
	 * @param Snapshots|null       $snapshots Defaults to a new instance.
	 * @param RollbackApplier|null $applier   Defaults to a new instance.
	 */
	public function __construct(
		?Jobs $jobs = null,
		?Snapshots $snapshots = null,
		?RollbackApplier $applier = null
	) {
		$this->jobs      = $jobs ?? new Jobs();
		$this->snapshots = $snapshots ?? new Snapshots();
		$this->applier   = $applier ?? new RollbackApplier();
	}

	/**
	 * 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 rollback for a job: records the fixed snapshot-count total
	 * 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->snapshots->count_by_job( $job_id ), 0, (int) $action_id );

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

	/**
	 * Replaces a stale pending rollback 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 );
		$rollback_totals = null !== $job ? (array) ( $job['totals']['rollback'] ?? array() ) : array();
		$previous_offset = (int) ( $rollback_totals['done'] ?? 0 );

		as_unschedule_all_actions( self::HOOK, array( $job_id, $previous_offset ), self::GROUP );
		$action_id = as_schedule_single_action( time(), self::HOOK, array( $job_id, 0 ), self::GROUP );
		$this->record_progress( $job_id, 0, $this->snapshots->count_by_job( $job_id ), 0, (int) $action_id );
	}

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

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

			$result = $this->applier->apply_batch( $batch );
			$job    = $this->jobs->find( $job_id );

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

			$rollback_totals = (array) ( $job['totals']['rollback'] ?? array() );
			$total           = (int) ( $rollback_totals['total'] ?? 0 );
			$done            = (int) ( $rollback_totals['done'] ?? 0 ) + count( $batch );
			$failed          = (int) ( $rollback_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, 'rollback', __( 'Rollback stopped unexpectedly. Retry it after checking Action Scheduler.', 'emberprice' ) );
		}
	}

	/**
	 * Progress snapshot for GET /jobs/{id}/progress, or null if the job
	 * doesn't exist. `stage` is synthesized (`rolling_back`/`rolled_back`)
	 * rather than read verbatim from the job's real status column: unlike
	 * matching/apply, there's no interim enum value for "rollback in
	 * flight" in `embp_jobs.status` (SPEC's schema only has the terminal
	 * `rolled_back`), so the job stays `applied` in the database for the
	 * whole run and only flips at the very end.
	 *
	 * @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;
		}

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

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

	/**
	 * Flips the job to `rolled_back` -- even with partial snapshot
	 * failures, same tolerance as BatchApplier's apply.
	 *
	 * @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']['rollback']['failed'] ?? 0 ) : 0;

		if ( $failed > 0 ) {
			$this->jobs->mark_failed(
				$job_id,
				'rollback',
				sprintf(
					/* translators: %d: failed snapshots. */
					__( 'Rollback completed with %d failed product(s). Retry the rollback.', 'emberprice' ),
					$failed
				)
			);
			return;
		}

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

	/**
	 * Merges the rollback progress into the job's `totals`, preserving
	 * whatever `totals.apply`/row-status counts already live there.
	 *
	 * @param int $job_id Job id.
	 * @param int $done   Snapshots processed so far.
	 * @param int $total  Fixed snapshot-count total.
	 * @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['rollback'] ?? array() );

		$totals['rollback'] = 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 );
	}
}
