<?php
/**
 * Sync job retry policy value object.
 *
 * Immutable value object that encapsulates the retry behaviour for
 * a sync job, per `specifications/sync-and-cache-strategy.md` §12.
 * The type carries the maximum attempt count and a per-attempt
 * backoff schedule so the dispatcher and worker layers can branch
 * on a single validated value without re-deriving the schedule
 * from configuration on every retry decision.
 *
 * Per §12.2 the default schedule is exponential:
 *
 *   - 1st failure → wait  5 minutes (300s)
 *   - 2nd failure → wait 15 minutes (900s)
 *   - 3rd failure → wait  1 hour (3600s)
 *   - 4th failure → wait  6 hours (21600s)
 *   - 5th failure → stop (max attempts reached)
 *
 * Per §12.3, when the provider returns a `Retry-After` value the
 * provider's directive takes priority over the policy's static
 * backoff. {@see self::withRetryAfter()} implements that rule.
 *
 * Per §12.4, the `max_attempts` default is 5 for `sync_product`,
 * with overrides for `refresh_provider_health` (3) and
 * `rebuild_render_cache` (2). {@see self::forJobType()} maps the
 * known job-type tokens to their canonical policies.
 *
 * @package CommerceLinkBuilder\Domain\Sync
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Domain\Sync;

use InvalidArgumentException;

/**
 * Immutable retry policy from `sync-and-cache-strategy.md` §12.
 * See the file-level docblock for the mapping to §12.2 backoff,
 * §12.3 `Retry-After` priority, and §12.4 per-job-type
 * `max_attempts` overrides.
 */
final class RetryPolicy {

	/**
	 * Job-type token for the per-product sync worker (§12.4).
	 */
	public const JOB_SYNC_PRODUCT = 'sync_product';

	/**
	 * Job-type token for the provider health refresh worker
	 * (§12.4).
	 */
	public const JOB_REFRESH_PROVIDER_HEALTH = 'refresh_provider_health';

	/**
	 * Job-type token for the render-cache rebuild worker (§12.4).
	 */
	public const JOB_REBUILD_RENDER_CACHE = 'rebuild_render_cache';

	/**
	 * Maximum number of attempts before the job is marked failed.
	 * Always >= 1 (validated at construction).
	 */
	private int $maxAttempts;

	/**
	 * Backoff schedule in seconds, indexed by zero-based
	 * just-completed-attempt slot. Element at index `i` is the
	 * number of seconds to wait after attempt `i + 1` failed
	 * before launching attempt `i + 2`.
	 *
	 * Length is always >= max(0, maxAttempts - 1) so every
	 * non-terminal attempt has a backoff value.
	 *
	 * @var list<int>
	 */
	private array $backoffSecondsByAttempt;

	/**
	 * Construct a retry policy with explicit values.
	 *
	 * Validation summary:
	 *
	 *   - `maxAttempts` MUST be >= 1; an unreachable retry policy
	 *     is a programming error.
	 *   - Every entry in `backoffSecondsByAttempt` MUST be a
	 *     non-negative integer; negative waits would invert the
	 *     schedule.
	 *   - The array MUST have length >= max(0, maxAttempts - 1) so
	 *     every non-terminal attempt has a backoff value. The last
	 *     attempt has no further retry, so its slot is allowed to
	 *     be absent.
	 *
	 * @param int       $maxAttempts             Maximum attempt count.
	 * @param list<int> $backoffSecondsByAttempt Backoff schedule.
	 *
	 * @throws InvalidArgumentException When `$maxAttempts` is less
	 *                                  than 1, when any backoff
	 *                                  entry is negative, or when
	 *                                  the schedule is shorter than
	 *                                  the non-terminal attempt
	 *                                  count.
	 */
	public function __construct( int $maxAttempts, array $backoffSecondsByAttempt ) {
		if ( $maxAttempts < 1 ) {
			$message = sprintf(
				'RetryPolicy maxAttempts must be >= 1; got %d.',
				$maxAttempts
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$normalized = array();
		$index      = 0;
		foreach ( $backoffSecondsByAttempt as $seconds ) {
			if ( ! is_int( $seconds ) || $seconds < 0 ) {
				$message = sprintf(
					'RetryPolicy backoff entries must be non-negative integers; got "%s" at index %d.',
					is_scalar( $seconds ) ? (string) $seconds : gettype( $seconds ),
					$index
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			$normalized[] = $seconds;
			++$index;
		}

		$requiredLength = $maxAttempts - 1;

		if ( count( $normalized ) < $requiredLength ) {
			$message = sprintf(
				'RetryPolicy backoff schedule must cover %d non-terminal attempt(s); got %d entr(ies).',
				$requiredLength,
				count( $normalized )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->maxAttempts             = $maxAttempts;
		$this->backoffSecondsByAttempt = $normalized;
	}

	/**
	 * Default retry policy from §12.2 / §12.4 for `sync_product`:
	 * `max_attempts = 5`, backoffs `[300, 900, 3600, 21600]`.
	 */
	public static function default(): self {
		return new self( 5, array( 300, 900, 3600, 21600 ) );
	}

	/**
	 * Canonical retry policy for the supplied job-type token, per
	 * §12.4.
	 *
	 * Mapping:
	 *
	 *   - `sync_product`            → max 5, backoffs [300, 900, 3600, 21600]
	 *   - `refresh_provider_health` → max 3, backoffs [300, 900]
	 *   - `rebuild_render_cache`    → max 2, backoffs [600]
	 *
	 * @param string $jobType One of the {@see self::JOB_*} tokens.
	 *
	 * @throws InvalidArgumentException When `$jobType` is not a
	 *                                  recognized §12.4 token.
	 */
	public static function forJobType( string $jobType ): self {
		switch ( $jobType ) {
			case self::JOB_SYNC_PRODUCT:
				return self::default();
			case self::JOB_REFRESH_PROVIDER_HEALTH:
				return new self( 3, array( 300, 900 ) );
			case self::JOB_REBUILD_RENDER_CACHE:
				return new self( 2, array( 600 ) );
			default:
				$message = sprintf(
					'RetryPolicy::forJobType received unknown job type "%s"; expected one of [%s].',
					$jobType,
					implode(
						', ',
						array(
							self::JOB_SYNC_PRODUCT,
							self::JOB_REFRESH_PROVIDER_HEALTH,
							self::JOB_REBUILD_RENDER_CACHE,
						)
					)
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
		}
	}

	/**
	 * Maximum number of attempts before the job is marked failed.
	 */
	public function maxAttempts(): int {
		return $this->maxAttempts;
	}

	/**
	 * Backoff schedule in seconds, indexed by zero-based
	 * just-completed-attempt slot.
	 *
	 * Exposed for diagnostics surfaces and for callers that need
	 * to render the full schedule (admin diagnostics view, audit
	 * log). Most retry-loop call sites should prefer
	 * {@see self::backoffSeconds()} or
	 * {@see self::withRetryAfter()}.
	 *
	 * @return list<int>
	 */
	public function backoffSchedule(): array {
		return $this->backoffSecondsByAttempt;
	}

	/**
	 * Number of seconds to wait before the next attempt, given
	 * that `$attemptNumber` attempts have just completed.
	 *
	 * `$attemptNumber` is 1-based and represents the count of
	 * attempts already made (so a value of 1 means the first
	 * attempt has just failed and we are deciding when to launch
	 * the second).
	 *
	 * @param int $attemptNumber 1-based count of completed attempts.
	 *
	 * @throws InvalidArgumentException When `$attemptNumber` is
	 *                                  less than 1, or when no
	 *                                  next attempt is allowed
	 *                                  (i.e. `$attemptNumber >=
	 *                                  $this->maxAttempts`).
	 */
	public function backoffSeconds( int $attemptNumber ): int {
		if ( $attemptNumber < 1 ) {
			$message = sprintf(
				'RetryPolicy::backoffSeconds attemptNumber must be >= 1; got %d.',
				$attemptNumber
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! $this->hasNextAttempt( $attemptNumber ) ) {
			$message = sprintf(
				'RetryPolicy::backoffSeconds called after attempt %d but maxAttempts is %d; no next attempt allowed.',
				$attemptNumber,
				$this->maxAttempts
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$index = $attemptNumber - 1;

		// Schedule length is validated at construction to cover
		// every non-terminal attempt, so this lookup is safe.
		return $this->backoffSecondsByAttempt[ $index ];
	}

	/**
	 * Whether a further attempt is allowed after `$attemptNumber`
	 * attempts have already completed.
	 *
	 * @param int $attemptNumber 1-based count of completed attempts.
	 */
	public function hasNextAttempt( int $attemptNumber ): bool {
		return $attemptNumber >= 1 && $attemptNumber < $this->maxAttempts;
	}

	/**
	 * Resolve the wait time before the next attempt, honoring a
	 * provider-supplied `Retry-After` directive per §12.3.
	 *
	 * §12.3 states that the provider's `Retry-After` value takes
	 * priority over the policy's static backoff. When
	 * `$retryAfterSeconds` is positive, this method returns it
	 * verbatim; when `$retryAfterSeconds` is zero or negative
	 * (interpreted as "no directive supplied"), the method falls
	 * back to {@see self::backoffSeconds()} for `$attemptNumber`.
	 *
	 * Note: this is a deliberate "provider wins" interpretation of
	 * §12.3. A provider that returns a very small `Retry-After`
	 * value will therefore shrink the wait below the exponential
	 * backoff. Call sites that need a floor (for example, to avoid
	 * hammering a flaky provider) should clamp the result
	 * themselves.
	 *
	 * @param int $retryAfterSeconds Provider-supplied directive, or
	 *                               <= 0 when no directive was
	 *                               provided.
	 * @param int $attemptNumber     1-based count of completed
	 *                               attempts.
	 *
	 * @throws InvalidArgumentException When `$attemptNumber` is
	 *                                  invalid or no next attempt
	 *                                  is allowed; see
	 *                                  {@see self::backoffSeconds()}.
	 */
	public function withRetryAfter( int $retryAfterSeconds, int $attemptNumber ): int {
		$backoff = $this->backoffSeconds( $attemptNumber );

		if ( $retryAfterSeconds > 0 ) {
			return $retryAfterSeconds;
		}

		return $backoff;
	}
}
