<?php
/**
 * Get-sync-job use case.
 *
 * Phase 11 slice 11e-A Application-layer use case that drives the
 * §11.4 `GET /affilun/v1/sync/jobs/{id}` REST surface documented in
 * `specifications/rest-api-spec.md`. The use case is a thin
 * orchestration seam between the persistence-layer single-row
 * lookup ({@see SyncJobRepository::findById()}), the wire-shape
 * row projection (DB → wire status remapping per §6.0), and the
 * payload-side correlation extraction described in §11.4.
 *
 * Wire ⇔ DB status mapping placement:
 *
 *   The persistence layer speaks DB-vocabulary throughout; the
 *   §11.4 REST surface speaks wire-vocabulary. Per §6.0 the only
 *   spelling difference is DB `pending` ⇔ wire `queued`. This use
 *   case is the seam where the DB → wire substitution happens for
 *   the `status` field; the projection logic is duplicated from
 *   {@see ListSyncJobsUseCase::projectJob()} on purpose so that the
 *   single-row and list surfaces remain decoupled and can evolve
 *   independently if §11.3 / §11.4 ever diverge.
 *
 * Payload JSON extraction:
 *
 *   The §11.4 detail surface adds two optional correlation axes on
 *   top of the §6.5 `SyncJob` shape: `correlation_id` and
 *   `parent_request_id`. These live INSIDE the record's
 *   `payload_json` blob (the canonical storage for job-side
 *   request envelope) rather than as dedicated columns on
 *   `affilun_sync_jobs`. The use case decodes the JSON via
 *   {@see json_decode()}, treats decode failures or non-array
 *   shapes as "no correlation envelope present" (both axes are
 *   null, no exception is raised), and extracts the two fields
 *   only when they are present and string-typed. This keeps the
 *   detail endpoint resilient against historical / hand-edited
 *   rows that lack a structured correlation envelope.
 *
 * @package Affilun\Application\SyncJobs\UseCase
 */

declare(strict_types=1);

namespace Affilun\Application\SyncJobs\UseCase;

use Affilun\Application\SyncJobs\Exception\SyncJobNotFoundException;
use Affilun\Infrastructure\Persistence\SyncJobRecord;
use Affilun\Infrastructure\Persistence\SyncJobRepository;
use DateTimeInterface;
use InvalidArgumentException;

/**
 * Application-layer use case for `GET /affilun/v1/sync/jobs/{id}` (§11.4).
 *
 * See the file-level docblock for the orchestration shape: id
 * validation → repository lookup → wire-shape projection →
 * payload-side correlation extraction → result VO.
 */
final class GetSyncJobUseCase {


	/**
	 * 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';


	/**
	 * Payload key carrying the cross-request correlation series id.
	 */
	private const PAYLOAD_KEY_CORRELATION_ID = 'correlation_id';


	/**
	 * Payload key carrying the originating-request id.
	 */
	private const PAYLOAD_KEY_PARENT_REQUEST_ID = 'parent_request_id';


	/**
	 * 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.4 get-sync-job lookup.
	 *
	 * Validates that `$id` is positive (non-positive ids are a
	 * programming error at the call site, NOT a not-found outcome),
	 * looks the row up via {@see SyncJobRepository::findById()},
	 * raises {@see SyncJobNotFoundException} on miss, projects the
	 * record onto the §6.5 `SyncJob` wire shape, and extracts the
	 * two §11.4 correlation axes from the record's `payload_json`
	 * blob.
	 *
	 * @param int $id Sync-job primary key (>= 1).
	 *
	 * @throws InvalidArgumentException When `$id` is non-positive.
	 * @throws SyncJobNotFoundException When no row matches `$id`.
	 */
	public function execute( int $id ): GetSyncJobResult {
		if ( $id <= 0 ) {
			$message = sprintf(
				'GetSyncJobUseCase: id must be a positive integer, got %d.',
				$id
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

		$payload         = $this->decodePayload( $record->payloadJson() );
		$correlationId   = $this->extractStringField( $payload, self::PAYLOAD_KEY_CORRELATION_ID );
		$parentRequestId = $this->extractStringField( $payload, self::PAYLOAD_KEY_PARENT_REQUEST_ID );

		return new GetSyncJobResult(
			$this->projectJob( $record, $payload ),
			$correlationId,
			$parentRequestId
		);
	}


	/**
	 * Project a single {@see SyncJobRecord} onto the §6.5 `SyncJob`
	 * wire shape.
	 *
	 * Mirrors {@see ListSyncJobsUseCase::projectJob()} field-for-field
	 * so the §11.3 list surface and the §11.4 detail surface remain
	 * shape-compatible. Accepts the pre-decoded payload as a second
	 * argument to avoid decoding the JSON blob twice during a
	 * detail-endpoint call.
	 *
	 * @param SyncJobRecord        $record  Record to project.
	 * @param array<string, mixed> $payload Pre-decoded payload map.
	 *
	 * @return array<string, mixed>
	 */
	private function projectJob( SyncJobRecord $record, array $payload ): 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'      => $payload,
		);
	}


	/**
	 * 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 — a malformed blob should not crash the
	 * detail endpoint per §11.4 resilience expectations.
	 *
	 * @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;
	}


	/**
	 * Extract a string-typed field from the decoded payload, or null
	 * when the key is absent / non-string / empty.
	 *
	 * @param array<string, mixed> $payload Decoded payload map.
	 * @param string               $key     Payload key to read.
	 */
	private function extractStringField( array $payload, string $key ): ?string {
		if ( ! array_key_exists( $key, $payload ) ) {
			return null;
		}
		$value = $payload[ $key ];
		if ( ! is_string( $value ) || $value === '' ) {
			return null;
		}
		return $value;
	}
}
