<?php
/**
 * Launch scheduling service.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Scheduler;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are escaped at UI boundaries.

use ScheduledChanges\Capabilities\CapabilityManager;
use ScheduledChanges\Domain\Audit\AuditEvent;
use ScheduledChanges\Domain\Launch\LaunchStatus;
use ScheduledChanges\Domain\Launch\LaunchVersion;
use ScheduledChanges\Domain\Launch\LaunchVersionRole;
use ScheduledChanges\Domain\Launch\LaunchVersionStatus;
use ScheduledChanges\Domain\Run\RunStatus;
use ScheduledChanges\Domain\Run\TriggerSource;
use ScheduledChanges\Domain\Shared\UtcTimestamp;
use ScheduledChanges\Domain\Shared\Uuid;
use ScheduledChanges\Lock\LockManager;
use ScheduledChanges\Provider\ProviderAuthorization;
use ScheduledChanges\Provider\ProviderAuthorizationService;
use ScheduledChanges\Repository\AuditRepository;
use ScheduledChanges\Repository\LaunchRepository;
use ScheduledChanges\Repository\LaunchVersionRepository;
use ScheduledChanges\Repository\RunRepository;
use ScheduledChanges\Validation\PlanValidator;

/**
 * Validates, queues, and atomically replaces one-time launch actions.
 */
final class SchedulerService implements LaunchMutationScheduler {
	private LaunchRepository $launches;
	private LaunchVersionRepository $versions;
	private AuditRepository $audit;
	private PlanValidator $validator;
	private ActionSchedulerGateway $actions;
	private LockManager $locks;
	private RecurrenceSafetyValidator $recurrence;
	private ProviderAuthorization $authorization;
	private ?RunRepository $runs;
	public function __construct(
		LaunchRepository $launches,
		LaunchVersionRepository $versions,
		AuditRepository $audit,
		PlanValidator $validator,
		ActionSchedulerGateway $actions,
		LockManager $locks,
		?RecurrenceSafetyValidator $recurrence = null,
		?ProviderAuthorization $authorization = null,
		?RunRepository $runs = null
	) {
		$recurrence        ??= new RecurrenceSafetyValidator();
		$this->launches      = $launches;
		$this->versions      = $versions;
		$this->audit         = $audit;
		$this->validator     = $validator;
		$this->actions       = $actions;
		$this->locks         = $locks;
		$this->recurrence    = $recurrence;
		$this->authorization = $authorization ?? new ProviderAuthorizationService();
		$this->runs          = $runs;
	}

	public function schedule(
		int $version_id,
		UtcTimestamp $scheduled_at,
		string $trigger = TriggerSource::SCHEDULED
	): int {
		return $this->dispatch( $version_id, $scheduled_at, $trigger, false );
	}

	public function runNow( int $version_id, ?UtcTimestamp $occurrence_at = null ): int {
		return $this->dispatch( $version_id, $occurrence_at ?? UtcTimestamp::now(), TriggerSource::MANUAL, true );
	}

	/**
	 * Queues a manual retry as a new attempt while preserving source-run lineage.
	 *
	 * @return array{source_run_id:int,launch_version_id:int,action_scheduler_id:int,attempt_number:int,occurrence_at_utc:string}
	 */
	public function manualRetry( int $source_run_id, int $actor_id, ?int $expected_launch_id = null ): array {
		if ( null === $this->runs ) {
			throw new SchedulingException( 'Run repository is unavailable.' );
		}

		if ( $actor_id <= 0 || ! user_can( $actor_id, CapabilityManager::EXECUTE_SCHEDULES ) ) {
			throw new SchedulingException( 'Current user cannot retry product changes.' );
		}

		$sourceRun = $this->runs->findById( $source_run_id );
		if ( null === $sourceRun || null === $sourceRun->id() ) {
			throw new SchedulingException( 'Source run was not found.' );
		}

		if ( ! $this->manualRetryAllowed( $sourceRun->status(), $sourceRun->result() ) ) {
			throw new \InvalidArgumentException( 'Only retryable terminal runs can be retried manually.' );
		}

		$occurrenceAt = $sourceRun->scheduledAt();
		if ( null === $occurrenceAt ) {
			throw new SchedulingException( 'Source run occurrence timestamp is unavailable.' );
		}

		$version = $this->versions->findById( $sourceRun->launchVersionId() );
		if ( null === $version || null === $version->id() ) {
			throw new SchedulingException( 'Immutable launch version was not found.' );
		}

		$launch = $this->launches->findById( $sourceRun->launchId() );
		if ( null === $launch || null === $launch->id() ) {
			throw new SchedulingException( 'Launch was not found for the source run.' );
		}

		if ( $version->launchId() !== $launch->id() || ( null !== $expected_launch_id && $expected_launch_id !== $launch->id() ) ) {
			throw new \InvalidArgumentException( 'Source run does not belong to this launch.' );
		}

		$this->assertAuthorizedForVersion( ProviderAuthorization::ACTION_EXECUTE, $actor_id, $version );

		$validation = $this->validator->validate(
			$version->plan()->graph(),
			$version->plan()->requiredProviders(),
			$actor_id
		);

		if ( ! $validation->isValid() ) {
			throw new ScheduleValidationException( $validation );
		}

		$scheduleLock = 'launch-schedule:' . $launch->id();
		$lockToken    = Uuid::generate()->value();
		$actionId     = 0;
		$attempt      = 0;
		$queuedAt     = UtcTimestamp::now();
		$this->locks->acquire( array( $scheduleLock ), $lockToken, 30 );

		try {
			$attempt  = $this->runs->nextAttemptNumber( $version->id(), $occurrenceAt );
			$actionId = $this->actions->enqueueNow(
				$version->id(),
				$occurrenceAt,
				TriggerSource::RETRY,
				$attempt,
				true,
				$actor_id
			);
			$this->versions->markQueuedCallback(
				$version->id(),
				$actionId,
				$queuedAt,
				$occurrenceAt,
				$attempt,
				TriggerSource::RETRY
			);
			$this->launches->updateStatus( $launch->id(), LaunchStatus::SCHEDULED );
		} catch ( \Throwable $throwable ) {
			if ( $actionId > 0 ) {
				$this->actions->cancelAction( $actionId );
			}

			throw $throwable;
		} finally {
			$this->locks->release( array( $scheduleLock ), $lockToken );
		}

		$this->audit->record(
			AuditEvent::info(
				'run.manual_retry_scheduled',
				$actor_id,
				array(
					'source_run_id'       => $sourceRun->id(),
					'source_run_status'   => $sourceRun->status(),
					'launch_version_id'   => $version->id(),
					'occurrence_at_utc'   => $occurrenceAt->toStorage(),
					'attempt_number'      => $attempt,
					'action_scheduler_id' => $actionId,
					'trigger_source'      => TriggerSource::RETRY,
				),
				$launch->id()
			)
		);

		return array(
			'source_run_id'       => $sourceRun->id(),
			'launch_version_id'   => $version->id(),
			'action_scheduler_id' => $actionId,
			'attempt_number'      => $attempt,
			'occurrence_at_utc'   => $occurrenceAt->toStorage(),
		);
	}

	private function dispatch(
		int $version_id,
		UtcTimestamp $scheduled_at,
		string $trigger,
		bool $enqueue_now
	): int {
		$capability = TriggerSource::MANUAL === $trigger
			? CapabilityManager::EXECUTE_SCHEDULES
			: CapabilityManager::SCHEDULE_CHANGES;

		if ( ! current_user_can( $capability ) ) {
			throw new SchedulingException( 'Current user cannot dispatch product changes.' );
		}

		$schedulerActorId = get_current_user_id();
		$version          = $this->versions->findById( $version_id );

		if ( null === $version || null === $version->id() ) {
			throw new SchedulingException( 'Immutable launch version was not found.' );
		}

		if ( LaunchVersionRole::START !== $version->role() ) {
			throw new \InvalidArgumentException( 'Schedule the start version; linked end versions are queued automatically.' );
		}

		$launch = $this->launches->findById( $version->launchId() );
		if ( null === $launch || null === $launch->id() ) {
			throw new SchedulingException( 'Launch was not found for the immutable version.' );
		}

		$this->assertAuthorizedForVersion(
			TriggerSource::MANUAL === $trigger ? ProviderAuthorization::ACTION_EXECUTE : ProviderAuthorization::ACTION_SCHEDULE,
			$schedulerActorId,
			$version
		);

		if ( ! $enqueue_now && $scheduled_at->native() < UtcTimestamp::now()->native() ) {
			throw new \InvalidArgumentException( 'Launch schedule time cannot be in the past.' );
		}

		$validation = $this->validator->validate(
			$version->plan()->graph(),
			$version->plan()->requiredProviders(),
			$schedulerActorId
		);

		if ( null !== $launch->recurrenceRule() ) {
			$validation = $validation->merge(
				$this->recurrence->validate( $version->plan()->graph() ),
				$this->recurrence->validateRule( $launch->recurrenceRule() )
			);
		}

		if ( ! $validation->isValid() ) {
			throw new ScheduleValidationException( $validation );
		}

		$endVersion = $this->versions->findLinkedEndVersion( $version_id );
		$endAt      = $launch->endAt();

		if ( null !== $endAt && null === $endVersion ) {
			throw new \InvalidArgumentException( 'Launch end time requires a compiled immutable end version.' );
		}

		if ( null === $endAt && null !== $endVersion ) {
			throw new \InvalidArgumentException( 'A linked end version requires a launch end time.' );
		}

		if ( null !== $endAt && $endAt->native() <= $scheduled_at->native() ) {
			throw new \InvalidArgumentException( 'Launch end time must be after its start time.' );
		}

		if ( null !== $endVersion ) {
			$this->assertAuthorizedForVersion(
				TriggerSource::MANUAL === $trigger ? ProviderAuthorization::ACTION_EXECUTE : ProviderAuthorization::ACTION_SCHEDULE,
				$schedulerActorId,
				$endVersion
			);
			$endValidation = $this->validator->validate(
				$endVersion->plan()->graph(),
				$endVersion->plan()->requiredProviders(),
				$schedulerActorId
			);

			if ( ! $endValidation->isValid() ) {
				throw new ScheduleValidationException( $endValidation );
			}
		}

		$scheduleLock = 'launch-schedule:' . $launch->id();
		$lockToken    = Uuid::generate()->value();
		$this->locks->acquire( array( $scheduleLock ), $lockToken, 30 );

		$previousVersionId = null;
		$actionId          = 0;
		$endActionId       = null;
		$cancelled         = 0;
		$schedulePersisted = false;

		try {
			$lockedLaunch = $this->launches->findById( $launch->id() );

			if ( null === $lockedLaunch ) {
				throw new SchedulingException( 'Launch was not found after acquiring the scheduling lock.' );
			}

			$previousVersionId = $lockedLaunch->currentVersionId();
			$actionId          = $enqueue_now
				? $this->actions->enqueueNow(
					$version_id,
					$scheduled_at,
					$trigger,
					1,
					true,
					$schedulerActorId
				)
				: $this->actions->schedule(
					$version_id,
					$scheduled_at,
					$trigger,
					1,
					true,
					null,
					$schedulerActorId
				);

			if ( null !== $endVersion && null !== $endVersion->id() && null !== $endAt ) {
				$endActionId = $this->actions->schedule(
					$endVersion->id(),
					$endAt,
					TriggerSource::SCHEDULED_END,
					1,
					true,
					null,
					$schedulerActorId
				);
				$this->versions->markQueuedCallback(
					$endVersion->id(),
					$endActionId,
					$endAt,
					$endAt,
					1,
					TriggerSource::SCHEDULED_END
				);
			}

			$this->versions->markQueuedCallback(
				$version_id,
				$actionId,
				$scheduled_at,
				$scheduled_at,
				1,
				$trigger
			);
			$this->launches->markScheduled( $launch->id(), $version_id, $scheduled_at );
			$schedulePersisted = true;
			if ( null !== $previousVersionId && $previousVersionId === $version_id ) {
				$cancelled = $this->actions->cancelVersion( $version_id, $actionId );
			} elseif ( null !== $previousVersionId ) {
				$cancelled = $this->actions->cancelVersion( $previousVersionId );
				$this->versions->markSuperseded( $previousVersionId, UtcTimestamp::now() );
				$previousEnd = $this->versions->findLinkedEndVersion( $previousVersionId );

				if ( null !== $previousEnd && null !== $previousEnd->id() ) {
					$cancelled += $this->actions->cancelVersion( $previousEnd->id() );
					$this->versions->markSuperseded( $previousEnd->id(), UtcTimestamp::now() );
				}
			}
		} catch ( \Throwable $throwable ) {
			if ( ! $schedulePersisted && $actionId > 0 ) {
				$this->actions->cancelVersion( $version_id );
			}

			if ( ! $schedulePersisted && null !== $endVersion && null !== $endVersion->id() ) {
				$this->actions->cancelVersion( $endVersion->id() );
			}

			throw $throwable;
		} finally {
			$this->locks->release( array( $scheduleLock ), $lockToken );
		}

		$this->audit->record(
			AuditEvent::info(
				'launch.scheduled',
				$schedulerActorId,
				array(
					'launch_creator_id'          => $version->creatorId(),
					'launch_version_id'          => $version_id,
					'action_scheduler_id'        => $actionId,
					'end_action_scheduler_id'    => $endActionId,
					'end_version_id'             => null === $endVersion ? null : $endVersion->id(),
					'end_at_utc'                 => null === $endAt ? null : $endAt->toStorage(),
					'scheduled_at_utc'           => $scheduled_at->toStorage(),
					'trigger_source'             => $trigger,
					'superseded_version_id'      => $previousVersionId,
					'cancelled_previous_actions' => $cancelled,
				),
				$launch->id()
			)
		);

		return $actionId;
	}

	/**
	 * @param array<string,mixed> $result Source run result summary.
	 */
	private function manualRetryAllowed( string $status, array $result ): bool {
		if (
			in_array(
				$status,
				array(
					RunStatus::FAILED,
					RunStatus::STALE,
					RunStatus::DEAD_LETTER,
				),
				true
			)
		) {
			return true;
		}

		return RunStatus::PARTIALLY_FAILED === $status
			&& '' !== (string) ( $result['lifecycle_classification'] ?? '' );
	}

	public function cancel( int $version_id, int $actor_id ): int {
		$version = $this->versions->findById( $version_id );

		if ( null === $version || null === $version->id() ) {
			throw new SchedulingException( 'Immutable launch version was not found.' );
		}

		if ( LaunchVersionRole::START !== $version->role() ) {
			throw new \InvalidArgumentException( 'Cancel the start version; linked end versions are cancelled automatically.' );
		}

		$launch = $this->launches->findById( $version->launchId() );
		if ( null === $launch || null === $launch->id() ) {
			throw new SchedulingException( 'Launch was not found for the immutable version.' );
		}

		if ( LaunchStatus::SCHEDULED !== $launch->status() ) {
			throw new \InvalidArgumentException( 'Only scheduled launches can be cancelled.' );
		}

		$this->assertAuthorizedForVersion( ProviderAuthorization::ACTION_DELETE, $actor_id, $version );

		$cancelled = $this->actions->cancelVersion( $version_id );
		if ( LaunchVersionStatus::COMPLETED !== $version->status() ) {
			$this->versions->updateStatus( $version_id, LaunchVersionStatus::CANCELLED );
		}

		$endVersion = $this->versions->findLinkedEndVersion( $version_id );
		if ( null !== $endVersion && null !== $endVersion->id() ) {
			$cancelled += $this->actions->cancelVersion( $endVersion->id() );
			$this->versions->updateStatus( $endVersion->id(), LaunchVersionStatus::CANCELLED );
		}

		$now = UtcTimestamp::now();
		$this->launches->updateStatus( $launch->id(), LaunchStatus::CANCELLED, $now );
		$this->audit->record(
			AuditEvent::info(
				'launch.cancelled',
				$actor_id,
				array(
					'launch_version_id'      => $version_id,
					'cancelled_action_count' => $cancelled,
					'end_version_id'         => null === $endVersion ? null : $endVersion->id(),
					'cancelled_at_utc'       => $now->toStorage(),
				),
				$launch->id()
			)
		);

		return $cancelled;
	}

	/**
	 * Enforces provider-owned target authorization before mutating a launch version.
	 */
	private function assertAuthorizedForVersion( string $action, int $actor_id, LaunchVersion $version ): void {
		$targets = $version->plan()->graph()->targets();

		if ( array() === $targets ) {
			throw new SchedulingException( 'The launch version has no provider-owned targets to authorize.' );
		}

		foreach ( $targets as $target ) {
			$allowed = false;

			if ( ProviderAuthorization::ACTION_SCHEDULE === $action ) {
				$allowed = $this->authorization->canSchedule( $actor_id, $target );
			} elseif ( ProviderAuthorization::ACTION_EXECUTE === $action ) {
				$allowed = $this->authorization->canExecute( $actor_id, $target );
			} elseif ( ProviderAuthorization::ACTION_DELETE === $action ) {
				$allowed = $this->authorization->canDelete( $actor_id, $target );
			}

			if ( ! $allowed ) {
				throw new SchedulingException( 'The current actor is not authorized for this provider-owned target.' );
			}
		}
	}
}
