<?php
/**
 * Persistence DTO for the `affilun_sync_jobs` table.
 *
 * Immutable value object that mirrors the canonical column set defined
 * in `specifications/database-schema.md` §8.2. The record is the narrow
 * data surface exchanged between the {@see SyncJobRepository} contract
 * and its implementations ({@see InMemorySyncJobRepository},
 * {@see WpdbSyncJobRepository}); it deliberately carries no behavior
 * beyond construction-time invariants and field accessors so that:
 *
 *   - Tests can compare two records field-by-field without having to
 *     reach into a mutable graph of domain entities.
 *   - The repository implementations can map rows to and from the
 *     database without a second indirection layer.
 *   - Phase 3 / later worker code (lock acquisition, retry bookkeeping,
 *     reclamation sweeps) can build richer domain types on top of this
 *     record without the persistence layer having to depend on them.
 *
 * Field semantics match `database-schema.md` §8.2 exactly:
 *
 *   - `id` is null for records that have not yet been persisted. The
 *     repository's `save()` contract is responsible for assigning the
 *     auto-increment id and returning a new record that carries it.
 *
 *   - Required (`NOT NULL`) columns are non-nullable on the record:
 *     `jobType`, `payloadJson`, `status`, `priority`, `attempts`,
 *     `maxAttempts`, `availableAt`, `createdAt`, `updatedAt`.
 *
 *   - Nullable columns from the schema (`lockedAt`, `lockedBy`,
 *     `finishedAt`, `lastErrorCode`, `lastError`) are nullable on the
 *     record so a fresh `pending` row can be constructed without
 *     synthesizing unrelated values.
 *
 *   - `status` is held as a plain string at this layer; the §8.2 enum
 *     (`pending`, `running`, `failed`, `completed`, `cancelled`) is
 *     enforced by the application / worker layer, matching the
 *     approach taken for `sync_state` / `lifecycle_state` on
 *     {@see ProductRecord} and `type` on {@see EmbedRecord}.
 *
 *   - `priority`, `attempts`, `maxAttempts` are `int unsigned` on the
 *     schema side; the record rejects negative values at construction
 *     time so a programming error (wrapped-around cast, accidental
 *     `-1` sentinel) surfaces before the database boundary. A
 *     `maxAttempts` of zero is additionally rejected because a job
 *     with no attempts budget is meaningless in practice.
 *
 *   - Timestamps (`availableAt`, `lockedAt`, `finishedAt`,
 *     `createdAt`, `updatedAt`) are held as UTC `\DateTimeImmutable`
 *     instances. The persistence layer converts to and from the MySQL
 *     `Y-m-d H:i:s` representation on the storage boundary; callers
 *     that want a formatted string should use
 *     {@see self::formatDateTime()}.
 *
 * Instances are immutable. To produce a modified copy (for example
 * after the repository has assigned an id, or after the worker has
 * advanced `updated_at`, recorded a lock, or marked the job finished),
 * call {@see self::withId()} or {@see self::withUpdatedAt()}, each of
 * which returns a new instance and leaves the original untouched.
 * Richer transition helpers (`withLock()`, `withFinished()`,
 * `withLastError()`) are deliberately NOT added here — they belong
 * with the Phase 2+ worker code that owns the state-machine, not with
 * the row DTO.
 *
 * @package Affilun\Infrastructure\Persistence
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Persistence;

use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Immutable row-shaped value object for `affilun_sync_jobs`.
 *
 * See the file-level docblock for the mapping to `database-schema.md`
 * §8.2 and the rationale for keeping this type behavior-free.
 */
final class SyncJobRecord {

	/**
	 * Canonical MySQL `DATETIME` format used by every timestamp column
	 * on this table, matching the WordPress convention of storing UTC
	 * values as `Y-m-d H:i:s`.
	 */
	public const MYSQL_DATETIME_FORMAT = 'Y-m-d H:i:s';

	/**
	 * Default `status` value per §8.2 for a freshly-queued job.
	 *
	 * Exposed as a class constant so {@see self::createNew()} and
	 * tests can reference the exact string without duplicating the
	 * literal.
	 */
	public const DEFAULT_STATUS = 'pending';

	/**
	 * Status discriminator for a job currently held by a worker per
	 * §8.5. Set by {@see self::withClaim()} as part of the §8.5
	 * lock-acquisition transition.
	 */
	public const STATUS_RUNNING = 'running';

	/**
	 * Default `priority` value per §8.2. Smaller numbers are higher
	 * priority; 100 is the neutral midpoint.
	 */
	public const DEFAULT_PRIORITY = 100;

	/**
	 * Default `max_attempts` value per §8.2. The worker gives up on
	 * a job after this many failures.
	 */
	public const DEFAULT_MAX_ATTEMPTS = 5;

	/**
	 * Auto-increment primary key, or null for unpersisted records.
	 */
	private ?int $id;

	/**
	 * Job type discriminator per §8.2 / §8.4 (`sync_product`,
	 * `sync_products_bulk`, `rebuild_render_cache`,
	 * `recalculate_usage_index`, `cleanup_logs`, ...). Held as a plain
	 * string at this layer; the set of recognized job types is owned
	 * by the worker layer.
	 */
	private string $jobType;

	/**
	 * Serialized JSON payload per §8.2 / §3.3. Held as a plain string;
	 * this layer does not parse the payload.
	 */
	private string $payloadJson;

	/**
	 * Status discriminator per §8.2. Initial enum is `pending`,
	 * `running`, `failed`, `completed`, `cancelled`; this DTO does
	 * not enforce the set.
	 */
	private string $status;

	/**
	 * Priority per §8.2 (smaller = higher priority). Non-negative.
	 */
	private int $priority;

	/**
	 * Executed attempt count per §8.2. Non-negative.
	 */
	private int $attempts;

	/**
	 * Maximum retry budget per §8.2. Strictly positive — a job with
	 * no attempts budget is meaningless.
	 */
	private int $maxAttempts;

	/**
	 * Earliest wall-clock time at which the worker may pick up this
	 * job, per §8.2 / §8.5. Required.
	 */
	private DateTimeImmutable $availableAt;

	/**
	 * Lock-acquisition timestamp per §8.2, or null when the job is
	 * not currently held by a worker.
	 */
	private ?DateTimeImmutable $lockedAt;

	/**
	 * Lock-holder worker identifier per §8.2, or null when the job
	 * is not currently held.
	 */
	private ?string $lockedBy;

	/**
	 * Completion timestamp per §8.2, or null while the job is still
	 * in-flight or pending.
	 */
	private ?DateTimeImmutable $finishedAt;

	/**
	 * Most recent failure code per §8.2, or null when the most recent
	 * attempt succeeded or the job has not been attempted yet.
	 */
	private ?string $lastErrorCode;

	/**
	 * Most recent failure message per §8.2, or null — see
	 * {@see self::$lastErrorCode}.
	 */
	private ?string $lastError;

	/**
	 * Row creation timestamp. Required per §8.2.
	 */
	private DateTimeImmutable $createdAt;

	/**
	 * Row update timestamp. Required per §8.2.
	 */
	private DateTimeImmutable $updatedAt;

	/**
	 * Construct a record with an explicit value for every column.
	 *
	 * The constructor is deliberately exhaustive so that a programming
	 * error (missing column on a write) surfaces at the type system
	 * boundary rather than at the database. Named-argument callers and
	 * the `fromArray()` / `toArray()` helpers are the ergonomic entry
	 * points for most code; construction-by-position is reserved for
	 * the repository implementations themselves.
	 *
	 * @param int|null               $id            Primary key, null for unpersisted records.
	 * @param string                 $jobType       Job type discriminator.
	 * @param string                 $payloadJson   Serialized JSON payload.
	 * @param string                 $status        Status discriminator.
	 * @param int                    $priority      Priority (non-negative; smaller = higher).
	 * @param int                    $attempts      Executed attempt count (non-negative).
	 * @param int                    $maxAttempts   Retry budget (strictly positive).
	 * @param DateTimeImmutable      $availableAt   Earliest execution time.
	 * @param DateTimeImmutable|null $lockedAt      Lock-acquisition time, nullable.
	 * @param string|null            $lockedBy      Lock-holder id, nullable.
	 * @param DateTimeImmutable|null $finishedAt    Completion time, nullable.
	 * @param string|null            $lastErrorCode Last failure code, nullable.
	 * @param string|null            $lastError     Last failure message, nullable.
	 * @param DateTimeImmutable      $createdAt     Row creation timestamp.
	 * @param DateTimeImmutable      $updatedAt     Row update timestamp.
	 *
	 * @throws InvalidArgumentException When a non-nullable string is
	 *                                  empty, `id` is non-positive,
	 *                                  `priority` / `attempts` are
	 *                                  negative, `maxAttempts` is
	 *                                  non-positive, or `lockedBy` is
	 *                                  the empty string.
	 */
	public function __construct(
		?int $id,
		string $jobType,
		string $payloadJson,
		string $status,
		int $priority,
		int $attempts,
		int $maxAttempts,
		DateTimeImmutable $availableAt,
		?DateTimeImmutable $lockedAt,
		?string $lockedBy,
		?DateTimeImmutable $finishedAt,
		?string $lastErrorCode,
		?string $lastError,
		DateTimeImmutable $createdAt,
		DateTimeImmutable $updatedAt
	) {
		if ( $id !== null && $id <= 0 ) {
			$message = sprintf( 'SyncJobRecord 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 );
		}

		if ( $priority < 0 ) {
			$message = sprintf( 'SyncJobRecord priority must be non-negative, got %d.', $priority );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $attempts < 0 ) {
			$message = sprintf( 'SyncJobRecord attempts must be non-negative, got %d.', $attempts );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $maxAttempts <= 0 ) {
			$message = sprintf( 'SyncJobRecord maxAttempts must be a positive integer, got %d.', $maxAttempts );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $lockedBy !== null && $lockedBy === '' ) {
			$message = 'SyncJobRecord lockedBy must be null or a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->assertNonEmpty( 'jobType', $jobType );
		$this->assertNonEmpty( 'payloadJson', $payloadJson );
		$this->assertNonEmpty( 'status', $status );

		$this->id            = $id;
		$this->jobType       = $jobType;
		$this->payloadJson   = $payloadJson;
		$this->status        = $status;
		$this->priority      = $priority;
		$this->attempts      = $attempts;
		$this->maxAttempts   = $maxAttempts;
		$this->availableAt   = self::normalizeToUtc( $availableAt ) ?? $availableAt;
		$this->lockedAt      = self::normalizeToUtc( $lockedAt );
		$this->lockedBy      = $lockedBy;
		$this->finishedAt    = self::normalizeToUtc( $finishedAt );
		$this->lastErrorCode = $lastErrorCode;
		$this->lastError     = $lastError;
		$this->createdAt     = self::normalizeToUtc( $createdAt ) ?? $createdAt;
		$this->updatedAt     = self::normalizeToUtc( $updatedAt ) ?? $updatedAt;
	}

	/**
	 * Ergonomic named constructor for fresh (unpersisted) records.
	 *
	 * Required columns are taken as positional arguments; everything
	 * else (status, priority, retry budget, scheduling, lock state,
	 * error summary) flows through an overrides array so callers that
	 * just want "enqueue a pending job now" can stay terse.
	 *
	 * Defaults:
	 *
	 *   - `status` defaults to {@see self::DEFAULT_STATUS} (`pending`).
	 *   - `priority` defaults to {@see self::DEFAULT_PRIORITY} (100).
	 *   - `attempts` defaults to 0.
	 *   - `maxAttempts` defaults to {@see self::DEFAULT_MAX_ATTEMPTS} (5).
	 *   - `availableAt` defaults to `$now` (job is immediately eligible).
	 *   - `lockedAt`, `lockedBy`, `finishedAt`, `lastErrorCode`,
	 *     `lastError` all default to null.
	 *
	 * @param string               $jobType     Job type discriminator.
	 * @param string               $payloadJson Serialized JSON payload.
	 * @param DateTimeImmutable    $now         Current UTC timestamp.
	 * @param array<string, mixed> $optional    Optional column overrides
	 *                                          keyed by property name.
	 */
	public static function createNew(
		string $jobType,
		string $payloadJson,
		DateTimeImmutable $now,
		array $optional = array()
	): self {
		$status = self::DEFAULT_STATUS;
		if ( array_key_exists( 'status', $optional ) && $optional['status'] !== null ) {
			$status = (string) $optional['status'];
		}

		$priority = self::DEFAULT_PRIORITY;
		if ( array_key_exists( 'priority', $optional ) && $optional['priority'] !== null ) {
			$priority = (int) $optional['priority'];
		}

		$attempts = 0;
		if ( array_key_exists( 'attempts', $optional ) && $optional['attempts'] !== null ) {
			$attempts = (int) $optional['attempts'];
		}

		$maxAttempts = self::DEFAULT_MAX_ATTEMPTS;
		if ( array_key_exists( 'maxAttempts', $optional ) && $optional['maxAttempts'] !== null ) {
			$maxAttempts = (int) $optional['maxAttempts'];
		}

		$availableAt = $now;
		if (
			array_key_exists( 'availableAt', $optional )
			&& $optional['availableAt'] instanceof DateTimeImmutable
		) {
			$availableAt = $optional['availableAt'];
		}

		$lockedAt = null;
		if (
			array_key_exists( 'lockedAt', $optional )
			&& $optional['lockedAt'] instanceof DateTimeImmutable
		) {
			$lockedAt = $optional['lockedAt'];
		}

		$lockedBy = null;
		if ( array_key_exists( 'lockedBy', $optional ) && $optional['lockedBy'] !== null ) {
			$lockedBy = (string) $optional['lockedBy'];
		}

		$finishedAt = null;
		if (
			array_key_exists( 'finishedAt', $optional )
			&& $optional['finishedAt'] instanceof DateTimeImmutable
		) {
			$finishedAt = $optional['finishedAt'];
		}

		$lastErrorCode = null;
		if ( array_key_exists( 'lastErrorCode', $optional ) && $optional['lastErrorCode'] !== null ) {
			$lastErrorCode = (string) $optional['lastErrorCode'];
		}

		$lastError = null;
		if ( array_key_exists( 'lastError', $optional ) && $optional['lastError'] !== null ) {
			$lastError = (string) $optional['lastError'];
		}

		return new self(
			null,
			$jobType,
			$payloadJson,
			$status,
			$priority,
			$attempts,
			$maxAttempts,
			$availableAt,
			$lockedAt,
			$lockedBy,
			$finishedAt,
			$lastErrorCode,
			$lastError,
			$now,
			$now
		);
	}

	/**
	 * Reconstitute a record from a row-shaped associative array.
	 *
	 * Intended as the single decode seam used by
	 * {@see WpdbSyncJobRepository} when mapping `$wpdb->get_row()`
	 * results back into records. Unknown keys are ignored so callers
	 * can pass raw result rows without having to filter them; missing
	 * non-nullable keys raise {@see InvalidArgumentException}.
	 *
	 * DateTime columns accept either a `DateTimeImmutable` instance
	 * (produced by in-memory repositories) or a `Y-m-d H:i:s` string
	 * (produced by `$wpdb`). Invalid strings raise
	 * {@see InvalidArgumentException}.
	 *
	 * @param array<string, mixed> $row Row-shaped associative array.
	 *
	 * @throws InvalidArgumentException When a required key is missing
	 *                                  or a timestamp string cannot
	 *                                  be parsed.
	 */
	public static function fromArray( array $row ): self {
		$requireString = static function ( string $key ) use ( $row ): string {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				$message = sprintf( 'SyncJobRecord::fromArray() missing required key "%s".', $key );
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			return (string) $row[ $key ];
		};

		$optionalString = static function ( string $key ) use ( $row ): ?string {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				return null;
			}
			return (string) $row[ $key ];
		};

		$requireInt = static function ( string $key ) use ( $row ): int {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				$message = sprintf( 'SyncJobRecord::fromArray() missing required integer "%s".', $key );
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			return (int) $row[ $key ];
		};

		$requireDate = static function ( string $key ) use ( $row ): DateTimeImmutable {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				$message = sprintf( 'SyncJobRecord::fromArray() missing required timestamp "%s".', $key );
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			return self::parseDateTime( $key, $row[ $key ] );
		};

		$optionalDate = static function ( string $key ) use ( $row ): ?DateTimeImmutable {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				return null;
			}
			return self::parseDateTime( $key, $row[ $key ] );
		};

		$idRaw = $row['id'] ?? null;
		$id    = $idRaw === null ? null : (int) $idRaw;

		return new self(
			$id,
			$requireString( 'job_type' ),
			$requireString( 'payload_json' ),
			$requireString( 'status' ),
			$requireInt( 'priority' ),
			$requireInt( 'attempts' ),
			$requireInt( 'max_attempts' ),
			$requireDate( 'available_at' ),
			$optionalDate( 'locked_at' ),
			$optionalString( 'locked_by' ),
			$optionalDate( 'finished_at' ),
			$optionalString( 'last_error_code' ),
			$optionalString( 'last_error' ),
			$requireDate( 'created_at' ),
			$requireDate( 'updated_at' )
		);
	}

	/**
	 * Encode the record as a row-shaped associative array keyed by the
	 * canonical snake_case column names from §8.2.
	 *
	 * Used by {@see WpdbSyncJobRepository::save()} to produce the
	 * payload handed to `$wpdb->insert()` / `$wpdb->update()`.
	 * Timestamps are formatted via {@see self::formatDateTime()} so the
	 * array is round-trippable through {@see self::fromArray()}.
	 *
	 * @return array<string, string|int|null>
	 */
	public function toArray(): array {
		return array(
			'id'              => $this->id,
			'job_type'        => $this->jobType,
			'payload_json'    => $this->payloadJson,
			'status'          => $this->status,
			'priority'        => $this->priority,
			'attempts'        => $this->attempts,
			'max_attempts'    => $this->maxAttempts,
			'available_at'    => self::formatDateTime( $this->availableAt ),
			'locked_at'       => self::formatDateTime( $this->lockedAt ),
			'locked_by'       => $this->lockedBy,
			'finished_at'     => self::formatDateTime( $this->finishedAt ),
			'last_error_code' => $this->lastErrorCode,
			'last_error'      => $this->lastError,
			'created_at'      => self::formatDateTime( $this->createdAt ),
			'updated_at'      => self::formatDateTime( $this->updatedAt ),
		);
	}

	/**
	 * Produce a copy of this record with the given primary-key id
	 * assigned. Used by the repository's `save()` path immediately
	 * after an insert to surface the database-assigned id to callers.
	 *
	 * @param int $id Newly-assigned primary key. Must be positive.
	 *
	 * @throws InvalidArgumentException When `$id` is non-positive.
	 */
	public function withId( int $id ): self {
		if ( $id <= 0 ) {
			$message = sprintf( 'SyncJobRecord::withId() requires a positive integer, got %d.', $id );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$clone     = clone $this;
		$clone->id = $id;
		return $clone;
	}

	/**
	 * Produce a copy of this record with a new `updated_at` timestamp.
	 *
	 * Used by the repository's `save()` path on the update branch so
	 * `updated_at` advances even when no other field changed.
	 *
	 * @param DateTimeImmutable $updatedAt New `updated_at` value; normalized to UTC.
	 */
	public function withUpdatedAt( DateTimeImmutable $updatedAt ): self {
		$clone            = clone $this;
		$clone->updatedAt = self::normalizeToUtc( $updatedAt ) ?? $updatedAt;
		return $clone;
	}

	/**
	 * Produce a copy of this record transitioned to the §8.5 claimed
	 * (`running`) state for the supplied worker.
	 *
	 * Sets `status='running'`, `lockedAt=$now`, `lockedBy=$workerId`,
	 * and `updatedAt=$now`. The `attempts` counter is intentionally
	 * NOT incremented here — the handler increments it on the
	 * terminal transition (success / failure) so that a worker crash
	 * mid-flight does not burn an attempt the handler never observed.
	 *
	 * Used by repository implementations of
	 * {@see SyncJobRepository::claimNext()} as the canonical
	 * post-claim record shape so both the in-memory and Wpdb-backed
	 * implementations stay behaviorally aligned.
	 *
	 * @param string            $workerId Lock-holder identifier; must
	 *                                    be non-empty and at most 64
	 *                                    bytes (matching the
	 *                                    `locked_by` column width).
	 * @param DateTimeImmutable $now      Current UTC instant; stamped
	 *                                    onto `lockedAt` and
	 *                                    `updatedAt` (normalized to
	 *                                    UTC).
	 *
	 * @throws InvalidArgumentException When `$workerId` is empty or
	 *                                  exceeds 64 bytes.
	 */
	public function withClaim( string $workerId, DateTimeImmutable $now ): self {
		$length = strlen( $workerId );
		if ( $length === 0 ) {
			$message = 'SyncJobRecord::withClaim() requires a non-empty workerId.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
		if ( $length > 64 ) {
			$message = sprintf(
				'SyncJobRecord::withClaim() requires workerId of at most 64 bytes, got %d.',
				$length
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$normalizedNow = self::normalizeToUtc( $now ) ?? $now;

		$clone            = clone $this;
		$clone->status    = self::STATUS_RUNNING;
		$clone->lockedAt  = $normalizedNow;
		$clone->lockedBy  = $workerId;
		$clone->updatedAt = $normalizedNow;
		return $clone;
	}


	/**
	 * Auto-increment primary key, or null for unpersisted records.
	 */
	public function id(): ?int {
		return $this->id;
	}

	/**
	 * Report whether this record has been assigned a primary key.
	 */
	public function isPersisted(): bool {
		return $this->id !== null;
	}

	/**
	 * Job type discriminator per §8.2 / §8.4.
	 */
	public function jobType(): string {
		return $this->jobType;
	}

	/**
	 * Serialized JSON payload per §8.2.
	 */
	public function payloadJson(): string {
		return $this->payloadJson;
	}

	/**
	 * Status discriminator per §8.2.
	 */
	public function status(): string {
		return $this->status;
	}

	/**
	 * Priority per §8.2 (smaller = higher priority).
	 */
	public function priority(): int {
		return $this->priority;
	}

	/**
	 * Executed attempt count per §8.2.
	 */
	public function attempts(): int {
		return $this->attempts;
	}

	/**
	 * Retry budget per §8.2.
	 */
	public function maxAttempts(): int {
		return $this->maxAttempts;
	}

	/**
	 * Earliest execution time per §8.2 / §8.5.
	 */
	public function availableAt(): DateTimeImmutable {
		return $this->availableAt;
	}

	/**
	 * Lock-acquisition timestamp per §8.2, or null when not held.
	 */
	public function lockedAt(): ?DateTimeImmutable {
		return $this->lockedAt;
	}

	/**
	 * Lock-holder worker identifier per §8.2, or null when not held.
	 */
	public function lockedBy(): ?string {
		return $this->lockedBy;
	}

	/**
	 * Completion timestamp per §8.2, or null while in-flight.
	 */
	public function finishedAt(): ?DateTimeImmutable {
		return $this->finishedAt;
	}

	/**
	 * Most recent failure code per §8.2, or null.
	 */
	public function lastErrorCode(): ?string {
		return $this->lastErrorCode;
	}

	/**
	 * Most recent failure message per §8.2, or null.
	 */
	public function lastError(): ?string {
		return $this->lastError;
	}

	/**
	 * UTC `created_at` timestamp for this record.
	 */
	public function createdAt(): DateTimeImmutable {
		return $this->createdAt;
	}

	/**
	 * UTC `updated_at` timestamp for this record.
	 */
	public function updatedAt(): DateTimeImmutable {
		return $this->updatedAt;
	}

	/**
	 * Format a {@see DateTimeInterface} as a canonical MySQL `DATETIME`
	 * string in UTC, or return null when the input is null.
	 *
	 * Exposed as a public static helper so the repository implementations
	 * and tests can round-trip timestamps through the same path.
	 *
	 * @param DateTimeInterface|null $value Timestamp to format.
	 */
	public static function formatDateTime( ?DateTimeInterface $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$utc = $value instanceof DateTimeImmutable
			? $value->setTimezone( new DateTimeZone( 'UTC' ) )
			: DateTimeImmutable::createFromInterface( $value )->setTimezone( new DateTimeZone( 'UTC' ) );

		return $utc->format( self::MYSQL_DATETIME_FORMAT );
	}

	/**
	 * Parse a MySQL `DATETIME` string (or an already-constructed
	 * {@see DateTimeImmutable}) into a UTC `DateTimeImmutable`.
	 *
	 * @param string $key   Column name, used only in the
	 *                      error message to surface which
	 *                      timestamp failed to parse.
	 * @param mixed  $value Value to parse.
	 *
	 * @throws InvalidArgumentException When `$value` is not a
	 *                                  {@see DateTimeImmutable} or a
	 *                                  parseable MySQL `DATETIME`
	 *                                  string.
	 */
	private static function parseDateTime( string $key, $value ): DateTimeImmutable {
		if ( $value instanceof DateTimeImmutable ) {
			return self::normalizeToUtc( $value ) ?? $value;
		}

		if ( is_string( $value ) && $value !== '' ) {
			$parsed = DateTimeImmutable::createFromFormat(
				self::MYSQL_DATETIME_FORMAT,
				$value,
				new DateTimeZone( 'UTC' )
			);

			if ( $parsed instanceof DateTimeImmutable ) {
				return $parsed;
			}

			try {
				return new DateTimeImmutable( $value, new DateTimeZone( 'UTC' ) );
				// phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Fall through to the descriptive throw below.
			} catch ( \Exception $unused ) {
				// Fall through to the descriptive throw below.
				unset( $unused );
			}
		}

		$message = sprintf(
			'SyncJobRecord::fromArray() could not parse "%s" as a MySQL DATETIME.',
			$key
		);
		// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
		throw new InvalidArgumentException( $message );
	}

	/**
	 * Normalize a nullable timestamp to UTC so equality checks and
	 * repository round-trips do not depend on the caller's timezone.
	 *
	 * @param DateTimeImmutable|null $value Value to normalize.
	 */
	private static function normalizeToUtc( ?DateTimeImmutable $value ): ?DateTimeImmutable {
		if ( $value === null ) {
			return null;
		}

		return $value->setTimezone( new DateTimeZone( 'UTC' ) );
	}

	/**
	 * Guard against an empty string for a column that the schema
	 * declares as `NOT NULL`.
	 *
	 * @param string $field Column / property name for the error message.
	 * @param string $value Value to validate.
	 *
	 * @throws InvalidArgumentException When `$value` is the empty string.
	 */
	private function assertNonEmpty( string $field, string $value ): void {
		if ( $value === '' ) {
			$message = sprintf( 'SyncJobRecord requires a non-empty value for "%s".', $field );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
	}
}
