<?php
/**
 * Sync-job outcome value object.
 *
 * Immutable value object that summarizes the result of a single
 * {@see \Affilun\Application\Sync\SyncProductJobHandler::handle()}
 * invocation. The handler returns one of three outcomes —
 * {@see self::OUTCOME_SUCCESS},
 * {@see self::OUTCOME_RETRY_SCHEDULED}, or
 * {@see self::OUTCOME_FAILED_TERMINAL} — and the worker layer
 * branches on the outcome to decide whether to advance the queue,
 * leave the job pending for a future tick, or surface the terminal
 * failure to the admin diagnostics view.
 *
 * Field semantics:
 *
 *   - `outcome` is the discriminator. The closed set is enforced
 *     by the constructor; an unknown value raises
 *     {@see \InvalidArgumentException} so a programming error
 *     surfaces before the value escapes the handler.
 *
 *   - `errorCode` is the stable machine-readable code per
 *     `specifications/sync-and-cache-strategy.md` §10.2 / §11.
 *     Required for non-success outcomes; null on success.
 *
 *   - `errorMessage` is the human-readable summary suitable for
 *     stamping the matching `affilun_sync_jobs.last_error` column.
 *     Required for non-success outcomes; null on success.
 *
 *   - `attempts` is the post-handler attempt count (i.e. the
 *     number of attempts already made INCLUDING the one that just
 *     ran). Always non-negative.
 *
 *   - `nextAvailableAt` is the UTC instant at which the worker may
 *     re-claim the job for the next attempt. Required when
 *     `outcome === OUTCOME_RETRY_SCHEDULED`; null otherwise.
 *
 * Instances are immutable. Construction goes through the named
 * static factories ({@see self::success()},
 * {@see self::retryScheduled()}, {@see self::failedTerminal()}) so
 * callers cannot accidentally produce an inconsistent shape (for
 * example, a "success" outcome carrying an error code).
 *
 * @package Affilun\Application\Sync
 */

declare(strict_types=1);

namespace Affilun\Application\Sync;

use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Immutable summary of a single sync-job handler invocation.
 *
 * See the file-level docblock for the closed outcome set, the
 * required-field rules tied to each outcome, and the rationale for
 * routing construction through named static factories rather than
 * a public constructor.
 */
final class SyncJobOutcome {

	/**
	 * Outcome token for "the job ran to completion and the
	 * snapshot was persisted".
	 */
	public const OUTCOME_SUCCESS = 'success';

	/**
	 * Outcome token for "the job failed transiently and is
	 * scheduled to retry at {@see self::nextAvailableAt()}".
	 */
	public const OUTCOME_RETRY_SCHEDULED = 'retry_scheduled';

	/**
	 * Outcome token for "the job failed in a way that exhausts
	 * the retry budget or is non-retryable by contract".
	 */
	public const OUTCOME_FAILED_TERMINAL = 'failed_terminal';

	/**
	 * Closed set of accepted outcome tokens.
	 *
	 * @var list<string>
	 */
	private const VALID_OUTCOMES = array(
		self::OUTCOME_SUCCESS,
		self::OUTCOME_RETRY_SCHEDULED,
		self::OUTCOME_FAILED_TERMINAL,
	);

	/**
	 * Outcome discriminator.
	 */
	private string $outcome;

	/**
	 * Stable machine-readable error code, or null on success.
	 */
	private ?string $errorCode;

	/**
	 * Human-readable error summary, or null on success.
	 */
	private ?string $errorMessage;

	/**
	 * Post-handler attempt count.
	 */
	private int $attempts;

	/**
	 * UTC instant at which the worker may re-claim the job, or
	 * null when no retry is scheduled.
	 */
	private ?DateTimeImmutable $nextAvailableAt;

	/**
	 * Construct an outcome with explicit values for every field.
	 *
	 * Validation summary:
	 *
	 *   - `$outcome` MUST be a member of {@see self::VALID_OUTCOMES}.
	 *   - `$errorCode` and `$errorMessage` MUST both be non-null
	 *     non-empty strings when `$outcome` is not
	 *     {@see self::OUTCOME_SUCCESS}, and MUST both be null when
	 *     `$outcome` is {@see self::OUTCOME_SUCCESS}.
	 *   - `$attempts` MUST be non-negative.
	 *   - `$nextAvailableAt` MUST be non-null when `$outcome` is
	 *     {@see self::OUTCOME_RETRY_SCHEDULED}, and MUST be null
	 *     otherwise.
	 *
	 * @param string                 $outcome         Outcome discriminator.
	 * @param string|null            $errorCode       Stable error code, or null.
	 * @param string|null            $errorMessage    Human-readable summary, or null.
	 * @param int                    $attempts        Post-handler attempt count.
	 * @param DateTimeImmutable|null $nextAvailableAt Retry instant, or null.
	 *
	 * @throws InvalidArgumentException When any of the validation
	 *                                  rules above is violated.
	 */
	public function __construct(
		string $outcome,
		?string $errorCode,
		?string $errorMessage,
		int $attempts,
		?DateTimeImmutable $nextAvailableAt
	) {
		if ( ! in_array( $outcome, self::VALID_OUTCOMES, true ) ) {
			$message = sprintf(
				'SyncJobOutcome: outcome must be one of [%s]; got "%s".',
				implode( ', ', self::VALID_OUTCOMES ),
				$outcome
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $attempts < 0 ) {
			$message = sprintf(
				'SyncJobOutcome: attempts must be >= 0; got %d.',
				$attempts
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $outcome === self::OUTCOME_SUCCESS ) {
			if ( $errorCode !== null || $errorMessage !== null ) {
				$message = 'SyncJobOutcome: success outcomes must carry null errorCode and errorMessage.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( $nextAvailableAt !== null ) {
				$message = 'SyncJobOutcome: success outcomes must carry null nextAvailableAt.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
		} else {
			if ( $errorCode === null || $errorCode === '' ) {
				$message = 'SyncJobOutcome: non-success outcomes require a non-empty errorCode.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( $errorMessage === null || $errorMessage === '' ) {
				$message = 'SyncJobOutcome: non-success outcomes require a non-empty errorMessage.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
		}

		if ( $outcome === self::OUTCOME_RETRY_SCHEDULED ) {
			if ( $nextAvailableAt === null ) {
				$message = 'SyncJobOutcome: retry_scheduled outcomes require a non-null nextAvailableAt.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
		} elseif ( $outcome === self::OUTCOME_FAILED_TERMINAL && $nextAvailableAt !== null ) {
			$message = 'SyncJobOutcome: failed_terminal outcomes must carry null nextAvailableAt.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->outcome         = $outcome;
		$this->errorCode       = $errorCode;
		$this->errorMessage    = $errorMessage;
		$this->attempts        = $attempts;
		$this->nextAvailableAt = self::normalizeToUtc( $nextAvailableAt );
	}

	/**
	 * Construct a success outcome.
	 *
	 * @param int $attempts Post-handler attempt count.
	 */
	public static function success( int $attempts ): self {
		return new self( self::OUTCOME_SUCCESS, null, null, $attempts, null );
	}

	/**
	 * Construct a retry-scheduled outcome.
	 *
	 * @param int               $attempts        Post-handler attempt count.
	 * @param string            $errorCode       Stable error code.
	 * @param string            $errorMessage    Human-readable summary.
	 * @param DateTimeImmutable $nextAvailableAt UTC instant for the next attempt.
	 */
	public static function retryScheduled(
		int $attempts,
		string $errorCode,
		string $errorMessage,
		DateTimeImmutable $nextAvailableAt
	): self {
		return new self(
			self::OUTCOME_RETRY_SCHEDULED,
			$errorCode,
			$errorMessage,
			$attempts,
			$nextAvailableAt
		);
	}

	/**
	 * Construct a failed-terminal outcome.
	 *
	 * @param int    $attempts     Post-handler attempt count.
	 * @param string $errorCode    Stable error code.
	 * @param string $errorMessage Human-readable summary.
	 */
	public static function failedTerminal(
		int $attempts,
		string $errorCode,
		string $errorMessage
	): self {
		return new self(
			self::OUTCOME_FAILED_TERMINAL,
			$errorCode,
			$errorMessage,
			$attempts,
			null
		);
	}

	/**
	 * Outcome discriminator token.
	 */
	public function outcome(): string {
		return $this->outcome;
	}

	/**
	 * Stable machine-readable error code, or null on success.
	 */
	public function errorCode(): ?string {
		return $this->errorCode;
	}

	/**
	 * Human-readable error summary, or null on success.
	 */
	public function errorMessage(): ?string {
		return $this->errorMessage;
	}

	/**
	 * Post-handler attempt count.
	 */
	public function attempts(): int {
		return $this->attempts;
	}

	/**
	 * UTC instant at which the worker may re-claim the job, or
	 * null when no retry is scheduled.
	 */
	public function nextAvailableAt(): ?DateTimeImmutable {
		return $this->nextAvailableAt;
	}

	/**
	 * Whether this outcome represents a successful sync.
	 */
	public function isSuccess(): bool {
		return $this->outcome === self::OUTCOME_SUCCESS;
	}

	/**
	 * Whether this outcome represents a transient failure with a
	 * scheduled retry.
	 */
	public function isRetry(): bool {
		return $this->outcome === self::OUTCOME_RETRY_SCHEDULED;
	}

	/**
	 * Whether this outcome represents a non-retryable terminal
	 * failure.
	 */
	public function isTerminal(): bool {
		return $this->outcome === self::OUTCOME_FAILED_TERMINAL;
	}

	/**
	 * Normalize the given timestamp to UTC, or return null when
	 * the input is null.
	 *
	 * @param DateTimeImmutable|null $value Input timestamp.
	 */
	private static function normalizeToUtc( ?DateTimeImmutable $value ): ?DateTimeImmutable {
		if ( $value === null ) {
			return null;
		}

		return $value->setTimezone( new DateTimeZone( 'UTC' ) );
	}
}
