<?php
/**
 * Result value object for {@see EnqueueProductSyncUseCase}.
 *
 * Phase 9 slice 9g Application-layer immutable VO that wraps the
 * outcome of a `POST /affilun/v1/sync/products/{id}` enqueue per
 * `specifications/rest-api-spec.md` §11.2:
 *
 *   - The persisted {@see SyncJobRecord} the dispatcher returned
 *     (with its assigned auto-increment id, `pending` DB status,
 *     priority, max-attempts budget, and timestamps).
 *
 *   - The correlation id stamped onto the job's payload, exposed
 *     separately so the controller can render it onto the §4.1
 *     response `meta.correlation_id` field without round-tripping
 *     through {@see SyncDispatcher::payloadFor()}.
 *
 * §11.2 envelope shape:
 *
 *   - {@see self::toArray()} produces the §11.2 success-envelope
 *     `data` shape verbatim:
 *     `array('queued' => true, 'job' => array('id' => int,
 *     'job_type' => string, 'status' => string))`.
 *
 *   - The `job.status` field uses the §6.0 REST / UI mapping:
 *     the DB-side `pending` value is surfaced as `'queued'` so
 *     the wire shape matches the spec's success example exactly.
 *     The mapping table is centralized in {@see self::STATUS_MAP}
 *     so future status-axis additions stay consistent with the
 *     §6.0 "ジョブ status 写像" table.
 *
 * @package Affilun\Application\Sync\UseCase
 */

declare(strict_types=1);

namespace Affilun\Application\Sync\UseCase;

use Affilun\Infrastructure\Persistence\SyncJobRecord;
use InvalidArgumentException;
use RuntimeException;

/**
 * Immutable result of {@see EnqueueProductSyncUseCase::execute()}.
 *
 * See the file-level docblock for the §11.2 envelope-shape
 * contract and the §6.0 DB-status → REST-status mapping rule.
 */
final class EnqueueProductSyncResult {

	/**
	 * §6.0 mapping from `affilun_sync_jobs.status` (DB) to REST / UI
	 * publication value.
	 *
	 * Held as a class-level constant so the mapping is colocated
	 * with the {@see self::toArray()} consumer and so future
	 * status-axis additions stay consistent with the §6.0 table.
	 *
	 * @var array<string, string>
	 */
	private const STATUS_MAP = array(
		'pending'   => 'queued',
		'running'   => 'running',
		'completed' => 'completed',
		'failed'    => 'failed',
		'cancelled' => 'cancelled',
	);

	/**
	 * Persisted job record returned by the dispatcher.
	 */
	private SyncJobRecord $job;

	/**
	 * Correlation id stamped onto the job's payload.
	 */
	private string $correlationId;

	/**
	 * Construct a result with explicit values.
	 *
	 * @param SyncJobRecord $job           Persisted job record;
	 *                                     MUST carry a non-null id.
	 * @param string        $correlationId Correlation id stamped
	 *                                     onto the payload; MUST be
	 *                                     a non-empty trimmed
	 *                                     string.
	 *
	 * @throws InvalidArgumentException When `$job->id()` is null or
	 *                                  `$correlationId` is empty
	 *                                  after trimming.
	 */
	public function __construct( SyncJobRecord $job, string $correlationId ) {
		if ( $job->id() === null ) {
			$message = 'EnqueueProductSyncResult requires a persisted SyncJobRecord (non-null id).';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$trimmed = trim( $correlationId );
		if ( $trimmed === '' ) {
			$message = 'EnqueueProductSyncResult requires a non-empty correlation id.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->job           = $job;
		$this->correlationId = $trimmed;
	}

	/**
	 * Persisted job record.
	 */
	public function job(): SyncJobRecord {
		return $this->job;
	}

	/**
	 * Correlation id stamped onto the job's payload.
	 */
	public function correlationId(): string {
		return $this->correlationId;
	}

	/**
	 * Render the §11.2 success envelope `data` shape:
	 *
	 *   `array(
	 *       'queued' => true,
	 *       'job'    => array(
	 *           'id'       => int,
	 *           'job_type' => string,
	 *           'status'   => string,
	 *       ),
	 *   )`
	 *
	 * The `job.status` field projects the DB-side
	 * `affilun_sync_jobs.status` through {@see self::STATUS_MAP} so the
	 * wire shape uses the §6.0 REST / UI publication value
	 * (`'queued'` rather than `'pending'`).
	 *
	 * @return array<string, mixed>
	 *
	 * @throws RuntimeException When the DB status is not a member
	 *                          of the §6.0 mapping table (a
	 *                          defensive guard against future
	 *                          status-axis drift).
	 */
	public function toArray(): array {
		$dbStatus = $this->job->status();
		if ( ! array_key_exists( $dbStatus, self::STATUS_MAP ) ) {
			$message = sprintf(
				'EnqueueProductSyncResult: unmapped affilun_sync_jobs.status "%s"; '
				. 'update §6.0 STATUS_MAP to cover the new value.',
				$dbStatus
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new RuntimeException( $message );
		}

		return array(
			'queued' => true,
			'job'    => array(
				'id'       => (int) $this->job->id(),
				'job_type' => $this->job->jobType(),
				'status'   => self::STATUS_MAP[ $dbStatus ],
			),
		);
	}
}
