<?php
/**
 * Periodic sync scheduler.
 *
 * Phase 2 (post-MVP) Application-layer collaborator that owns the
 * §8.3 "定期同期" sweep: every tick, find the stale / expired
 * products under the cap, and enqueue a `sync_product` job for
 * each one through {@see SyncDispatcher} at the priority the
 * {@see SyncPolicy} attributes to the record.
 *
 * Responsibilities (per `specifications/sync-and-cache-strategy.md`
 * §8.3 / §13 and ADR-0021 §5):
 *
 *   1. Stamp a single UTC "now" instant from the injected
 *      {@see Clock} at the top of the tick. Carried back on the
 *      result so the surrounding cron service provider can record
 *      "what time did this sweep observe the queue?" without
 *      re-reading the wall clock.
 *
 *   2. Ask {@see StaleProductFinder::findStale()} for at most
 *      {@see self::$batchSize} records. The finder already orders
 *      by §13.2 priority desc / `lastSyncedAt` asc so the most
 *      urgent records appear first; the scheduler never re-orders
 *      the slice.
 *
 *   3. For each returned record, look up the §13.2 priority via
 *      {@see SyncPolicy::priorityFor()} and enqueue a
 *      `sync_product` job through {@see SyncDispatcher::enqueueProductSync()}.
 *      A fresh correlation id is generated per enqueue (the
 *      dispatcher's default), so each periodic enqueue is
 *      observable as its own series in logs / audit events.
 *
 *   4. Records that have no persisted id are skipped defensively —
 *      the finder is contract-bound to return persisted records,
 *      but a malformed in-memory test double could violate that
 *      invariant and we would rather skip than dereference null.
 *
 * Scope boundaries:
 *
 *   - Duplicate enqueue suppression lives in {@see SyncDispatcher}.
 *     When a pending / running `sync_product` row already exists for
 *     a product, enqueue returns that open job instead of stacking
 *     another row for the same work item.
 *
 *   - The scheduler does NOT log, emit audit events, or write
 *     diagnostics. Those responsibilities belong to the surrounding
 *     cron service provider so the scheduler stays framework-free
 *     and trivially unit-testable. Callers that want observability
 *     inspect the returned {@see PeriodicSyncTickResult}.
 *
 *   - The scheduler does NOT reclaim broken records. {@see SyncPolicy}
 *     classifies broken rows as {@see SyncPriority::urgent()} and
 *     {@see StaleProductFinder::findBroken()} surfaces them as a
 *     dedicated cohort, but those are routed through a separate
 *     code path (admin / WP-CLI) rather than the periodic sweep
 *     because broken-recovery semantics belong to a manual review
 *     loop in the MVP.
 *
 * @package Affilun\Application\Sync
 */

declare(strict_types=1);

namespace Affilun\Application\Sync;

use Affilun\Domain\Clock;
use Affilun\Infrastructure\Persistence\ProductRecord;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Single-tick periodic sync sweep driver.
 *
 * See the file-level docblock for the per-tick find → enqueue loop,
 * the policy-derived priority routing, and dispatcher-owned
 * duplicate-enqueue suppression.
 */
final class PeriodicSyncScheduler {

	/**
	 * Default per-tick enqueue cap per `specifications/sync-and-cache-strategy.md`
	 * §11.4 "10〜50 件程度" guidance, biased toward the middle of the
	 * range so a single sweep can drain a small backlog without
	 * starving the worker tick that follows it.
	 */
	public const DEFAULT_BATCH_SIZE = 20;

	/**
	 * Stale-product finder this sweep consults.
	 */
	private StaleProductFinder $finder;

	/**
	 * Sync policy used to derive the §13.2 priority for each enqueue.
	 */
	private SyncPolicy $policy;

	/**
	 * Dispatcher that owns the §10.2 enqueue.
	 */
	private SyncDispatcher $dispatcher;

	/**
	 * Clock port that produces the per-tick "now" instant.
	 */
	private Clock $clock;

	/**
	 * Maximum records to enqueue per {@see self::tick()} invocation.
	 */
	private int $batchSize;

	/**
	 * Construct the scheduler with explicit collaborators.
	 *
	 * @param StaleProductFinder $finder     Source of stale records.
	 * @param SyncPolicy         $policy     Priority classifier.
	 * @param SyncDispatcher     $dispatcher Job enqueue port.
	 * @param Clock              $clock      Clock port.
	 * @param int                $batchSize  Per-tick enqueue cap
	 *                                       (must be > 0).
	 *
	 * @throws InvalidArgumentException When `$batchSize <= 0`.
	 */
	public function __construct(
		StaleProductFinder $finder,
		SyncPolicy $policy,
		SyncDispatcher $dispatcher,
		Clock $clock,
		int $batchSize = self::DEFAULT_BATCH_SIZE
	) {
		if ( $batchSize <= 0 ) {
			$message = sprintf(
				'PeriodicSyncScheduler requires batchSize > 0, got %d.',
				$batchSize
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->finder     = $finder;
		$this->policy     = $policy;
		$this->dispatcher = $dispatcher;
		$this->clock      = $clock;
		$this->batchSize  = $batchSize;
	}

	/**
	 * Per-tick enqueue cap.
	 */
	public function batchSize(): int {
		return $this->batchSize;
	}

	/**
	 * Sweep the stale cohort and enqueue one `sync_product` job per
	 * record, returning the resulting tick summary.
	 *
	 * Stamps a single UTC "now" instant at the top of the tick, asks
	 * the finder for at most {@see self::$batchSize} records, and
	 * routes each record through the dispatcher at the priority the
	 * policy attributes to it. Records without a persisted id are
	 * skipped defensively (contract violation by the finder); they
	 * are counted on the result so the surrounding scheduler can
	 * surface the anomaly.
	 */
	public function tick(): PeriodicSyncTickResult {
		$now = $this->clock->now()->setTimezone( new DateTimeZone( 'UTC' ) );

		$records = $this->finder->findStale( $this->batchSize );

		$enqueued    = 0;
		$skipped     = 0;
		$enqueuedIds = array();

		foreach ( $records as $record ) {
			if ( ! $record instanceof ProductRecord ) {
				++$skipped;
				continue;
			}

			$id = $record->id();
			if ( $id === null || $id <= 0 ) {
				++$skipped;
				continue;
			}

			$priority = $this->policy->priorityFor( $record );

			$this->dispatcher->enqueueProductSync( $id, $priority );

			++$enqueued;
			$enqueuedIds[] = $id;
		}

		return new PeriodicSyncTickResult(
			count( $records ),
			$enqueued,
			$skipped,
			$enqueuedIds,
			$now
		);
	}
}
