<?php
/**
 * WordPress Action Scheduler gateway.
 *
 * @package ScheduledChanges
 */

namespace ScheduledChanges\Scheduler;

use ScheduledChanges\Domain\Run\TriggerSource;
use ScheduledChanges\Domain\Shared\UtcTimestamp;

/**
 * Uses the Action Scheduler public APIs bundled by core or another plugin.
 */
final class WordPressActionSchedulerGateway implements ActionSchedulerGateway {
	public const GROUP = 'jjs-post-scheduler';
	public const HOOK  = 'scheduled_changes_execute_launch';

	public function available(): bool {
		return function_exists( 'as_schedule_single_action' )
			&& function_exists( 'as_enqueue_async_action' )
			&& function_exists( 'as_get_scheduled_actions' )
			&& class_exists( 'ActionScheduler' )
			&& class_exists( 'ActionScheduler_Store' );
	}

	public function schedule(
		int $version_id,
		UtcTimestamp $scheduled_at,
		string $trigger,
		int $attempt_number,
		bool $unique = true,
		?UtcTimestamp $occurrence_at = null,
		int $execution_actor_id = 0
	): int {
		if ( ! $this->available() ) {
			throw new \RuntimeException( 'Action Scheduler APIs are unavailable.' );
		}

		if ( $execution_actor_id <= 0 ) {
			throw new \InvalidArgumentException( 'Scheduled launch actions require an execution actor.' );
		}

		$args = $this->actionArgs(
			$version_id,
			$occurrence_at ?? $scheduled_at,
			$trigger,
			$attempt_number,
			$execution_actor_id
		);

		if ( $unique ) {
			$existing = $this->matchingAction( $args );

			if ( null !== $existing ) {
				return $existing;
			}
		}

		$actionId = as_schedule_single_action(
			$scheduled_at->native()->getTimestamp(),
			self::HOOK,
			$args,
			self::GROUP,
			false,
			10
		);

		if ( $actionId <= 0 ) {
			throw new \RuntimeException( 'Action Scheduler did not create the launch action.' );
		}

		return $actionId;
	}

	public function enqueueNow(
		int $version_id,
		UtcTimestamp $occurrence_at,
		string $trigger,
		int $attempt_number,
		bool $unique = true,
		int $execution_actor_id = 0
	): int {
		if ( ! $this->available() ) {
			throw new \RuntimeException( 'Action Scheduler APIs are unavailable.' );
		}

		if ( $execution_actor_id <= 0 ) {
			throw new \InvalidArgumentException( 'Scheduled launch actions require an execution actor.' );
		}

		$args = $this->actionArgs(
			$version_id,
			$occurrence_at,
			$trigger,
			$attempt_number,
			$execution_actor_id
		);

		if ( $unique ) {
			$existing = $this->matchingAction( $args );

			if ( null !== $existing ) {
				return $existing;
			}
		}

		$actionId = as_enqueue_async_action(
			self::HOOK,
			$args,
			self::GROUP,
			$unique,
			10
		);

		if ( $actionId <= 0 ) {
			throw new \RuntimeException( 'Action Scheduler did not create the launch action.' );
		}

		return $actionId;
	}

	/**
	 * Builds canonical Action Scheduler callback arguments.
	 *
	 * @return array<int,mixed>
	 */
	private function actionArgs(
		int $version_id,
		UtcTimestamp $occurrence_at,
		string $trigger,
		int $attempt_number,
		int $execution_actor_id
	): array {
		return array(
			QueueActionPayload::create(
				$version_id,
				$occurrence_at->toStorage(),
				$trigger,
				$attempt_number,
				$execution_actor_id
			)->toArray(),
		);
	}

	/**
	 * Finds the same pending or running callback arguments.
	 *
	 * @param array<int,mixed> $args Callback arguments.
	 */
	private function matchingAction( array $args ): ?int {
		$store = \ActionScheduler::store();

		foreach ( array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ) as $status ) {
			$actionIds = as_get_scheduled_actions(
				array(
					'hook'     => self::HOOK,
					'group'    => self::GROUP,
					'status'   => $status,
					'per_page' => -1,
				),
				'ids'
			);

			foreach ( $actionIds as $actionId ) {
				if ( $store->fetch_action( $actionId )->get_args() === $args ) {
					return (int) $actionId;
				}
			}
		}

		return null;
	}

	public function cancelVersion( int $version_id, ?int $except_action_id = null ): int {
		if ( ! $this->available() ) {
			return 0;
		}

		$store     = \ActionScheduler::store();
		$actionIds = as_get_scheduled_actions(
			array(
				'hook'     => self::HOOK,
				'group'    => self::GROUP,
				'status'   => \ActionScheduler_Store::STATUS_PENDING,
				'per_page' => -1,
			),
			'ids'
		);
		$cancelled = 0;

		foreach ( $actionIds as $actionId ) {
			if ( null !== $except_action_id && (int) $actionId === $except_action_id ) {
				continue;
			}

			$action = $store->fetch_action( $actionId );
			$args   = $action->get_args();

			try {
				$payload = QueueActionPayload::fromActionArguments( $args );
			} catch ( \UnexpectedValueException $exception ) {
				continue;
			}

			if ( $payload->versionId() !== $version_id ) {
				continue;
			}

			$store->cancel_action( $actionId );
			++$cancelled;
		}

		return $cancelled;
	}

	/**
	 * {@inheritDoc}
	 */
	public function cancelAction( int $action_id ): bool {
		if ( $action_id <= 0 || ! $this->available() ) {
			return false;
		}

		\ActionScheduler::store()->cancel_action( $action_id );

		return true;
	}

	/**
	 * {@inheritDoc}
	 */
	public function scheduledLaunchActions(): array {
		if ( ! $this->available() ) {
			return array();
		}

		$store = \ActionScheduler::store();
		$items = array();

		foreach ( array( \ActionScheduler_Store::STATUS_PENDING, \ActionScheduler_Store::STATUS_RUNNING ) as $status ) {
			$actionIds = as_get_scheduled_actions(
				array(
					'hook'     => self::HOOK,
					'group'    => self::GROUP,
					'status'   => $status,
					'per_page' => -1,
				),
				'ids'
			);

			foreach ( $actionIds as $actionId ) {
				try {
					$payload = QueueActionPayload::fromActionArguments( $store->fetch_action( $actionId )->get_args() );
				} catch ( \UnexpectedValueException $exception ) {
					continue;
				}

				$items[] = array(
					'action_scheduler_id' => (int) $actionId,
					'version_id'          => $payload->versionId(),
					'occurrence_at_utc'   => $payload->occurrenceAtUtc(),
					'attempt_number'      => $payload->attemptNumber(),
					'trigger_source'      => $payload->triggerSource(),
					'status'              => (string) $status,
				);
			}
		}

		return $items;
	}

	public function counts(): array {
		if ( ! $this->available() ) {
			return array(
				'pending'  => 0,
				'running'  => 0,
				'failed'   => 0,
				'complete' => 0,
				'overdue'  => 0,
			);
		}

		$store = \ActionScheduler::store();
		$base  = array(
			'hook'  => self::HOOK,
			'group' => self::GROUP,
		);

		return array(
			'pending'  => $this->count( $store, $base, \ActionScheduler_Store::STATUS_PENDING ),
			'running'  => $this->count( $store, $base, \ActionScheduler_Store::STATUS_RUNNING ),
			'failed'   => $this->count( $store, $base, \ActionScheduler_Store::STATUS_FAILED ),
			'complete' => $this->count( $store, $base, \ActionScheduler_Store::STATUS_COMPLETE ),
			'overdue'  => (int) $store->query_actions(
				array_merge(
					$base,
					array(
						'status'       => \ActionScheduler_Store::STATUS_PENDING,
						'date'         => as_get_datetime_object(),
						'date_compare' => '<',
					)
				),
				'count'
			),
		);
	}

	/**
	 * @param \ActionScheduler_Store $store Action Scheduler store.
	 * @param array<string,mixed>    $base Base query.
	 */
	private function count( \ActionScheduler_Store $store, array $base, string $status ): int {
		return (int) $store->query_actions(
			array_merge( $base, array( 'status' => $status ) ),
			'count'
		);
	}
}
