<?php
/**
 * List-sync-jobs use case.
 *
 * Phase 11 slice 11e-A Application-layer use case that drives the
 * §11.3 `GET /affilun/v1/sync/jobs` REST surface documented in
 * `specifications/rest-api-spec.md`. The use case is a thin
 * orchestration seam between the persistence-layer query surface
 * ({@see SyncJobRepository::findPaginated()}) and the wire-shape
 * row projection (DB → wire status remapping per §6.0); it does
 * NOT own filter validation (that lives on
 * {@see \Affilun\Infrastructure\Persistence\SyncJobListQuery})
 * and does NOT own envelope construction (that lives on the REST
 * controller in a later slice).
 *
 * Wire ⇔ DB status mapping placement:
 *
 *   The persistence layer speaks DB-vocabulary throughout
 *   (`pending|running|completed|failed|cancelled` per
 *   `database-schema.md` §8.2). The §11.3 REST surface speaks
 *   wire-vocabulary (`queued|running|completed|failed|cancelled`
 *   per `rest-api-spec.md` §11.3). Per §6.0 the only spelling
 *   difference is DB `pending` ⇔ wire `queued`; every other status
 *   is identical. This use case is the seam where the DB → wire
 *   substitution happens for the `status` field on each row. The
 *   inverse (wire `queued` → DB `pending`) is a controller-side
 *   concern when constructing the {@see SyncJobListQuery} input
 *   to this use case.
 *
 * @package Affilun\Application\SyncJobs\UseCase
 */

declare(strict_types=1);

namespace Affilun\Application\SyncJobs\UseCase;

use Affilun\Infrastructure\Persistence\SyncJobListQuery;
use Affilun\Infrastructure\Persistence\SyncJobRecord;
use Affilun\Infrastructure\Persistence\SyncJobRepository;
use DateTimeInterface;

/**
 * Application-layer use case for `GET /affilun/v1/sync/jobs` (§11.3).
 *
 * See the file-level docblock for the orchestration shape: query
 * → repository → wire-shape projection → result VO, and the
 * placement rationale for the DB → wire status remapping.
 */
final class ListSyncJobsUseCase {


	/**
	 * DB-vocabulary status that maps to wire `queued` per §6.0.
	 */
	private const DB_STATUS_PENDING = 'pending';


	/**
	 * Wire-vocabulary status corresponding to DB `pending` per §6.0.
	 */
	private const WIRE_STATUS_QUEUED = 'queued';


	/**
	 * Persistence-layer query surface.
	 */
	private SyncJobRepository $jobs;


	/**
	 * Construct the use case with explicit collaborator injection.
	 *
	 * @param SyncJobRepository $jobs Persistence-layer query surface.
	 */
	public function __construct( SyncJobRepository $jobs ) {
		$this->jobs = $jobs;
	}


	/**
	 * Execute the §11.3 list-sync-jobs query.
	 *
	 * Calls {@see SyncJobRepository::findPaginated()} with the
	 * supplied query VO, projects every returned
	 * {@see SyncJobRecord} through {@see self::projectJob()} (which
	 * applies the DB `pending` → wire `queued` substitution), and
	 * wraps the result in a {@see ListSyncJobsResult} carrying the
	 * pagination axes echoed back from the query.
	 *
	 * @param SyncJobListQuery $query Validated list-query inputs.
	 */
	public function execute( SyncJobListQuery $query ): ListSyncJobsResult {
		$page = $this->jobs->findPaginated( $query );

		$items = array();
		foreach ( $page->items() as $record ) {
			$items[] = $this->projectJob( $record );
		}
		return new ListSyncJobsResult(
			$items,
			$page->page(),
			$page->perPage(),
			$page->total(),
			$page->pageCount()
		);
	}
	/**
	 * Project a single {@see SyncJobRecord} onto the §6.5 `SyncJob`
	 * wire shape.
	 *
	 * Returned shape:
	 *
	 *   `array(
	 *       'id'            => ?int,
	 *       'job_type'      => string,
	 *       'status'        => string,    // wire vocabulary
	 *       'attempts'      => int,
	 *       'available_at'  => ?string,   // ISO-8601 UTC
	 *       'locked_at'     => ?string,   // ISO-8601 UTC
	 *       'finished_at'   => ?string,   // ISO-8601 UTC
	 *       'last_error'    => ?string,
	 *       'payload'       => array<string, mixed>,
	 *   )`
	 *
	 * The DB `pending` → wire `queued` substitution per
	 * `rest-api-spec.md` §6.0 is applied on the `status` axis;
	 * every other status passes through verbatim.
	 *
	 * @param SyncJobRecord $record Record to project.
	 *
	 * @return array<string, mixed>
	 */
	private function projectJob( SyncJobRecord $record ): array {
		$status = $record->status();
		if ( $status === self::DB_STATUS_PENDING ) {
			$status = self::WIRE_STATUS_QUEUED;
		}
		return array(
			'id'           => $record->id(),
			'job_type'     => $record->jobType(),
			'status'       => $status,
			'attempts'     => $record->attempts(),
			'available_at' => $this->formatTimestamp( $record->availableAt() ),
			'locked_at'    => $this->formatTimestamp( $record->lockedAt() ),
			'finished_at'  => $this->formatTimestamp( $record->finishedAt() ),
			'last_error'   => $record->lastError(),
			'payload'      => $this->decodePayload( $record->payloadJson() ),
		);
	}

	/**
	 * Format a {@see DateTimeInterface} as an ISO-8601 UTC string,
	 * or return null when the input is null.
	 *
	 * @param DateTimeInterface|null $value Timestamp to format.
	 */
	private function formatTimestamp( ?DateTimeInterface $value ): ?string {
		if ( $value === null ) {
			return null;
		}
		return $value->format( DateTimeInterface::ATOM );
	}

	/**
	 * Decode the record's `payload_json` blob into an associative
	 * array. Returns an empty array when decoding fails or yields a
	 * non-array shape — the §6.5 surface treats payload as a
	 * structured map and a malformed blob should not crash the
	 * listing endpoint.
	 *
	 * @param string $payloadJson Raw JSON blob.
	 *
	 * @return array<string, mixed>
	 */
	private function decodePayload( string $payloadJson ): array {
		$decoded = json_decode( $payloadJson, true );
		if ( ! is_array( $decoded ) ) {
			return array();
		}
		/** @var array<string, mixed> $decoded */
		return $decoded;
	}
}
