<?php
/**
 * Periodic sync tick result.
 *
 * Phase 2 (post-MVP) Application-layer value object that captures
 * the outcome of one {@see PeriodicSyncScheduler::tick()} invocation.
 * Carried back to the surrounding cron service provider so it can
 * fold per-tick metrics into observability surfaces (logs, audit
 * events, diagnostics counters) without having to reach into
 * scheduler-internal state.
 *
 * Fields:
 *
 *   - `scanned` — total number of records returned by the
 *     {@see \Affilun\Application\Sync\StaleProductFinder::findStale()}
 *     scan for this tick. Bounded by the scheduler's `batchSize`.
 *
 *   - `enqueued` — number of records the scheduler successfully
 *     handed to {@see \Affilun\Application\Sync\SyncDispatcher::enqueueProductSync()}.
 *     Equal to `scanned - skipped` on a healthy tick.
 *
 *   - `skipped` — number of records the scheduler skipped because
 *     the finder returned a row that violated its contract (no
 *     persisted id, wrong type). Skips are not enqueue failures;
 *     they indicate a defensive guard fired rather than a runtime
 *     error.
 *
 *   - `enqueuedProductIds` — the persisted product ids enqueued
 *     during the tick, in scan order. Useful for tests and audit
 *     trails that want to assert on exactly which records were
 *     swept; the surrounding cron service provider can also use
 *     this to seed structured log fields.
 *
 *   - `tickStartedAt` — the UTC instant the scheduler stamped at
 *     the top of the tick. Carried so the cron service provider
 *     can record the observation timestamp on log / audit entries
 *     it derives from the tick result, and so tests can assert on
 *     the exact "now" the scheduler observed.
 *
 * Invariants:
 *
 *   - `scanned >= 0`, `enqueued >= 0`, `skipped >= 0`.
 *   - `enqueued + skipped === scanned`.
 *   - `count(enqueuedProductIds) === enqueued`.
 *   - Every entry in `enqueuedProductIds` is a positive integer.
 *   - `tickStartedAt` is a UTC `\DateTimeImmutable`.
 *
 * The constructor enforces these invariants so a programming error
 * in the scheduler (mis-counted skip, mis-sized enqueued-ids list,
 * non-positive id) surfaces at construction time rather than as a
 * confusing assertion failure further downstream.
 *
 * @package Affilun\Application\Sync
 */

declare(strict_types=1);

namespace Affilun\Application\Sync;

use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Immutable per-tick outcome carried back from
 * {@see PeriodicSyncScheduler::tick()}.
 *
 * See the file-level docblock for the field semantics, the
 * `scanned` / `enqueued` / `skipped` invariants, and the role this
 * value object plays at the scheduler-to-cron observability seam.
 */
final class PeriodicSyncTickResult {

	/**
	 * Total records returned by the stale-product finder for this
	 * tick.
	 */
	private int $scanned;

	/**
	 * Records successfully handed to the dispatcher for enqueue.
	 */
	private int $enqueued;

	/**
	 * Records skipped because the finder violated its contract
	 * (no persisted id, wrong type).
	 */
	private int $skipped;

	/**
	 * Persisted product ids enqueued during the tick, in scan order.
	 *
	 * @var list<int>
	 */
	private array $enqueuedProductIds;

	/**
	 * UTC instant stamped at the top of the tick.
	 */
	private DateTimeImmutable $tickStartedAt;

	/**
	 * Construct the tick result with explicit per-axis values.
	 *
	 * @param int               $scanned            Total records the
	 *                                              finder returned.
	 * @param int               $enqueued           Records actually
	 *                                              enqueued.
	 * @param int               $skipped            Records skipped
	 *                                              defensively.
	 * @param list<int>         $enqueuedProductIds Per-record product
	 *                                              ids in scan order.
	 * @param DateTimeImmutable $tickStartedAt      UTC tick-start
	 *                                              instant.
	 *
	 * @throws InvalidArgumentException When `$scanned`, `$enqueued`,
	 *                                  `$skipped`, or
	 *                                  `count($enqueuedProductIds)`
	 *                                  violate the file-level
	 *                                  invariants, or when
	 *                                  `$enqueuedProductIds`
	 *                                  contains a non-positive
	 *                                  integer.
	 */
	public function __construct(
		int $scanned,
		int $enqueued,
		int $skipped,
		array $enqueuedProductIds,
		DateTimeImmutable $tickStartedAt
	) {
		if ( $scanned < 0 ) {
			$message = sprintf(
				'PeriodicSyncTickResult: scanned must be >= 0, got %d.',
				$scanned
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

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

		if ( $enqueued + $skipped !== $scanned ) {
			$message = sprintf(
				'PeriodicSyncTickResult: enqueued (%d) + skipped (%d) must equal scanned (%d).',
				$enqueued,
				$skipped,
				$scanned
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$idCount = count( $enqueuedProductIds );
		if ( $idCount !== $enqueued ) {
			$message = sprintf(
				'PeriodicSyncTickResult: enqueuedProductIds count (%d) must equal enqueued (%d).',
				$idCount,
				$enqueued
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$normalized = array();
		foreach ( $enqueuedProductIds as $index => $productId ) {
			if ( ! is_int( $productId ) || $productId <= 0 ) {
				$message = sprintf(
					'PeriodicSyncTickResult: enqueuedProductIds[%d] must be a positive integer.',
					(int) $index
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			$normalized[] = $productId;
		}

		$this->scanned            = $scanned;
		$this->enqueued           = $enqueued;
		$this->skipped            = $skipped;
		$this->enqueuedProductIds = $normalized;
		$this->tickStartedAt      = $tickStartedAt->setTimezone( new DateTimeZone( 'UTC' ) );
	}

	/**
	 * Total records returned by the stale-product finder.
	 */
	public function scanned(): int {
		return $this->scanned;
	}

	/**
	 * Records successfully enqueued through the dispatcher.
	 */
	public function enqueued(): int {
		return $this->enqueued;
	}

	/**
	 * Records skipped because the finder violated its contract.
	 */
	public function skipped(): int {
		return $this->skipped;
	}

	/**
	 * Persisted product ids enqueued during the tick, in scan order.
	 *
	 * @return list<int>
	 */
	public function enqueuedProductIds(): array {
		return $this->enqueuedProductIds;
	}

	/**
	 * UTC instant stamped at the top of the tick.
	 */
	public function tickStartedAt(): DateTimeImmutable {
		return $this->tickStartedAt;
	}

	/**
	 * Whether the tick observed an empty stale cohort.
	 *
	 * Convenience predicate so the surrounding cron service provider
	 * can elide "periodic sweep observed nothing" log lines on idle
	 * ticks without having to inspect {@see self::scanned()} directly.
	 */
	public function isIdle(): bool {
		return $this->scanned === 0;
	}
}
