<?php
/**
 * Sync dispatcher.
 *
 * Phase 9 Application-layer collaborator that enqueues `sync_product`
 * jobs into the `affilun_sync_jobs` table per
 * `specifications/sync-and-cache-strategy.md` §10.2 / §11. The
 * dispatcher is the single entry point for "schedule a product
 * resync" — REST controllers, admin actions, and the policy-driven
 * stale sweep all funnel through {@see self::enqueueProductSync()}
 * so the queue carries a consistent payload shape and a consistent
 * retry budget regardless of which surface produced the request.
 *
 * Payload shape (per §10.2):
 *
 *   - `product_id` — the local `affilun_products.id` of the row to
 *     refresh. The handler resolves the provider key and the
 *     provider product id from the matching record at handle time,
 *     so the payload itself stays narrow.
 *
 *   - `correlation_id` — an opaque UUID v4 that downstream
 *     observability surfaces (logs, audit trail, REST envelopes)
 *     can use to correlate the enqueue with the eventual handle
 *     outcome. The dispatcher generates one when the caller does
 *     not supply one so the field is never empty.
 *
 *   - `enqueued_at` — the UTC ISO-8601 timestamp at which the
 *     dispatcher accepted the request. Held inside the payload (in
 *     addition to `affilun_sync_jobs.created_at`) so admin diagnostics
 *     surfaces can show the originating timestamp without joining
 *     against the column.
 *
 * Retry policy:
 *
 *   - The dispatcher uses the §12.4 default policy for the
 *     `sync_product` job type
 *     ({@see RetryPolicy::forJobType('sync_product')}). The policy's
 *     `maxAttempts` value is stamped onto the inserted row so the
 *     worker / handler can branch on the budget without re-deriving
 *     it from configuration on every retry decision.
 *
 * Priority:
 *
 *   - The caller supplies the {@see SyncPriority} explicitly so the
 *     dispatcher does not have to consult the §13.2 priority policy
 *     itself. Admin-initiated resyncs typically pass
 *     {@see SyncPriority::urgent()}; policy-driven sweeps pass
 *     whatever priority the {@see SyncPolicy} returned for the
 *     candidate record.
 *
 * @package Affilun\Application\Sync
 */

declare(strict_types=1);

namespace Affilun\Application\Sync;

use Affilun\Domain\Clock;
use Affilun\Domain\Sync\RetryPolicy;
use Affilun\Domain\Sync\SyncPriority;
use Affilun\Infrastructure\Persistence\SyncJobRecord;
use Affilun\Infrastructure\Persistence\SyncJobRepository;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;
use RuntimeException;

/**
 * Phase 9 collaborator that enqueues `sync_product` jobs into the
 * `affilun_sync_jobs` queue.
 *
 * See the file-level docblock for the payload shape, the §12.4
 * retry-policy binding, and the priority-supplied-by-caller
 * convention.
 */
final class SyncDispatcher {

	/**
	 * Persistence port that backs the §10.2 enqueue.
	 */
	private SyncJobRepository $jobs;

	/**
	 * Clock port used to stamp the §10.2 `enqueued_at` payload
	 * field and the persisted `availableAt` / `createdAt`
	 * timestamps.
	 */
	private Clock $clock;

	/**
	 * §12.4 default retry policy for the `sync_product` job type.
	 */
	private RetryPolicy $defaultPolicy;

	/**
	 * Construct the dispatcher with explicit collaborators.
	 *
	 * @param SyncJobRepository $jobs          Persistence port.
	 * @param Clock             $clock         Clock port.
	 * @param RetryPolicy       $defaultPolicy §12.4 policy for
	 *                                         `sync_product`.
	 */
	public function __construct(
		SyncJobRepository $jobs,
		Clock $clock,
		RetryPolicy $defaultPolicy
	) {
		$this->jobs          = $jobs;
		$this->clock         = $clock;
		$this->defaultPolicy = $defaultPolicy;
	}

	/**
	 * Enqueue a `sync_product` job for the supplied product id.
	 *
	 * Validates `$productId > 0`, builds the §10.2 payload (with a
	 * caller-supplied or auto-generated correlation id and the
	 * current UTC timestamp as `enqueued_at`), and persists a fresh
	 * `pending` row through the repository. The returned record
	 * carries the assigned auto-increment id so callers can stamp
	 * audit logs / REST responses without a follow-up lookup.
	 *
	 * @param int          $productId     Local `affilun_products.id` to
	 *                                    refresh.
	 * @param SyncPriority $priority      §13.2 priority for this
	 *                                    enqueue.
	 * @param string|null  $correlationId Caller-supplied UUID v4,
	 *                                    or null to auto-generate.
	 *
	 * @throws InvalidArgumentException When `$productId` is not a
	 *                                  positive integer.
	 */
	public function enqueueProductSync(
		int $productId,
		SyncPriority $priority,
		?string $correlationId = null
	): SyncJobRecord {
		if ( $productId <= 0 ) {
			$message = sprintf(
				'SyncDispatcher::enqueueProductSync requires a positive productId; got %d.',
				$productId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$existing = $this->jobs->findOpenProductSync( $productId );
		if ( $existing !== null ) {
			return $existing;
		}

		$now = $this->clock->now()->setTimezone( new DateTimeZone( 'UTC' ) );

		$payload = array(
			'product_id'     => $productId,
			'correlation_id' => $correlationId ?? self::generateCorrelationId(),
			'enqueued_at'    => $now->format( DateTimeImmutable::ATOM ),
		);

		$payloadJson = self::encodePayload( $payload );

		$record = SyncJobRecord::createNew(
			'sync_product',
			$payloadJson,
			$now,
			array(
				'priority'    => $priority->weight(),
				'maxAttempts' => $this->defaultPolicy->maxAttempts(),
			)
		);

		return $this->jobs->save( $record );
	}

	/**
	 * Decode the payload of the supplied {@see SyncJobRecord} into
	 * an associative array.
	 *
	 * Static helper so REST controllers and worker layers can share
	 * one canonical decode path. The returned array is the verbatim
	 * `json_decode(..., true)` result; callers are responsible for
	 * the per-field validation rules they need.
	 *
	 * @param SyncJobRecord $record Record whose payload to decode.
	 *
	 * @return array<string, mixed>
	 *
	 * @throws RuntimeException When the payload is not valid JSON
	 *                          or does not decode to an associative
	 *                          array.
	 */
	public static function payloadFor( SyncJobRecord $record ): array {
		$decoded = json_decode( $record->payloadJson(), true );

		if ( ! is_array( $decoded ) ) {
			$message = sprintf(
				'SyncDispatcher::payloadFor: payload is not a valid JSON object (record id %s).',
				$record->id() === null ? 'null' : (string) $record->id()
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException( $message );
		}

		/** @var array<string, mixed> $decoded */
		return $decoded;
	}

	/**
	 * Generate a fresh UUID v4 for the `correlation_id` payload
	 * field.
	 *
	 * Prefers `wp_generate_uuid4()` when available so the
	 * dispatcher follows the WordPress convention; falls back to a
	 * pure-PHP `random_bytes(16)` formatter otherwise so unit tests
	 * (which run without the WordPress runtime loaded) can still
	 * exercise this path.
	 */
	private static function generateCorrelationId(): string {
		if ( function_exists( 'wp_generate_uuid4' ) ) {
			return wp_generate_uuid4();
		}

		$bytes = random_bytes( 16 );

		// Set the version (4) and variant (RFC 4122) bits per
		// RFC 4122 §4.4.
		$bytes[6] = chr( ( ord( $bytes[6] ) & 0x0F ) | 0x40 );
		$bytes[8] = chr( ( ord( $bytes[8] ) & 0x3F ) | 0x80 );

		$hex = bin2hex( $bytes );

		return sprintf(
			'%s-%s-%s-%s-%s',
			substr( $hex, 0, 8 ),
			substr( $hex, 8, 4 ),
			substr( $hex, 12, 4 ),
			substr( $hex, 16, 4 ),
			substr( $hex, 20, 12 )
		);
	}

	/**
	 * Encode the §10.2 payload array as a JSON string.
	 *
	 * @param array<string, mixed> $payload Payload to encode.
	 *
	 * @throws RuntimeException When JSON encoding fails.
	 */
	private static function encodePayload( array $payload ): string {
		if ( function_exists( 'wp_json_encode' ) ) {
			$encoded = wp_json_encode( $payload );
		} else {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure-unit test environment without WordPress runtime; production code path uses wp_json_encode above.
			$encoded = json_encode( $payload );
		}

		if ( ! is_string( $encoded ) ) {
			$message = 'SyncDispatcher: failed to JSON-encode sync_product payload.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException( $message );
		}

		return $encoded;
	}
}
