<?php
/**
 * Persistence DTO for the `affilun_products` table.
 *
 * Immutable value object that mirrors the canonical column set defined
 * in `specifications/database-schema.md` §4.2. The record is the
 * narrow data surface exchanged between the
 * {@see ProductRepository} contract and its implementations
 * ({@see InMemoryProductRepository}, the forthcoming
 * `WpdbProductRepository`); 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 domain entities (`ProductSnapshot` etc.) can be derived
 *     from this record without the persistence layer having to depend
 *     on the domain package.
 *
 * Field semantics match `database-schema.md` §4.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.
 *
 *   - Nullable columns in the schema are nullable on the record, and
 *     non-nullable columns are required by the constructor so a
 *     programming error (forgetting to set `title` before a save)
 *     surfaces at construction time rather than at the database
 *     boundary.
 *
 *   - `sync_state` and `lifecycle_state` are held as plain strings at
 *     this layer; the four-/three-valued enum constraints documented
 *     in §4.2 are enforced by the domain layer in Phase 3, not by
 *     this DTO. Persisting an invalid value would still round-trip
 *     through the record, but the repository's `save()` path validates
 *     the allowed set before issuing the write.
 *
 *   - Timestamps (`created_at`, `updated_at`, `archived_at`,
 *     `last_synced_at`, `last_rendered_at`, `last_sync_attempted_at`)
 *     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 `self::formatDateTime()`.
 *
 * Instances are immutable. To produce a modified copy (for example
 * after the repository has assigned an id or after the application
 * layer has transitioned `sync_state`), call {@see self::withId()} or
 * one of the other focused `with*()` helpers, each of which returns
 * a new instance and leaves the original untouched.
 *
 * @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_products`.
 *
 * See the file-level docblock for the mapping to `database-schema.md`
 * §4.2 and the rationale for keeping this type behavior-free.
 */
final class ProductRecord {

	/**
	 * 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 `sync_state` applied to fresh records per §4.2.
	 */
	public const DEFAULT_SYNC_STATE = 'fresh';

	/**
	 * Default `lifecycle_state` applied to fresh records per §4.2.
	 */
	public const DEFAULT_LIFECYCLE_STATE = 'active';

	/**
	 * Default `snapshot_version` applied to fresh records per §4.2.
	 */
	public const DEFAULT_SNAPSHOT_VERSION = 1;

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

	/**
	 * Provider identifier (for example `amazon`, `rakuten`, `yahoo`).
	 */
	private string $provider;

	/**
	 * Provider-side item identifier (ASIN, itemCode, etc.).
	 */
	private string $providerItemId;

	/**
	 * Normalized hash used to group duplicate candidates. See
	 * `database-schema.md` §4.4 for the generation contract.
	 */
	private string $canonicalHash;

	/**
	 * Product title. Held as a plain string; column is `text NOT NULL`.
	 */
	private string $title;

	/**
	 * Brand name. Nullable per §4.2.
	 */
	private ?string $brand;

	/**
	 * Normalized product description. Nullable per §4.2.
	 */
	private ?string $description;

	/**
	 * Primary image URL. Nullable per §4.2.
	 */
	private ?string $imagePrimaryUrl;

	/**
	 * Image gallery JSON string. Nullable per §4.2; this layer does not
	 * parse the payload.
	 */
	private ?string $imageGalleryJson;

	/**
	 * Numeric price held as a string so the `decimal(12,2)` value
	 * round-trips without float precision loss. Callers that need an
	 * arithmetic value should decode it themselves.
	 */
	private ?string $priceAmount;

	/**
	 * ISO-4217 currency code (for example `JPY`). Nullable per §4.2.
	 */
	private ?string $priceCurrency;

	/**
	 * Display-oriented price label. Nullable per §4.2.
	 */
	private ?string $priceLabel;

	/**
	 * Availability status string. Nullable per §4.2.
	 */
	private ?string $availabilityStatus;

	/**
	 * Canonical detail URL. Column is `text NOT NULL` per §4.2.
	 */
	private string $detailUrl;

	/**
	 * Affiliate URL. Nullable per §4.2.
	 */
	private ?string $affiliateUrl;

	/**
	 * Raw provider payload snapshot as a JSON string. Nullable per §4.2.
	 */
	private ?string $rawPayloadJson;

	/**
	 * Cross-provider search keywords for secondary provider buttons.
	 */
	private ?string $searchKeywords;

	/**
	 * Source token for {@see self::$searchKeywords}.
	 */
	private ?string $searchKeywordsSource;

	/**
	 * Snapshot schema version per §4.2.
	 */
	private int $snapshotVersion;

	/**
	 * Sync-state axis value per §4.2 and ADR-0006. Allowed values are
	 * `fresh` / `stale` / `expired` / `broken`; this DTO does not
	 * enforce the set (see file-level docblock).
	 */
	private string $syncState;

	/**
	 * Lifecycle-state axis value per §4.2. Allowed values are
	 * `active` / `archived` / `deleted_pending`; this DTO does not
	 * enforce the set.
	 */
	private string $lifecycleState;

	/**
	 * Timestamp of the most recent `lifecycle_state='archived'`
	 * transition, or null when the record is not archived.
	 */
	private ?DateTimeImmutable $archivedAt;

	/**
	 * Most recent successful sync timestamp. Nullable per §4.2.
	 */
	private ?DateTimeImmutable $lastSyncedAt;

	/**
	 * Most recent rendering timestamp. Nullable per §4.2.
	 */
	private ?DateTimeImmutable $lastRenderedAt;

	/**
	 * Most recent sync attempt timestamp regardless of outcome.
	 */
	private ?DateTimeImmutable $lastSyncAttemptedAt;

	/**
	 * Last sync error code. Nullable per §4.2.
	 */
	private ?string $lastSyncErrorCode;

	/**
	 * Last sync error message summary. Nullable per §4.2.
	 */
	private ?string $lastSyncErrorMessage;

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

	/**
	 * Row update timestamp. Required per §4.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                 $provider             Provider identifier.
	 * @param string                 $providerItemId       Provider-side item identifier.
	 * @param string                 $canonicalHash        Normalized hash per §4.4.
	 * @param string                 $title                Product title.
	 * @param string|null            $brand                Brand, nullable.
	 * @param string|null            $description          Normalized description, nullable.
	 * @param string|null            $imagePrimaryUrl      Primary image URL, nullable.
	 * @param string|null            $imageGalleryJson     Image gallery JSON string, nullable.
	 * @param string|null            $priceAmount          Decimal price as string, nullable.
	 * @param string|null            $priceCurrency        ISO-4217 currency, nullable.
	 * @param string|null            $priceLabel           Display-oriented price label, nullable.
	 * @param string|null            $availabilityStatus   Availability string, nullable.
	 * @param string                 $detailUrl            Canonical detail URL.
	 * @param string|null            $affiliateUrl         Affiliate URL, nullable.
	 * @param string|null            $rawPayloadJson       Raw provider payload JSON, nullable.
	 * @param int                    $snapshotVersion      Snapshot schema version.
	 * @param string                 $syncState            Sync-state axis value.
	 * @param string                 $lifecycleState       Lifecycle-state axis value.
	 * @param DateTimeImmutable|null $archivedAt           Archive transition timestamp, nullable.
	 * @param DateTimeImmutable|null $lastSyncedAt         Last successful sync, nullable.
	 * @param DateTimeImmutable|null $lastRenderedAt       Last render, nullable.
	 * @param DateTimeImmutable|null $lastSyncAttemptedAt  Last sync attempt, nullable.
	 * @param string|null            $lastSyncErrorCode    Last sync error code, nullable.
	 * @param string|null            $lastSyncErrorMessage Last sync error message, nullable.
	 * @param DateTimeImmutable      $createdAt            Row creation timestamp.
	 * @param DateTimeImmutable      $updatedAt            Row update timestamp.
	 * @param string|null            $searchKeywords       Cross-provider search keywords.
	 * @param string|null            $searchKeywordsSource Search-keyword source token.
	 *
	 * @throws InvalidArgumentException When a non-nullable string is empty,
	 *                                  `snapshotVersion` is negative, or
	 *                                  `id` is non-positive.
	 */
	public function __construct(
		?int $id,
		string $provider,
		string $providerItemId,
		string $canonicalHash,
		string $title,
		?string $brand,
		?string $description,
		?string $imagePrimaryUrl,
		?string $imageGalleryJson,
		?string $priceAmount,
		?string $priceCurrency,
		?string $priceLabel,
		?string $availabilityStatus,
		string $detailUrl,
		?string $affiliateUrl,
		?string $rawPayloadJson,
		int $snapshotVersion,
		string $syncState,
		string $lifecycleState,
		?DateTimeImmutable $archivedAt,
		?DateTimeImmutable $lastSyncedAt,
		?DateTimeImmutable $lastRenderedAt,
		?DateTimeImmutable $lastSyncAttemptedAt,
		?string $lastSyncErrorCode,
		?string $lastSyncErrorMessage,
		DateTimeImmutable $createdAt,
		DateTimeImmutable $updatedAt,
		?string $searchKeywords = null,
		?string $searchKeywordsSource = null
	) {
		if ( $id !== null && $id <= 0 ) {
			$message = sprintf( 'ProductRecord 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 );
		}

		$this->assertNonEmpty( 'provider', $provider );
		$this->assertNonEmpty( 'providerItemId', $providerItemId );
		$this->assertNonEmpty( 'canonicalHash', $canonicalHash );
		$this->assertNonEmpty( 'title', $title );
		$this->assertNonEmpty( 'detailUrl', $detailUrl );
		$this->assertNonEmpty( 'syncState', $syncState );
		$this->assertNonEmpty( 'lifecycleState', $lifecycleState );

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

		$this->id                   = $id;
		$this->provider             = $provider;
		$this->providerItemId       = $providerItemId;
		$this->canonicalHash        = $canonicalHash;
		$this->title                = $title;
		$this->brand                = $brand;
		$this->description          = $description;
		$this->imagePrimaryUrl      = $imagePrimaryUrl;
		$this->imageGalleryJson     = $imageGalleryJson;
		$this->priceAmount          = $priceAmount;
		$this->priceCurrency        = $priceCurrency;
		$this->priceLabel           = $priceLabel;
		$this->availabilityStatus   = $availabilityStatus;
		$this->detailUrl            = $detailUrl;
		$this->affiliateUrl         = $affiliateUrl;
		$this->rawPayloadJson       = $rawPayloadJson;
		$this->searchKeywords       = self::normalizeOptionalString( $searchKeywords );
		$this->searchKeywordsSource = self::normalizeOptionalString( $searchKeywordsSource );
		$this->snapshotVersion      = $snapshotVersion;
		$this->syncState            = $syncState;
		$this->lifecycleState       = $lifecycleState;
		$this->archivedAt           = self::normalizeToUtc( $archivedAt );
		$this->lastSyncedAt         = self::normalizeToUtc( $lastSyncedAt );
		$this->lastRenderedAt       = self::normalizeToUtc( $lastRenderedAt );
		$this->lastSyncAttemptedAt  = self::normalizeToUtc( $lastSyncAttemptedAt );
		$this->lastSyncErrorCode    = $lastSyncErrorCode;
		$this->lastSyncErrorMessage = $lastSyncErrorMessage;
		$this->createdAt            = self::normalizeToUtc( $createdAt ) ?? $createdAt;
		$this->updatedAt            = self::normalizeToUtc( $updatedAt ) ?? $updatedAt;
	}

	/**
	 * Ergonomic named constructor for fresh (unpersisted) records.
	 *
	 * Supplies `snapshot_version = 1`, `sync_state = 'fresh'`, and
	 * `lifecycle_state = 'active'` per §4.2 defaults. Timestamps
	 * default to the injected "now" value so tests can supply a
	 * deterministic clock without having to spell out `created_at`
	 * and `updated_at` separately.
	 *
	 * @param string               $provider            Provider identifier.
	 * @param string               $providerItemId      Provider-side item identifier.
	 * @param string               $canonicalHash       Normalized hash per §4.4.
	 * @param string               $title               Product title.
	 * @param string               $detailUrl           Canonical detail URL.
	 * @param DateTimeImmutable    $now                 Current UTC timestamp.
	 * @param array<string, mixed> $optional            Optional column overrides keyed by property name.
	 */
	public static function createNew(
		string $provider,
		string $providerItemId,
		string $canonicalHash,
		string $title,
		string $detailUrl,
		DateTimeImmutable $now,
		array $optional = array()
	): self {
		/**
		 * Helper: pull a nullable string out of the optional overrides.
		 *
		 * @param string $key Override key.
		 */
		$stringOrNull = static function ( string $key ) use ( $optional ): ?string {
			if ( ! array_key_exists( $key, $optional ) ) {
				return null;
			}
			$value = $optional[ $key ];
			return $value === null ? null : (string) $value;
		};

		/**
		 * Helper: pull a nullable DateTimeImmutable out of the overrides.
		 *
		 * @param string $key Override key.
		 */
		$dateOrNull = static function ( string $key ) use ( $optional ): ?DateTimeImmutable {
			if ( ! array_key_exists( $key, $optional ) ) {
				return null;
			}
			$value = $optional[ $key ];
			if ( $value === null ) {
				return null;
			}
			if ( ! $value instanceof DateTimeImmutable ) {
				$message = sprintf( 'ProductRecord override "%s" must be DateTimeImmutable or null.', $key );
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			return $value;
		};

		$snapshotVersion = array_key_exists( 'snapshotVersion', $optional )
			? (int) $optional['snapshotVersion']
			: self::DEFAULT_SNAPSHOT_VERSION;
		$syncState       = array_key_exists( 'syncState', $optional )
			? (string) $optional['syncState']
			: self::DEFAULT_SYNC_STATE;
		$lifecycleState  = array_key_exists( 'lifecycleState', $optional )
			? (string) $optional['lifecycleState']
			: self::DEFAULT_LIFECYCLE_STATE;

		return new self(
			null,
			$provider,
			$providerItemId,
			$canonicalHash,
			$title,
			$stringOrNull( 'brand' ),
			$stringOrNull( 'description' ),
			$stringOrNull( 'imagePrimaryUrl' ),
			$stringOrNull( 'imageGalleryJson' ),
			$stringOrNull( 'priceAmount' ),
			$stringOrNull( 'priceCurrency' ),
			$stringOrNull( 'priceLabel' ),
			$stringOrNull( 'availabilityStatus' ),
			$detailUrl,
			$stringOrNull( 'affiliateUrl' ),
			$stringOrNull( 'rawPayloadJson' ),
			$snapshotVersion,
			$syncState,
			$lifecycleState,
			$dateOrNull( 'archivedAt' ),
			$dateOrNull( 'lastSyncedAt' ),
			$dateOrNull( 'lastRenderedAt' ),
			$dateOrNull( 'lastSyncAttemptedAt' ),
			$stringOrNull( 'lastSyncErrorCode' ),
			$stringOrNull( 'lastSyncErrorMessage' ),
			$now,
			$now,
			$stringOrNull( 'searchKeywords' ),
			$stringOrNull( 'searchKeywordsSource' )
		);
	}

	/**
	 * Reconstitute a record from a row-shaped associative array.
	 *
	 * Intended as the single decode seam used by
	 * `WpdbProductRepository` 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( 'ProductRecord::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 ];
		};

		$requireDate = static function ( string $key ) use ( $row ): DateTimeImmutable {
			if ( ! array_key_exists( $key, $row ) || $row[ $key ] === null ) {
				$message = sprintf( 'ProductRecord::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;

		$snapshotVersionRaw = $row['snapshot_version'] ?? $row['snapshotVersion'] ?? self::DEFAULT_SNAPSHOT_VERSION;

		return new self(
			$id,
			$requireString( 'provider' ),
			$requireString( 'provider_item_id' ),
			$requireString( 'canonical_hash' ),
			$requireString( 'title' ),
			$optionalString( 'brand' ),
			$optionalString( 'description' ),
			$optionalString( 'image_primary_url' ),
			$optionalString( 'image_gallery_json' ),
			$optionalString( 'price_amount' ),
			$optionalString( 'price_currency' ),
			$optionalString( 'price_label' ),
			$optionalString( 'availability_status' ),
			$requireString( 'detail_url' ),
			$optionalString( 'affiliate_url' ),
			$optionalString( 'raw_payload_json' ),
			(int) $snapshotVersionRaw,
			$requireString( 'sync_state' ),
			$requireString( 'lifecycle_state' ),
			$optionalDate( 'archived_at' ),
			$optionalDate( 'last_synced_at' ),
			$optionalDate( 'last_rendered_at' ),
			$optionalDate( 'last_sync_attempted_at' ),
			$optionalString( 'last_sync_error_code' ),
			$optionalString( 'last_sync_error_message' ),
			$requireDate( 'created_at' ),
			$requireDate( 'updated_at' ),
			$optionalString( 'search_keywords' ),
			$optionalString( 'search_keywords_source' )
		);
	}

	/**
	 * Encode the record as a row-shaped associative array keyed by the
	 * canonical snake_case column names from §4.2.
	 *
	 * Used by `WpdbProductRepository::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,
			'provider'                => $this->provider,
			'provider_item_id'        => $this->providerItemId,
			'canonical_hash'          => $this->canonicalHash,
			'title'                   => $this->title,
			'brand'                   => $this->brand,
			'description'             => $this->description,
			'image_primary_url'       => $this->imagePrimaryUrl,
			'image_gallery_json'      => $this->imageGalleryJson,
			'price_amount'            => $this->priceAmount,
			'price_currency'          => $this->priceCurrency,
			'price_label'             => $this->priceLabel,
			'availability_status'     => $this->availabilityStatus,
			'detail_url'              => $this->detailUrl,
			'affiliate_url'           => $this->affiliateUrl,
			'raw_payload_json'        => $this->rawPayloadJson,
			'search_keywords'         => $this->searchKeywords,
			'search_keywords_source'  => $this->searchKeywordsSource,
			'snapshot_version'        => $this->snapshotVersion,
			'sync_state'              => $this->syncState,
			'lifecycle_state'         => $this->lifecycleState,
			'archived_at'             => self::formatDateTime( $this->archivedAt ),
			'last_synced_at'          => self::formatDateTime( $this->lastSyncedAt ),
			'last_rendered_at'        => self::formatDateTime( $this->lastRenderedAt ),
			'last_sync_attempted_at'  => self::formatDateTime( $this->lastSyncAttemptedAt ),
			'last_sync_error_code'    => $this->lastSyncErrorCode,
			'last_sync_error_message' => $this->lastSyncErrorMessage,
			'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( 'ProductRecord::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;
	}

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

	/**
	 * Provider identifier (for example `"rakuten"`).
	 */
	public function provider(): string {
		return $this->provider;
	}

	/**
	 * Provider-side item identifier.
	 */
	public function providerItemId(): string {
		return $this->providerItemId;
	}

	/**
	 * Canonical hash of the normalized snapshot payload.
	 */
	public function canonicalHash(): string {
		return $this->canonicalHash;
	}

	/**
	 * Human-readable product title.
	 */
	public function title(): string {
		return $this->title;
	}

	/**
	 * Brand name, when the provider supplies one.
	 */
	public function brand(): ?string {
		return $this->brand;
	}

	/**
	 * Long-form product description, when available.
	 */
	public function description(): ?string {
		return $this->description;
	}

	/**
	 * URL of the primary product image, when available.
	 */
	public function imagePrimaryUrl(): ?string {
		return $this->imagePrimaryUrl;
	}

	/**
	 * JSON-encoded secondary image gallery, when available.
	 */
	public function imageGalleryJson(): ?string {
		return $this->imageGalleryJson;
	}

	/**
	 * Price amount as a fixed-precision decimal string, when available.
	 */
	public function priceAmount(): ?string {
		return $this->priceAmount;
	}

	/**
	 * ISO currency code for {@see self::priceAmount()}, when available.
	 */
	public function priceCurrency(): ?string {
		return $this->priceCurrency;
	}

	/**
	 * Display label for the price (for example `"From"`), when available.
	 */
	public function priceLabel(): ?string {
		return $this->priceLabel;
	}

	/**
	 * Availability status string from the provider, when available.
	 */
	public function availabilityStatus(): ?string {
		return $this->availabilityStatus;
	}

	/**
	 * Canonical detail page URL on the provider.
	 */
	public function detailUrl(): string {
		return $this->detailUrl;
	}

	/**
	 * Affiliate-rewritten detail URL, when available.
	 */
	public function affiliateUrl(): ?string {
		return $this->affiliateUrl;
	}

	/**
	 * JSON-encoded raw provider payload, when retained.
	 */
	public function rawPayloadJson(): ?string {
		return $this->rawPayloadJson;
	}

	/**
	 * Cross-provider search keywords for secondary provider buttons.
	 */
	public function searchKeywords(): ?string {
		return $this->searchKeywords;
	}

	/**
	 * Source token for {@see self::searchKeywords()}.
	 */
	public function searchKeywordsSource(): ?string {
		return $this->searchKeywordsSource;
	}

	/**
	 * Snapshot schema version this record was captured under.
	 */
	public function snapshotVersion(): int {
		return $this->snapshotVersion;
	}

	/**
	 * Sync state of the record (see `database-schema.md` §4.2).
	 */
	public function syncState(): string {
		return $this->syncState;
	}

	/**
	 * Lifecycle state of the record (see `database-schema.md` §4.2).
	 */
	public function lifecycleState(): string {
		return $this->lifecycleState;
	}

	/**
	 * UTC timestamp at which the record was archived, or null.
	 */
	public function archivedAt(): ?DateTimeImmutable {
		return $this->archivedAt;
	}

	/**
	 * UTC timestamp of the last successful sync, or null.
	 */
	public function lastSyncedAt(): ?DateTimeImmutable {
		return $this->lastSyncedAt;
	}

	/**
	 * UTC timestamp of the last successful render, or null.
	 */
	public function lastRenderedAt(): ?DateTimeImmutable {
		return $this->lastRenderedAt;
	}

	/**
	 * UTC timestamp of the last sync attempt, or null.
	 */
	public function lastSyncAttemptedAt(): ?DateTimeImmutable {
		return $this->lastSyncAttemptedAt;
	}

	/**
	 * Short error code from the last failed sync attempt, or null.
	 */
	public function lastSyncErrorCode(): ?string {
		return $this->lastSyncErrorCode;
	}

	/**
	 * Error message from the last failed sync attempt, or null.
	 */
	public function lastSyncErrorMessage(): ?string {
		return $this->lastSyncErrorMessage;
	}

	/**
	 * 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(
			'ProductRecord::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' ) );
	}

	/**
	 * Normalize an optional string column.
	 *
	 * @param string|null $value Value to normalize.
	 */
	private static function normalizeOptionalString( ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$trimmed = trim( $value );
		return $trimmed === '' ? null : $trimmed;
	}

	/**
	 * 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( 'ProductRecord 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 );
		}
	}
}
