<?php
/**
 * Use case: enqueue a single-product sync job for the §11.2 REST
 * surface.
 *
 * Phase 9 slice 9g Application-layer use case that wraps
 * {@see SyncDispatcher::enqueueProductSync()} with the small
 * lookup + priority-decision logic the
 * `POST /affilun/v1/sync/products/{id}` controller needs:
 *
 *   1. Validate `$productId > 0` (defense in depth: the controller
 *      already validates the route parameter, but the use case is
 *      a callable seam that admin / CLI surfaces will reuse).
 *
 *   2. Look up the {@see ProductRecord} via
 *      {@see ProductRepository::findById()}; missing rows raise
 *      {@see ProductNotFoundException} so the controller can map
 *      to the §15.2 `affilun_product_not_found` 404 envelope without
 *      having to know the persistence shape.
 *
 *   3. Decide the §13.2 {@see SyncPriority} from the record's
 *      `syncState` axis and `lastSyncedAt` timestamp using the
 *      heuristic documented on
 *      {@see self::priorityFor()}.
 *
 *   4. Delegate the actual enqueue to {@see SyncDispatcher}, which
 *      owns the §10.2 payload shape (`product_id`,
 *      `correlation_id`, `enqueued_at`), the §12.4 retry-policy
 *      stamp, and the {@see SyncJobRecord} persistence.
 *
 *   5. Return an {@see EnqueueProductSyncResult} carrying both
 *      the persisted job and the resolved correlation id, so the
 *      controller can render the §11.2 success envelope without
 *      round-tripping through {@see SyncDispatcher::payloadFor()}.
 *
 * Priority heuristic (documented on {@see self::priorityFor()}):
 *
 *   - `syncState === 'broken'` → {@see SyncPriority::urgent()}.
 *     Structural failure on the record needs the recovery path
 *     ahead of routine work, matching §13.2's "broken → urgent"
 *     mapping.
 *
 *   - `lastSyncedAt === null` (never successfully synced) →
 *     {@see SyncPriority::high()}. A never-synced row produces no
 *     known-good snapshot for any consumer, so an explicit
 *     admin-initiated resync should outrank routine `normal`
 *     traffic without quite reaching `urgent`.
 *
 *   - `syncState === 'stale'` or `syncState === 'expired'` →
 *     {@see SyncPriority::high()}. The admin surface already
 *     classified the record as past its freshness threshold; an
 *     explicit user-initiated resync is the strongest signal we
 *     have that the row needs to be refreshed promptly.
 *
 *   - Anything else (typically `'fresh'`) →
 *     {@see SyncPriority::normal()}. The admin still asked for a
 *     resync, so the work runs at the default queue priority
 *     rather than being demoted; demoting fresh-but-requested
 *     resyncs would surprise admins debugging display issues.
 *
 * The heuristic deliberately does NOT consult {@see SyncPolicy}
 * (which would require a {@see \Affilun\Domain\Clock}
 * and threshold configuration). The §11.2 endpoint is invoked by
 * humans who already saw the admin-side state column; trusting
 * the persisted `syncState` value keeps this use case stateless
 * and side-effect-free outside the dispatcher call.
 *
 * @package Affilun\Application\Sync\UseCase
 */

declare(strict_types=1);

namespace Affilun\Application\Sync\UseCase;

use Affilun\Application\Product\Exception\ProductNotFoundException;
use Affilun\Application\Sync\SyncDispatcher;
use Affilun\Domain\Sync\SyncPriority;
use Affilun\Domain\Sync\SyncState;
use Affilun\Infrastructure\Persistence\ProductRecord;
use Affilun\Infrastructure\Persistence\ProductRepository;
use InvalidArgumentException;

/**
 * Application-layer use case that drives the §11.2
 * `POST /affilun/v1/sync/products/{id}` endpoint.
 *
 * See the file-level docblock for the validation order, the
 * priority-decision heuristic, and the rationale for not
 * consulting {@see \Affilun\Application\Sync\SyncPolicy}
 * here.
 */
final class EnqueueProductSyncUseCase {

	/**
	 * Dispatcher that owns the §10.2 enqueue payload shape and the
	 * §12.4 retry-policy stamp.
	 */
	private SyncDispatcher $dispatcher;

	/**
	 * Persistence port used to confirm the product exists before
	 * the dispatcher writes the queue row.
	 */
	private ProductRepository $products;

	/**
	 * Construct the use case with explicit dependencies.
	 *
	 * @param SyncDispatcher    $dispatcher Dispatcher owning the
	 *                                      §10.2 enqueue.
	 * @param ProductRepository $products   Repository owning the
	 *                                      `affilun_products` lookup.
	 */
	public function __construct(
		SyncDispatcher $dispatcher,
		ProductRepository $products
	) {
		$this->dispatcher = $dispatcher;
		$this->products   = $products;
	}

	/**
	 * Enqueue a sync-product job for `$productId`.
	 *
	 * Validation / lookup / priority / enqueue order is documented
	 * on the file-level docblock.
	 *
	 * @param int         $productId     Local `affilun_products.id` to
	 *                                   refresh; MUST be > 0.
	 * @param string|null $correlationId Caller-supplied UUID v4 or
	 *                                   opaque token, or null to
	 *                                   let the dispatcher generate
	 *                                   a fresh UUID v4.
	 *
	 * @throws InvalidArgumentException  When `$productId` is not a
	 *                                   positive integer.
	 * @throws ProductNotFoundException  When no `affilun_products` row
	 *                                   matches the supplied id.
	 */
	public function execute(
		int $productId,
		?string $correlationId = null
	): EnqueueProductSyncResult {
		if ( $productId <= 0 ) {
			$message = sprintf(
				'EnqueueProductSyncUseCase requires a positive productId; got %d.',
				$productId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$record = $this->products->findById( $productId );
		if ( $record === null ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing exception, never rendered to HTML.
			throw ProductNotFoundException::forId( $productId );
		}

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

		$job = $this->dispatcher->enqueueProductSync(
			$productId,
			$priority,
			$correlationId
		);

		// The dispatcher stamps the resolved correlation id onto the
		// payload; decoding here keeps the result VO authoritative
		// regardless of whether the caller supplied one.
		$payload        = SyncDispatcher::payloadFor( $job );
		$resolvedCorrId = is_string( $payload['correlation_id'] ?? null )
			? (string) $payload['correlation_id']
			: ( $correlationId ?? '' );

		return new EnqueueProductSyncResult( $job, $resolvedCorrId );
	}

	/**
	 * Decide the §13.2 priority for a resync of `$record`.
	 *
	 * Heuristic (see file-level docblock for the rationale):
	 *
	 *   - `syncState === 'broken'` → urgent.
	 *   - `lastSyncedAt === null`  → high.
	 *   - `syncState === 'stale'`  → high.
	 *   - `syncState === 'expired'` → high.
	 *   - Otherwise                → normal.
	 *
	 * @param ProductRecord $record Record being resynced.
	 */
	private function priorityFor( ProductRecord $record ): SyncPriority {
		$syncState = $record->syncState();

		if ( $syncState === SyncState::BROKEN ) {
			return SyncPriority::urgent();
		}

		if ( $record->lastSyncedAt() === null ) {
			return SyncPriority::high();
		}

		if ( $syncState === SyncState::STALE || $syncState === SyncState::EXPIRED ) {
			return SyncPriority::high();
		}

		return SyncPriority::normal();
	}
}
