<?php
/**
 * Action Scheduler launch worker.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Scheduler;

use ScheduledChanges\Capabilities\CapabilityManager;
use ScheduledChanges\Domain\Audit\AuditEvent;
use ScheduledChanges\Domain\Audit\AuditSeverity;
use ScheduledChanges\Domain\Launch\Launch;
use ScheduledChanges\Domain\Launch\LaunchVersion;
use ScheduledChanges\Domain\Launch\LaunchStatus;
use ScheduledChanges\Domain\Launch\LaunchVersionStatus;
use ScheduledChanges\Domain\Launch\LaunchVersionRole;
use ScheduledChanges\Domain\Run\FailureClassification;
use ScheduledChanges\Domain\Run\Run;
use ScheduledChanges\Domain\Run\RunStatus;
use ScheduledChanges\Domain\Run\TriggerSource;
use ScheduledChanges\Domain\Shared\UtcTimestamp;
use ScheduledChanges\Domain\Shared\Uuid;
use ScheduledChanges\Execution\ExecutionEngine;
use ScheduledChanges\Execution\ExecutionOutcome;
use ScheduledChanges\Execution\ExecutionRequest;
use ScheduledChanges\Execution\RetryPolicy;
use ScheduledChanges\Extension\LaunchExecutionHookPayload;
use ScheduledChanges\Extension\PublicHooks;
use ScheduledChanges\Lock\LockContentionException;
use ScheduledChanges\Lock\LockManager;
use ScheduledChanges\Provider\ProviderManager;
use ScheduledChanges\Repository\AuditRepository;
use ScheduledChanges\Repository\LaunchRepository;
use ScheduledChanges\Repository\LaunchVersionRepository;
use ScheduledChanges\Repository\RunRepository;
use ScheduledChanges\Validation\PlanValidator;
use ScheduledChanges\Validation\ValidationReport;

/**
 * Creates a run, locks targets, validates, executes, retries, and finalizes.
 */
final class SchedulerWorker {
	private ProviderManager $providers;
	private LaunchRepository $launches;
	private LaunchVersionRepository $versions;
	private RunRepository $runs;
	private SchedulerOutcomeDispatcher $outcomes;
	private SchedulerLifecycleFinalizer $lifecycle;
	private PlanValidator $validator;
	private LockManager $locks;
	private ExecutionEngine $engine;
	private ActionSchedulerGateway $actions;
	private CurrentActionContext $actionContext;
	private RetryPolicy $retryPolicy;
	private EnvironmentFingerprint $environment;
	private const LOCK_TTL = 300;

	public function __construct( ProviderManager $providers, LaunchRepository $launches, LaunchVersionRepository $versions, RunRepository $runs, AuditRepository $audit, PlanValidator $validator, LockManager $locks, ExecutionEngine $engine, ActionSchedulerGateway $actions, CurrentActionContext $actionContext, ?RetryPolicy $retryPolicy = null, ?EnvironmentFingerprint $environment = null ) {
		$retryPolicy       ??= new RetryPolicy();
		$environment       ??= new EnvironmentFingerprint();
		$this->providers     = $providers;
		$this->launches      = $launches;
		$this->versions      = $versions;
		$this->runs          = $runs;
		$this->outcomes      = new SchedulerOutcomeDispatcher( $audit );
		$this->lifecycle     = new SchedulerLifecycleFinalizer( $launches, $versions );
		$this->validator     = $validator;
		$this->locks         = $locks;
		$this->engine        = $engine;
		$this->actions       = $actions;
		$this->actionContext = $actionContext;
		$this->retryPolicy   = $retryPolicy;
		$this->environment   = $environment;
	}

	/**
	 * Executes a canonical queue payload or an exact legacy positional callback.
	 *
	 * @param array<string,mixed>|int $payload_or_version Canonical payload or legacy version ID.
	 * @param string                  $scheduled_at_utc Legacy occurrence timestamp.
	 * @param string                  $trigger_source Legacy trigger source.
	 * @param int                     $requested_attempt Legacy attempt number.
	 * @param int                     $execution_actor_id Legacy execution actor.
	 */
	public function handle( $payload_or_version, $scheduled_at_utc = '', $trigger_source = 'scheduled', $requested_attempt = 1, $execution_actor_id = 0 ): void {
		QueueMonitor::recordActivity();
		$this->providers->load();

		$arguments = func_get_args();
		unset( $payload_or_version, $scheduled_at_utc, $trigger_source, $requested_attempt, $execution_actor_id );

		$payload = QueueActionPayload::fromActionArguments( $arguments );
		$version = $this->scheduledVersion( $payload );

		$launch = $this->scheduledLaunch( $version );
		$this->assertExecutionActor( $payload, $launch );

		if ( ! $this->claimQueuedCallback( $payload, $launch ) ) {
			return;
		}

		$context = $this->createRunContext( $payload, $version, $launch );
		if ( ! $this->startRun( $context ) ) {
			return;
		}

		$this->executeLifecycle( $context );
	}

	private function scheduledVersion( QueueActionPayload $payload ): LaunchVersion {
		$version_id = $payload->versionId();
		$version    = $this->versions->findById( $version_id );

		if ( null === $version || null === $version->id() ) {
			throw new \RuntimeException( 'Scheduled immutable launch version was not found.' );
		}

		return $version;
	}

	private function scheduledLaunch( LaunchVersion $version ): Launch {
		$launch = $this->launches->findById( $version->launchId() );
		if ( null === $launch || null === $launch->id() ) {
			throw new \RuntimeException( 'Scheduled launch was not found.' );
		}

		return $launch;
	}

	private function assertExecutionActor( QueueActionPayload $payload, Launch $launch ): void {
		$execution_actor_id = $payload->executionActorId();

		if ( $execution_actor_id <= 0 ) {
			throw new \RuntimeException( 'Scheduled launch action is missing its execution actor.' );
		}

		if ( ! user_can( $execution_actor_id, CapabilityManager::EXECUTE_SCHEDULES ) ) {
			$this->outcomes->record(
				AuditEvent::warning(
					'run.permission_denied',
					$execution_actor_id,
					array(
						'launch_version_id' => $payload->versionId(),
						'reason'            => 'execution_actor_capability_missing',
					),
					$launch->id()
				)
			);
			throw new \RuntimeException( 'The immutable execution actor can no longer execute scheduled changes.' );
		}
	}

	private function claimQueuedCallback( QueueActionPayload $payload, Launch $launch ): bool {
		$action_scheduler_id = $this->actionContext->actionId();
		$context             = array(
			'launch_version_id' => $payload->versionId(),
			'occurrence_at_utc' => $payload->occurrenceAtUtc(),
			'attempt_number'    => $payload->attemptNumber(),
			'trigger_source'    => $payload->triggerSource(),
		);

		if ( null === $action_scheduler_id ) {
			$this->recordClaimSkipped(
				$payload,
				$launch,
				array_merge(
					$context,
					array( 'reason' => 'action_scheduler_id_unavailable' )
				)
			);

			return false;
		}

		$claimed = $this->versions->claimQueuedCallback(
			$payload->versionId(),
			$action_scheduler_id,
			UtcTimestamp::fromStorage( $payload->occurrenceAtUtc() ),
			$payload->attemptNumber(),
			$payload->triggerSource()
		);

		if ( ! $claimed ) {
			$this->recordClaimSkipped(
				$payload,
				$launch,
				array_merge(
					$context,
					array(
						'action_scheduler_id' => $action_scheduler_id,
						'reason'              => 'queued_identity_mismatch_or_already_claimed',
					)
				)
			);
		}

		return $claimed;
	}

	/**
	 * @param array<string,mixed> $context Audit context.
	 */
	private function recordClaimSkipped( QueueActionPayload $payload, Launch $launch, array $context ): void {
		$this->outcomes->record(
			AuditEvent::warning(
				'run.claim_skipped',
				$payload->executionActorId(),
				$context,
				$launch->id()
			)
		);
	}

	private function createRunContext(
		QueueActionPayload $payload,
		LaunchVersion $version,
		Launch $launch
	): SchedulerRunContext {
		$version_id  = $payload->versionId();
		$actor_id    = $payload->executionActorId();
		$scheduledAt = UtcTimestamp::fromStorage( $payload->occurrenceAtUtc() );
		$attempt     = $payload->attemptNumber();
		$trigger     = $payload->triggerSource();
		$lockToken   = Uuid::generate()->value();
		$run         = Run::pending(
			$launch->id(),
			$version_id,
			$this->actionContext->actionId(),
			$trigger,
			$attempt,
			$scheduledAt,
			$lockToken,
			$this->environment->generate( $this->providers->providers() )
		);
		$run_id      = $this->runs->create( $run );
		$graph       = $version->plan()->graph();
		$targets     = array_map(
			static fn ( $target ): string => $target->lockReference(),
			$graph->targets()
		);

		return new SchedulerRunContext(
			$launch,
			$version,
			$version_id,
			$actor_id,
			$attempt,
			$scheduledAt,
			$lockToken,
			$run,
			$run_id,
			$targets
		);
	}

	private function startRun( SchedulerRunContext $context ): bool {
		$startedAt = UtcTimestamp::now();
		if ( ! $this->runs->markValidating( $context->runId(), $startedAt ) ) {
			$this->recordRunTransitionSkipped(
				$context,
				array( RunStatus::PENDING ),
				RunStatus::VALIDATING
			);

			return false;
		}

		$this->outcomes->audit(
			'run.started',
			AuditSeverity::INFO,
			$context->launchId(),
			$context->runId(),
			$context->actorId(),
			array(
				'launch_version_id'   => $context->versionId(),
				'action_scheduler_id' => $this->actionContext->actionId(),
				'attempt_number'      => $context->attempt(),
				'scheduled_at_utc'    => $context->scheduledAt()->toStorage(),
				'started_at_utc'      => $startedAt->toStorage(),
				'delay_seconds'       => max(
					0,
					$startedAt->native()->getTimestamp() - $context->scheduledAt()->native()->getTimestamp()
				),
			)
		);
		return true;
	}

	private function executeLifecycle( SchedulerRunContext $context ): void {
		$validation = ValidationReport::valid();
		try {
			$validation = $this->validateLockedRun( $context );

			if ( ! $validation->isValid() ) {
				$this->finalizeValidationFailure(
					$context->runId(),
					$context->launch(),
					$context->versionId(),
					$context->actorId(),
					$validation
				);

				return;
			}

			$outcome = $this->executeOperations( $context, $validation );

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

			$this->recordExecutionOutcome( $context, $validation, $outcome );
		} catch ( LockContentionException $exception ) {
			$this->handleLockContention(
				$exception,
				$context->runId(),
				$context->launch(),
				$context->versionId(),
				$context->actorId(),
				$context->attempt(),
				$validation
			);
		} catch ( \Throwable $throwable ) {
			$this->recordUnexpectedFailure( $context, $validation, $throwable );

			throw $throwable;
		} finally {
			$this->releaseLocks( $context );
		}
	}

	private function validateLockedRun( SchedulerRunContext $context ): ValidationReport {
		$this->locks->acquire( $context->targets(), $context->lockToken(), self::LOCK_TTL );
		$context->markLocked();

		$validation = $this->validator->validate(
			$context->graph(),
			$context->requiredProviders(),
			$context->actorId(),
			true,
			false
		);

		if ( ! $validation->isValid() ) {
			$this->releaseLocks( $context );
		}

		return $validation;
	}

	private function executeOperations(
		SchedulerRunContext $context,
		ValidationReport $validation
	): ?ExecutionOutcome {
		if ( ! $this->runs->markRunning( $context->runId(), $validation ) ) {
			$this->recordRunTransitionSkipped(
				$context,
				array( RunStatus::VALIDATING ),
				RunStatus::RUNNING
			);

			return null;
		}

		$this->launches->updateStatus( $context->launchId(), LaunchStatus::RUNNING );
		PublicHooks::beforeExecuteLaunch(
			LaunchExecutionHookPayload::before(
				$context->runId(),
				$context->run()->uuid()->value(),
				$context->launchId(),
				$context->versionId()
			)
		);

		$outcome = $this->engine->execute( $this->executionRequest( $context ) );
		$this->releaseLocks( $context );

		return $outcome;
	}

	private function executionRequest( SchedulerRunContext $context ): ExecutionRequest {
		return new ExecutionRequest(
			$context->runId(),
			$context->versionId(),
			$context->graph(),
			$context->run()->uuid(),
			$context->actorId(),
			$context->attempt(),
			$context->scheduledAt(),
			$context->launch()->failurePolicy(),
			$context->targets(),
			$context->lockToken()
		);
	}

	private function releaseLocks( SchedulerRunContext $context ): void {
		if ( ! $context->locked() ) {
			return;
		}

		$this->locks->release( $context->targets(), $context->lockToken() );
		$context->markLocksReleased();
	}

	/**
	 * @param string[] $expected_statuses Allowed prior statuses.
	 */
	private function recordRunTransitionSkipped( SchedulerRunContext $context, array $expected_statuses, string $target_status ): void {
		$this->outcomes->audit(
			'run.transition_skipped',
			AuditSeverity::WARNING,
			$context->launchId(),
			$context->runId(),
			$context->actorId(),
			array(
				'launch_version_id' => $context->versionId(),
				'expected_statuses' => $expected_statuses,
				'target_status'     => $target_status,
			)
		);
	}

	private function recordExecutionOutcome(
		SchedulerRunContext $context,
		ValidationReport $validation,
		ExecutionOutcome $outcome
	): void {
		$summary        = $outcome->summary();
		$status         = $outcome->status();
		$retryScheduled = false;

		if ( $this->shouldRetry( $context, $outcome ) ) {
			$summary        = $this->scheduleRetryOutcome( $context, $outcome, $summary );
			$retryScheduled = true === ( $summary['retry']['pointer_persisted'] ?? false );

			if ( ! $retryScheduled ) {
				$status = $this->statusWithWarnings( $status, $validation );
				$this->lifecycle->finalize( $context->launchId(), $context->versionId(), $status );
			}
		} elseif ( $this->shouldDeadLetter( $context, $outcome ) ) {
			$status  = RunStatus::DEAD_LETTER;
			$summary = $this->addRetryExhaustedContext(
				$summary,
				(string) $outcome->retryClassification(),
				$context->attempt()
			);
			$this->lifecycle->finalize( $context->launchId(), $context->versionId(), $status );
		} elseif ( $this->shouldScheduleRecurrence( $context, $status ) ) {
			$status  = $this->statusWithWarnings( $status, $validation );
			$summary = $this->scheduleNextOccurrence( $context, $summary );

			if ( true !== ( $summary['next_occurrence']['pointer_persisted'] ?? false ) ) {
				$this->lifecycle->finalize( $context->launchId(), $context->versionId(), $status );
			}
		} else {
			$status = $this->statusWithWarnings( $status, $validation );
			$this->lifecycle->finalize( $context->launchId(), $context->versionId(), $status );
		}

		if ( ! $this->finalizeRunOutcome( $context, $validation, $outcome, $status, $summary ) ) {
			return;
		}
	}

	private function shouldRetry( SchedulerRunContext $context, ExecutionOutcome $outcome ): bool {
		return null !== $outcome->retryClassification()
			&& $this->retryPolicy->retryable( $outcome->retryClassification(), $context->attempt() );
	}

	private function shouldDeadLetter( SchedulerRunContext $context, ExecutionOutcome $outcome ): bool {
		return null !== $outcome->retryClassification()
			&& $this->retryPolicy->exhausted( $outcome->retryClassification(), $context->attempt() );
	}

	private function shouldScheduleRecurrence( SchedulerRunContext $context, string $status ): bool {
		return in_array(
			$status,
			array( RunStatus::SUCCESSFUL, RunStatus::SUCCESSFUL_WITH_WARNINGS ),
			true
		)
			&& null !== $context->launch()->recurrenceRule()
			&& LaunchVersionRole::START === $context->version()->role();
	}

	/**
	 * @param array<string,mixed> $summary Execution summary.
	 *
	 * @return array<string,mixed>
	 */
	private function scheduleRetryOutcome(
		SchedulerRunContext $context,
		ExecutionOutcome $outcome,
		array $summary
	): array {
		$nextAttempt                           = $context->attempt() + 1;
		$retryAt                               = $this->afterSeconds( $this->retryPolicy->delaySeconds( $nextAttempt ) );
		$retryId                               = $this->actions->schedule(
			$context->versionId(),
			$retryAt,
			TriggerSource::RETRY,
			$nextAttempt,
			true,
			$context->scheduledAt(),
			$context->actorId()
		);
		$summary['retry']                      = array(
			'action_scheduler_id' => $retryId,
			'attempt_number'      => $nextAttempt,
			'scheduled_at_utc'    => $retryAt->toStorage(),
			'occurrence_at_utc'   => $context->scheduledAt()->toStorage(),
			'classification'      => $outcome->retryClassification(),
		);
		$persisted                             = $this->persistReplacementQueuedCallback(
			$context,
			$retryId,
			$retryAt,
			$context->scheduledAt(),
			$nextAttempt,
			TriggerSource::RETRY
		);
		$summary['retry']['pointer_persisted'] = $persisted;

		if ( ! $persisted ) {
			$summary['retry']['compensation'] = $this->compensateReplacementAction( $context, $retryId, 'retry' );

			return $summary;
		}

		$this->launches->updateStatus( $context->launchId(), LaunchStatus::SCHEDULED );

		return $summary;
	}

	/**
	 * @param array<string,mixed> $result Execution result summary.
	 *
	 * @return array<string,mixed>
	 */
	private function addRetryExhaustedContext( array $result, string $classification, int $attempt_number ): array {
		$result['dead_letter'] = array(
			'reason'          => 'retry_exhausted',
			'classification'  => $classification,
			'attempt_number'  => $attempt_number,
			'max_attempts'    => RetryPolicy::MAX_ATTEMPTS,
			'automatic_retry' => false,
		);

		return $result;
	}

	/**
	 * @param array<string,mixed> $summary Execution summary.
	 *
	 * @return array<string,mixed>
	 */
	private function scheduleNextOccurrence( SchedulerRunContext $context, array $summary ): array {
		$nextAt                     = RecurrenceRule::parse( (string) $context->launch()->recurrenceRule() )
			->next( $context->scheduledAt(), $context->launch()->timezone() );
		$nextId                     = $this->actions->schedule(
			$context->versionId(),
			$nextAt,
			TriggerSource::RECURRENCE,
			1,
			true,
			null,
			$context->actorId()
		);
		$summary['next_occurrence'] = array(
			'action_scheduler_id' => $nextId,
			'scheduled_at_utc'    => $nextAt->toStorage(),
			'occurrence_at_utc'   => $nextAt->toStorage(),
		);
		$persisted                  = $this->persistReplacementQueuedCallback(
			$context,
			$nextId,
			$nextAt,
			$nextAt,
			1,
			TriggerSource::RECURRENCE
		);
		$summary['next_occurrence']['pointer_persisted'] = $persisted;

		if ( ! $persisted ) {
			$summary['next_occurrence']['compensation'] = $this->compensateReplacementAction( $context, $nextId, 'recurrence' );

			return $summary;
		}

		$this->launches->markScheduled( $context->launchId(), $context->versionId(), $nextAt );

		return $summary;
	}

	private function persistReplacementQueuedCallback( SchedulerRunContext $context, int $action_id, UtcTimestamp $fire_at, UtcTimestamp $occurrence_at, int $attempt_number, string $trigger_source ): bool {
		$currentActionId = $context->run()->actionSchedulerId();

		if ( null === $currentActionId ) {
			return false;
		}

		return $this->versions->replaceQueuedCallback(
			$context->versionId(),
			$currentActionId,
			$context->scheduledAt(),
			$context->attempt(),
			$context->run()->triggerSource(),
			$action_id,
			$fire_at,
			$occurrence_at,
			$attempt_number,
			$trigger_source
		);
	}

	/**
	 * @return array<string,mixed>
	 */
	private function compensateReplacementAction( SchedulerRunContext $context, int $action_id, string $kind ): array {
		$cancelled = $this->actions->cancelVersion( $context->versionId(), $context->run()->actionSchedulerId() );
		$result    = array(
			'reason'                 => 'queue_pointer_persist_failed',
			'reschedule_kind'        => $kind,
			'action_scheduler_id'    => $action_id,
			'cancelled_action_count' => $cancelled,
		);

		$this->outcomes->audit(
			'run.reschedule_compensated',
			AuditSeverity::WARNING,
			$context->launchId(),
			$context->runId(),
			$context->actorId(),
			array_merge(
				$result,
				array(
					'launch_version_id' => $context->versionId(),
					'occurrence_at_utc' => $context->scheduledAt()->toStorage(),
					'attempt_number'    => $context->attempt(),
				)
			)
		);

		return $result;
	}

	/**
	 * @param array<string,mixed> $summary Execution summary.
	 */
	private function finalizeRunOutcome(
		SchedulerRunContext $context,
		ValidationReport $validation,
		ExecutionOutcome $outcome,
		string $status,
		array $summary
	): bool {
		if ( ! $this->runs->finalize(
			$context->runId(),
			$status,
			$validation,
			$summary,
			count( $validation->warnings() ),
			$outcome->failedCount(),
			UtcTimestamp::now()
		) ) {
			$this->recordRunTransitionSkipped(
				$context,
				array( RunStatus::VALIDATING, RunStatus::RUNNING ),
				$status
			);

			return false;
		}

		$this->outcomes->audit(
			'run.completed',
			$this->terminalAuditSeverity( $status ),
			$context->launchId(),
			$context->runId(),
			$context->actorId(),
			array_merge( $summary, array( 'status' => $status ) )
		);
		PublicHooks::afterExecuteLaunch(
			LaunchExecutionHookPayload::after(
				$context->runId(),
				$context->launchId(),
				$context->versionId(),
				$status
			)
		);

		return true;
	}

	private function recordUnexpectedFailure(
		SchedulerRunContext $context,
		ValidationReport $validation,
		\Throwable $throwable
	): void {
		if ( ! $this->runs->finalize(
			$context->runId(),
			RunStatus::FAILED,
			$validation,
			array(
				'classification' => FailureClassification::UNKNOWN,
				'message'        => $throwable->getMessage(),
			),
			count( $validation->warnings() ),
			1,
			UtcTimestamp::now()
		) ) {
			$this->recordRunTransitionSkipped(
				$context,
				array( RunStatus::VALIDATING, RunStatus::RUNNING ),
				RunStatus::FAILED
			);

			return;
		}

		$this->launches->updateStatus( $context->launchId(), LaunchStatus::FAILED, UtcTimestamp::now() );
		$this->versions->transitionStatus( $context->versionId(), array( LaunchVersionStatus::RUNNING ), LaunchVersionStatus::FAILED );
		$this->outcomes->audit(
			'run.failed',
			AuditSeverity::CRITICAL,
			$context->launchId(),
			$context->runId(),
			$context->actorId(),
			array( 'message' => $throwable->getMessage() )
		);
	}

	private function finalizeValidationFailure(
		int $run_id,
		Launch $launch,
		int $version_id,
		int $actor_id,
		ValidationReport $validation
	): void {
		if ( ! $this->runs->finalize(
			$run_id,
			RunStatus::FAILED,
			$validation,
			array( 'classification' => FailureClassification::PERMANENT_VALIDATION ),
			count( $validation->warnings() ),
			count( $validation->blockingErrors() ),
			UtcTimestamp::now()
		) ) {
			return;
		}

		$this->launches->updateStatus( (int) $launch->id(), LaunchStatus::FAILED, UtcTimestamp::now() );
		$this->versions->transitionStatus( $version_id, array( LaunchVersionStatus::RUNNING ), LaunchVersionStatus::FAILED );
		$this->outcomes->audit(
			'run.validation_failed',
			AuditSeverity::ERROR,
			(int) $launch->id(),
			$run_id,
			$actor_id,
			$validation->toArray()
		);
	}

	private function handleLockContention(
		LockContentionException $exception,
		int $run_id,
		Launch $launch,
		int $version_id,
		int $actor_id,
		int $attempt,
		ValidationReport $validation
	): void {
		$result = array(
			'classification'   => FailureClassification::LOCK_CONTENTION,
			'target_reference' => $exception->targetReference(),
		);

		$status = RunStatus::FAILED;

		if ( $this->retryPolicy->retryable( FailureClassification::LOCK_CONTENTION, $attempt ) ) {
			$nextAttempt                          = $attempt + 1;
			$retryAt                              = $this->afterSeconds( $this->retryPolicy->delaySeconds( $nextAttempt ) );
			$occurrenceAt                         = $this->scheduledOccurrence( $run_id );
			$currentRun                           = $this->runs->findById( $run_id );
			$retryActionId                        = $this->actions->schedule(
				$version_id,
				$retryAt,
				TriggerSource::RETRY,
				$nextAttempt,
				true,
				$occurrenceAt,
				$actor_id
			);
			$result['retry']                      = array(
				'action_scheduler_id' => $retryActionId,
				'attempt_number'      => $nextAttempt,
				'scheduled_at_utc'    => $retryAt->toStorage(),
				'occurrence_at_utc'   => $occurrenceAt->toStorage(),
			);
			$persisted                            = null !== $currentRun
				&& null !== $currentRun->actionSchedulerId()
				&& $this->versions->replaceQueuedCallback(
					$version_id,
					$currentRun->actionSchedulerId(),
					$occurrenceAt,
					$attempt,
					$currentRun->triggerSource(),
					$retryActionId,
					$retryAt,
					$occurrenceAt,
					$nextAttempt,
					TriggerSource::RETRY
				);
			$result['retry']['pointer_persisted'] = $persisted;

			if ( $persisted ) {
				$this->launches->updateStatus( (int) $launch->id(), LaunchStatus::SCHEDULED );
			} else {
				$cancelled                       = $this->actions->cancelVersion( $version_id, null === $currentRun ? null : $currentRun->actionSchedulerId() );
				$result['retry']['compensation'] = array(
					'reason'                 => 'queue_pointer_persist_failed',
					'reschedule_kind'        => 'lock_contention_retry',
					'action_scheduler_id'    => $retryActionId,
					'cancelled_action_count' => $cancelled,
				);
				$this->lifecycle->finalize( (int) $launch->id(), $version_id, $status );
				$this->outcomes->audit(
					'run.reschedule_compensated',
					AuditSeverity::WARNING,
					(int) $launch->id(),
					$run_id,
					$actor_id,
					array_merge(
						$result['retry']['compensation'],
						array(
							'launch_version_id' => $version_id,
							'occurrence_at_utc' => $occurrenceAt->toStorage(),
							'attempt_number'    => $attempt,
						)
					)
				);
			}
		} else {
			if ( $this->retryPolicy->exhausted( FailureClassification::LOCK_CONTENTION, $attempt ) ) {
				$status = RunStatus::DEAD_LETTER;
				$result = $this->addRetryExhaustedContext( $result, FailureClassification::LOCK_CONTENTION, $attempt );
			}

			$this->lifecycle->finalize( (int) $launch->id(), $version_id, $status );
		}

		if ( ! $this->runs->finalize(
			$run_id,
			$status,
			$validation,
			$result,
			count( $validation->warnings() ),
			1,
			UtcTimestamp::now()
		) ) {
			return;
		}

		$this->outcomes->audit(
			'run.lock_contention',
			RunStatus::DEAD_LETTER === $status ? AuditSeverity::CRITICAL : AuditSeverity::WARNING,
			(int) $launch->id(),
			$run_id,
			$actor_id,
			$result
		);
	}

	private function statusWithWarnings( string $status, ValidationReport $validation ): string {
		return RunStatus::SUCCESSFUL === $status && array() !== $validation->warnings()
			? RunStatus::SUCCESSFUL_WITH_WARNINGS
			: $status;
	}

	private function terminalAuditSeverity( string $status ): string {
		switch ( $status ) {
			case RunStatus::SUCCESSFUL:
			case RunStatus::SUCCESSFUL_WITH_WARNINGS:
				return AuditSeverity::INFO;
			case RunStatus::STALE:
				return AuditSeverity::WARNING;
			case RunStatus::PARTIALLY_FAILED:
			case RunStatus::FAILED:
				return AuditSeverity::ERROR;
			case RunStatus::DEAD_LETTER:
				return AuditSeverity::CRITICAL;
			default:
				throw new \InvalidArgumentException( 'Unsupported terminal run status: ' . esc_html( $status ) );
		}
	}

	private function afterSeconds( int $seconds ): UtcTimestamp {
		return new UtcTimestamp(
			( new \DateTimeImmutable( 'now', new \DateTimeZone( 'UTC' ) ) )
				->modify( '+' . $seconds . ' seconds' )
		);
	}

	private function scheduledOccurrence( int $run_id ): UtcTimestamp {
		$run = $this->runs->findById( $run_id );

		if ( null === $run || null === $run->scheduledAt() ) {
			throw new \RuntimeException( 'Run occurrence timestamp is unavailable for retry.' );
		}

		return $run->scheduledAt();
	}
}
