<?php
/**
 * Sync worker tick result.
 *
 * Phase 2 (post-MVP) Application-layer value object that captures
 * the outcome of one {@see SyncWorker::tick()} invocation. Carried
 * back to the surrounding scheduler (WP-Cron, CLI, integration
 * test) so it can fold per-tick metrics into observability surfaces
 * (logs, audit events, diagnostics counters) without having to
 * reach into worker-internal state.
 *
 * Fields:
 *
 *   - `claimed` — total number of jobs that were successfully
 *     transitioned to `running` by the tick (i.e. the
 *     {@see \Affilun\Infrastructure\Persistence\SyncJobRepository::claimNext()}
 *     call returned a non-null record). Includes both jobs the
 *     handler ran and jobs the worker skipped because their
 *     `job_type` is not registered yet.
 *
 *   - `skipped` — subset of `claimed` representing jobs whose
 *     `job_type` does not match a registered handler in this
 *     slice. These jobs remain in `running` state and will be
 *     reclaimed by the §8.5 lock-timeout sweep on a future tick
 *     once a handler that knows about the type is registered.
 *
 *   - `outcomes` — list of
 *     {@see \Affilun\Application\Sync\SyncJobOutcome}
 *     values produced by the handler, in the order the worker
 *     processed them. The size of this list equals
 *     `claimed - skipped`.
 *
 *   - `tickStartedAt` — the UTC instant the worker stamped at the
 *     top of the tick. Carried so the scheduler 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 worker forwarded into
 *     {@see \Affilun\Infrastructure\Persistence\SyncJobRepository::claimNext()}.
 *
 * Invariants:
 *
 *   - `claimed >= 0`, `skipped >= 0`, `skipped <= claimed`.
 *   - `count(outcomes) === claimed - skipped`.
 *   - `tickStartedAt` is a UTC `\DateTimeImmutable`.
 *
 * The constructor enforces the numeric invariants so a programming
 * error in the worker (mis-counted skip, mis-sized outcome list)
 * 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 SyncWorker::tick()}.
 *
 * See the file-level docblock for the field semantics, the
 * `claimed` / `skipped` / `outcomes` invariants, and the role this
 * value object plays at the worker-to-scheduler observability seam.
 */
final class SyncWorkerTickResult {

	/**
	 * Total jobs transitioned to `running` during the tick.
	 */
	private int $claimed;

	/**
	 * Subset of `claimed` whose `job_type` had no registered handler.
	 */
	private int $skipped;

	/**
	 * Per-job handler outcomes, in processing order.
	 *
	 * @var list<SyncJobOutcome>
	 */
	private array $outcomes;

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

	/**
	 * Construct the tick result with explicit per-axis values.
	 *
	 * @param int                  $claimed       Total jobs claimed.
	 * @param int                  $skipped       Subset of `$claimed`
	 *                                            whose job_type had
	 *                                            no registered handler.
	 * @param list<SyncJobOutcome> $outcomes     Per-handled-job outcomes.
	 * @param DateTimeImmutable    $tickStartedAt UTC tick-start instant.
	 *
	 * @throws InvalidArgumentException When `$claimed`, `$skipped`,
	 *                                  or `count($outcomes)` violate
	 *                                  the file-level invariants, or
	 *                                  when `$outcomes` contains a
	 *                                  non-{@see SyncJobOutcome}
	 *                                  entry.
	 */
	public function __construct(
		int $claimed,
		int $skipped,
		array $outcomes,
		DateTimeImmutable $tickStartedAt
	) {
		if ( $claimed < 0 ) {
			$message = sprintf(
				'SyncWorkerTickResult: claimed must be >= 0, got %d.',
				$claimed
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

		$expectedOutcomes = $claimed - $skipped;
		$actualOutcomes   = count( $outcomes );
		if ( $actualOutcomes !== $expectedOutcomes ) {
			$message = sprintf(
				'SyncWorkerTickResult: outcomes count (%d) must equal claimed - skipped (%d).',
				$actualOutcomes,
				$expectedOutcomes
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$normalized = array();
		foreach ( $outcomes as $index => $outcome ) {
			if ( ! $outcome instanceof SyncJobOutcome ) {
				$message = sprintf(
					'SyncWorkerTickResult: outcomes[%d] is not a SyncJobOutcome.',
					(int) $index
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			$normalized[] = $outcome;
		}

		$this->claimed       = $claimed;
		$this->skipped       = $skipped;
		$this->outcomes      = $normalized;
		$this->tickStartedAt = $tickStartedAt->setTimezone( new DateTimeZone( 'UTC' ) );
	}

	/**
	 * Total jobs transitioned to `running` during the tick.
	 */
	public function claimed(): int {
		return $this->claimed;
	}

	/**
	 * Subset of `claimed` whose `job_type` had no registered handler.
	 */
	public function skipped(): int {
		return $this->skipped;
	}

	/**
	 * Number of jobs the handler actually processed
	 * (`claimed - skipped`).
	 */
	public function handled(): int {
		return $this->claimed - $this->skipped;
	}

	/**
	 * Per-job handler outcomes, in processing order.
	 *
	 * @return list<SyncJobOutcome>
	 */
	public function outcomes(): array {
		return $this->outcomes;
	}

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

	/**
	 * Whether the tick performed no work (no jobs were claimed).
	 *
	 * Convenience predicate so the surrounding scheduler can elide
	 * "worker tick observed nothing" log lines on idle ticks without
	 * having to inspect {@see self::claimed()} directly.
	 */
	public function isIdle(): bool {
		return $this->claimed === 0;
	}

	/**
	 * Number of handler outcomes that reported {@see SyncJobOutcome::isSuccess()}.
	 */
	public function successCount(): int {
		$count = 0;
		foreach ( $this->outcomes as $outcome ) {
			if ( $outcome->isSuccess() ) {
				++$count;
			}
		}
		return $count;
	}

	/**
	 * Number of handler outcomes that reported {@see SyncJobOutcome::isRetry()}.
	 */
	public function retryCount(): int {
		$count = 0;
		foreach ( $this->outcomes as $outcome ) {
			if ( $outcome->isRetry() ) {
				++$count;
			}
		}
		return $count;
	}

	/**
	 * Number of handler outcomes that reported {@see SyncJobOutcome::isTerminal()}.
	 */
	public function terminalFailureCount(): int {
		$count = 0;
		foreach ( $this->outcomes as $outcome ) {
			if ( $outcome->isTerminal() ) {
				++$count;
			}
		}
		return $count;
	}
}
