<?php
/**
 * Product snapshot value object.
 *
 * Immutable value object that represents the normalized snapshot a
 * provider returned for a single external product at a specific
 * point in time. The type collects every field the
 * `specifications/provider-api-contracts.md` §7.4 contract enumerates
 * so consumers — REST responses, render pipelines, sync diagnostics —
 * can present the product faithfully without having to recombine
 * sibling objects.
 *
 * Field semantics match `provider-api-contracts.md` §7.4 exactly:
 *
 *   - `provider` is the stable provider key from §4 (lowercase ASCII,
 *     no whitespace). It MUST equal {@see ProviderProductId::provider()}
 *     on the supplied {@see self::providerProductId()}; the
 *     constructor enforces this so a programming error that mixes
 *     ids across providers (the §7.3 "provider 不一致" footgun)
 *     surfaces before the database boundary.
 *
 *   - `providerProductId` is the canonical
 *     {@see ProviderProductId} pair identifying the external product
 *     across the AFFILUN domain. Required.
 *
 *   - `title` is the product's human-readable title as the provider
 *     supplied it. Required per §7.4 ("`title` は必須") and rejected
 *     if empty after trimming.
 *
 *   - `brand` is the manufacturer / shop name when the provider
 *     supplies one. Nullable per §7.4 (some providers — notably
 *     Yahoo!ショッピング on certain item shapes — do not always
 *     surface a brand axis).
 *
 *   - `description` is the long-form product description in the
 *     provider's native language. Nullable; the rendering layer is
 *     responsible for any sanitization / truncation for display.
 *
 *   - `detailUrl` is the canonical product detail URL on the
 *     provider's own site, suitable for the "詳細を見る" affordance.
 *     Required per §7.4 ("`detailUrl` は必須") and validated as a
 *     non-empty `http://` or `https://` URL.
 *
 *   - `affiliateUrl` is the affiliate-tracked URL for the same
 *     product when the provider has minted one. Per §7.4
 *     ("`affiliateUrl` は build 時点で未設定でもよい") this field
 *     may be null at construction time and populated later by the
 *     application layer once an {@see \Affilun\Domain\Affiliate\AffiliateLinkResult}
 *     has been built.
 *
 *   - `primaryImage` is the main product image URL when the
 *     provider supplied one. Nullable per §7.4. The rendering layer
 *     falls back to a AFFILUN-canonical placeholder when null.
 *
 *   - `galleryImages` is the additional-images list. Empty by
 *     default per §7.4 ("`galleryImages` は空配列可"). Each entry
 *     is validated as a non-empty `http(s)://` URL on construction;
 *     the list is rejected when any entry is not a string or fails
 *     validation. The primary image is NOT duplicated into the
 *     gallery list automatically; callers that want both displays
 *     are responsible for the duplication.
 *
 *   - `price` is the {@see PriceInfo} snapshot. Nullable per §7.4
 *     because some provider responses elide price data (region-
 *     restricted items, third-party seller items without a
 *     listed price).
 *
 *   - `availability` is the {@see AvailabilityInfo} snapshot. NOT
 *     nullable on this DTO: §7.6 already provides
 *     {@see AvailabilityInfo::STATUS_UNKNOWN} as the canonical
 *     "no signal" fallback, so an absent availability is encoded
 *     as `AvailabilityInfo::unknown()` rather than as null.
 *
 *   - `rating` is the average review score the provider reported,
 *     as a decimal string in the same canonical-string form that
 *     {@see PriceInfo::amount} uses. Nullable. Validated as a
 *     finite non-negative decimal string; the upper bound (5.0
 *     for Amazon, 5.0 for 楽天, 5.0 for Yahoo!ショッピング) is
 *     deliberately NOT enforced here because providers occasionally
 *     return scores on a 10-point scale and this DTO must not lose
 *     information by clamping at this layer.
 *
 *   - `reviewCount` is the number of reviews the provider reported.
 *     Nullable. Validated as a non-negative integer; a `null`
 *     `rating` paired with a positive `reviewCount` is a legitimate
 *     state (the provider may report a count without an aggregated
 *     score for new products with too few reviews).
 *
 *   - `categories` is the provider's category-path entries
 *     ("Books > Computing > Internet"), normalized to a flat list
 *     of leaf-most-first strings. Empty by default. Each entry is
 *     validated as a non-empty trimmed string; the list is
 *     rejected when any entry is not a string or is empty after
 *     trimming.
 *
 *   - `attributes` is the provider's structured attribute map
 *     (color, size, material, ...). Held as an associative array
 *     keyed by canonical attribute name. Validated to ensure every
 *     key is a non-empty string and every value is a scalar or
 *     null; nested arrays are rejected because §7.4 expects a flat
 *     attribute surface for the rendering layer.
 *
 *   - `fetchedAt` is the UTC timestamp at which the provider
 *     returned this snapshot. Required. Used by the sync layer to
 *     detect stale snapshots and by the render layer to decide
 *     whether to display a "as of {date}" caption.
 *
 *   - `rawMeta` is the provider-native metadata blob preserved for
 *     debugging and provider-specific extensions. Held as an
 *     associative array; per §7.4 ("UI は直接依存しない") consuming
 *     surfaces MUST NOT branch on its contents. Validated only to
 *     reject non-string keys.
 *
 * Instances are immutable. Two mutation helpers are provided because
 * they map to distinct, frequent application-layer flows:
 *
 *   - {@see self::withAffiliateUrl()} populates the `affiliateUrl`
 *     field after the affiliate-link builder has produced one.
 *     This matches the §7.4 "build 時点で未設定でもよい" allowance.
 *
 *   - {@see self::withFetchedAt()} produces a re-stamped copy when
 *     the same payload is observed again from a cache layer; useful
 *     for tests and for the sync layer's "touch" path that wants to
 *     extend a snapshot's freshness window without re-fetching.
 *
 * Other fields have no in-place edit path because a meaningful change
 * (price, availability, title) should be modeled as a fresh
 * {@see ProductSnapshot} carrying its own `fetchedAt`, not as an
 * in-place patch.
 *
 * @package Affilun\Domain\Product
 */

declare(strict_types=1);

namespace Affilun\Domain\Product;

use Affilun\Domain\Pricing\AvailabilityInfo;
use Affilun\Domain\Pricing\PriceInfo;
use Affilun\Domain\Provider\ProviderProductId;
use DateTimeImmutable;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Immutable normalized snapshot of a single external product.
 *
 * See the file-level docblock for the mapping to
 * `provider-api-contracts.md` §7.4, the cross-provider field
 * conventions, and the rationale for the two `with*()` mutation
 * helpers exposed here.
 */
final class ProductSnapshot {

	/**
	 * Stable provider key per §4. Held redundantly alongside
	 * {@see self::$providerProductId} so consumers that only need
	 * the key (REST list responses, log lines) do not have to
	 * unwrap the identifier object.
	 */
	private string $provider;

	/**
	 * Canonical `(provider, value)` identifier for the external
	 * product per §7.3.
	 */
	private ProviderProductId $providerProductId;

	/**
	 * Human-readable product title per §7.4. Required.
	 */
	private string $title;

	/**
	 * Brand / manufacturer / shop name, or null when the provider
	 * did not surface one.
	 */
	private ?string $brand;

	/**
	 * Long-form product description, or null.
	 */
	private ?string $description;

	/**
	 * Canonical product detail URL on the provider's own site.
	 * Required and validated as `http(s)://...`.
	 */
	private string $detailUrl;

	/**
	 * Affiliate-tracked URL, or null when the affiliate link has
	 * not been minted yet (per §7.4 build-time allowance).
	 */
	private ?string $affiliateUrl;

	/**
	 * Primary product image URL, or null.
	 */
	private ?string $primaryImage;

	/**
	 * Additional product image URLs.
	 *
	 * @var list<string>
	 */
	private array $galleryImages;

	/**
	 * Price snapshot, or null when the provider response elided
	 * price data.
	 */
	private ?PriceInfo $price;

	/**
	 * Availability snapshot. Required; absent signals are encoded
	 * as {@see AvailabilityInfo::unknown()} rather than null.
	 */
	private AvailabilityInfo $availability;

	/**
	 * Average review score as a non-negative decimal string, or
	 * null when the provider did not report one.
	 */
	private ?string $rating;

	/**
	 * Number of reviews the provider reported, or null.
	 */
	private ?int $reviewCount;

	/**
	 * Category-path entries in leaf-most-first order.
	 *
	 * @var list<string>
	 */
	private array $categories;

	/**
	 * Structured attribute map keyed by canonical attribute name.
	 *
	 * @var array<string, scalar|null>
	 */
	private array $attributes;

	/**
	 * UTC timestamp at which the provider returned this snapshot.
	 */
	private DateTimeImmutable $fetchedAt;

	/**
	 * Provider-native metadata blob preserved for debugging.
	 *
	 * @var array<string, mixed>
	 */
	private array $rawMeta;

	/**
	 * Construct a snapshot with explicit values for every field.
	 *
	 * The constructor is exhaustive so a programming error (missing
	 * required field on a write, swapped detail / affiliate URL)
	 * surfaces at the type system boundary rather than at the
	 * rendering layer. Named-argument construction is the ergonomic
	 * entry point for most call sites.
	 *
	 * Validation summary:
	 *
	 *   - `provider` is trimmed and matched against
	 *     `providerProductId->provider()`; mismatched keys raise
	 *     {@see InvalidArgumentException} per §7.3.
	 *   - `title` and `detailUrl` are required and rejected when
	 *     empty after trimming. `detailUrl` is additionally
	 *     validated as a `http(s)://...` URL.
	 *   - `affiliateUrl` and `primaryImage`, when non-null, are
	 *     trimmed and validated as `http(s)://...` URLs.
	 *   - `galleryImages` and `categories` reject any non-string
	 *     entry and any entry that is empty after trimming;
	 *     `galleryImages` entries are additionally URL-validated.
	 *   - `rating`, when non-null, is validated as a non-negative
	 *     decimal string in the same grammar `PriceInfo::amount`
	 *     accepts.
	 *   - `reviewCount`, when non-null, is rejected for negative
	 *     values.
	 *   - `attributes` rejects non-string keys and rejects values
	 *     that are not scalar or null.
	 *   - `rawMeta` rejects non-string keys.
	 *   - `fetchedAt` is normalized to UTC on storage.
	 *
	 * @param string                     $provider          Stable provider key.
	 * @param ProviderProductId          $providerProductId Canonical id.
	 * @param string                     $title             Product title.
	 * @param string|null                $brand             Brand / shop, or null.
	 * @param string|null                $description       Long description, or null.
	 * @param string                     $detailUrl         Provider detail URL.
	 * @param string|null                $affiliateUrl      Affiliate URL, or null.
	 * @param string|null                $primaryImage      Primary image URL, or null.
	 * @param list<string>               $galleryImages     Gallery URLs.
	 * @param PriceInfo|null             $price             Price snapshot, or null.
	 * @param AvailabilityInfo           $availability      Availability snapshot.
	 * @param string|null                $rating            Average review score.
	 * @param int|null                   $reviewCount       Number of reviews.
	 * @param list<string>               $categories        Category path entries.
	 * @param array<string, scalar|null> $attributes    Structured attributes.
	 * @param DateTimeImmutable          $fetchedAt         Observation timestamp.
	 * @param array<string, mixed>       $rawMeta           Raw provider metadata.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  §7.4 contract or the
	 *                                  cross-provider validation
	 *                                  rules summarized above.
	 */
	public function __construct(
		string $provider,
		ProviderProductId $providerProductId,
		string $title,
		?string $brand,
		?string $description,
		string $detailUrl,
		?string $affiliateUrl,
		?string $primaryImage,
		array $galleryImages,
		?PriceInfo $price,
		AvailabilityInfo $availability,
		?string $rating,
		?int $reviewCount,
		array $categories,
		array $attributes,
		DateTimeImmutable $fetchedAt,
		array $rawMeta
	) {
		$normalizedProvider = trim( $provider );
		if ( $normalizedProvider === '' ) {
			$message = 'ProductSnapshot requires a non-empty provider key.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! $providerProductId->belongsTo( $normalizedProvider ) ) {
			$message = sprintf(
				'ProductSnapshot provider key "%s" does not match providerProductId provider "%s"; '
				. 'see provider-api-contracts.md §7.3.',
				$normalizedProvider,
				$providerProductId->provider()
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->provider          = $normalizedProvider;
		$this->providerProductId = $providerProductId;
		$this->title             = self::normalizeRequiredString( 'title', $title );
		$this->brand             = self::normalizeOptionalString( 'brand', $brand );
		$this->description       = self::normalizeOptionalString( 'description', $description );
		$this->detailUrl         = self::normalizeRequiredUrl( 'detailUrl', $detailUrl );
		$this->affiliateUrl      = self::normalizeOptionalUrl( 'affiliateUrl', $affiliateUrl );
		$this->primaryImage      = self::normalizeOptionalUrl( 'primaryImage', $primaryImage );
		$this->galleryImages     = self::normalizeGalleryImages( $galleryImages );
		$this->price             = $price;
		$this->availability      = $availability;
		$this->rating            = self::normalizeRating( $rating );
		$this->reviewCount       = self::normalizeReviewCount( $reviewCount );
		$this->categories        = self::normalizeCategories( $categories );
		$this->attributes        = self::normalizeAttributes( $attributes );
		$this->fetchedAt         = self::normalizeToUtc( $fetchedAt );
		$this->rawMeta           = self::normalizeRawMeta( $rawMeta );
	}

	/**
	 * Stable provider key per §4.
	 */
	public function provider(): string {
		return $this->provider;
	}

	/**
	 * Canonical `(provider, value)` identifier per §7.3.
	 */
	public function providerProductId(): ProviderProductId {
		return $this->providerProductId;
	}

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

	/**
	 * Brand / shop name, or null.
	 */
	public function brand(): ?string {
		return $this->brand;
	}

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

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

	/**
	 * Affiliate-tracked URL, or null when not yet minted.
	 */
	public function affiliateUrl(): ?string {
		return $this->affiliateUrl;
	}

	/**
	 * Primary product image URL, or null.
	 */
	public function primaryImage(): ?string {
		return $this->primaryImage;
	}

	/**
	 * Additional product image URLs.
	 *
	 * @return list<string>
	 */
	public function galleryImages(): array {
		return $this->galleryImages;
	}

	/**
	 * Price snapshot, or null.
	 */
	public function price(): ?PriceInfo {
		return $this->price;
	}

	/**
	 * Availability snapshot.
	 */
	public function availability(): AvailabilityInfo {
		return $this->availability;
	}

	/**
	 * Average review score as a decimal string, or null.
	 */
	public function rating(): ?string {
		return $this->rating;
	}

	/**
	 * Number of reviews, or null.
	 */
	public function reviewCount(): ?int {
		return $this->reviewCount;
	}

	/**
	 * Category-path entries in leaf-most-first order.
	 *
	 * @return list<string>
	 */
	public function categories(): array {
		return $this->categories;
	}

	/**
	 * Structured attribute map.
	 *
	 * @return array<string, scalar|null>
	 */
	public function attributes(): array {
		return $this->attributes;
	}

	/**
	 * UTC observation timestamp.
	 */
	public function fetchedAt(): DateTimeImmutable {
		return $this->fetchedAt;
	}

	/**
	 * Provider-native metadata blob.
	 *
	 * Per §7.4 the rendering layer MUST NOT branch on this map's
	 * contents; it is preserved purely for diagnostics and for
	 * provider-specific extensions outside the cross-provider
	 * contract.
	 *
	 * @return array<string, mixed>
	 */
	public function rawMeta(): array {
		return $this->rawMeta;
	}

	/**
	 * Produce a copy of this snapshot with the affiliate URL set.
	 *
	 * Matches the §7.4 "build 時点で未設定でもよい" allowance: the
	 * affiliate-link builder constructs the URL after the snapshot
	 * is in hand, and this helper threads the result back into the
	 * snapshot without re-validating every other field.
	 *
	 * Passing null clears the affiliate URL — useful for the sync
	 * layer's "rebuild affiliate link" path that wants to surface a
	 * snapshot whose affiliate URL has been invalidated.
	 *
	 * @param string|null $affiliateUrl New affiliate URL, or null
	 *                                  to clear.
	 *
	 * @throws InvalidArgumentException When `$affiliateUrl` is a
	 *                                  non-null string that fails
	 *                                  the URL validator.
	 */
	public function withAffiliateUrl( ?string $affiliateUrl ): self {
		$clone               = clone $this;
		$clone->affiliateUrl = self::normalizeOptionalUrl( 'affiliateUrl', $affiliateUrl );
		return $clone;
	}

	/**
	 * Produce a copy of this snapshot with `fetchedAt` advanced.
	 *
	 * Used by the sync layer's "touch" path that wants to extend a
	 * snapshot's freshness window without re-fetching the provider.
	 *
	 * @param DateTimeImmutable $fetchedAt New observation timestamp;
	 *                                     normalized to UTC on
	 *                                     storage.
	 */
	public function withFetchedAt( DateTimeImmutable $fetchedAt ): self {
		$clone            = clone $this;
		$clone->fetchedAt = self::normalizeToUtc( $fetchedAt );
		return $clone;
	}

	/**
	 * Validate and normalize a required string field.
	 *
	 * @param string $field Field name for the error message.
	 * @param string $value Raw value.
	 *
	 * @throws InvalidArgumentException When `$value` is empty after trimming.
	 */
	private static function normalizeRequiredString( string $field, string $value ): string {
		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'ProductSnapshot %s must be a non-empty string.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize a nullable string field.
	 *
	 * @param string      $field Field name for the error message.
	 * @param string|null $value Raw value.
	 *
	 * @throws InvalidArgumentException When `$value` is the empty
	 *                                  string after trimming.
	 */
	private static function normalizeOptionalString( string $field, ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'ProductSnapshot %s must be null or a non-empty string.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate a required `http(s)://` URL.
	 *
	 * @param string $field Field name for the error message.
	 * @param string $value Raw URL.
	 *
	 * @throws InvalidArgumentException When `$value` is empty or
	 *                                  not a syntactically-valid
	 *                                  `http(s)://` URL.
	 */
	private static function normalizeRequiredUrl( string $field, string $value ): string {
		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'ProductSnapshot %s must be a non-empty URL.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		self::assertHttpUrl( $field, $trimmed );

		return $trimmed;
	}

	/**
	 * Validate an optional `http(s)://` URL.
	 *
	 * @param string      $field Field name for the error message.
	 * @param string|null $value Raw URL.
	 *
	 * @throws InvalidArgumentException When `$value` is the empty
	 *                                  string after trimming or is
	 *                                  not a syntactically-valid
	 *                                  `http(s)://` URL.
	 */
	private static function normalizeOptionalUrl( string $field, ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'ProductSnapshot %s must be null or a non-empty URL.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		self::assertHttpUrl( $field, $trimmed );

		return $trimmed;
	}

	/**
	 * Assert that `$value` is a syntactically-valid `http(s)://`
	 * URL, raising {@see InvalidArgumentException} otherwise.
	 *
	 * Uses PHP's `filter_var()` because writing a URL grammar by
	 * hand is famously brittle; the additional scheme guard rejects
	 * `ftp://`, `file://`, `data:`, and `javascript:` even when
	 * they pass the underlying validator.
	 *
	 * @param string $field Field name for the error message.
	 * @param string $value Trimmed URL value.
	 *
	 * @throws InvalidArgumentException When `$value` is not a
	 *                                  valid `http(s)://` URL.
	 */
	private static function assertHttpUrl( string $field, string $value ): void {
		if ( filter_var( $value, FILTER_VALIDATE_URL ) === false ) {
			$message = sprintf(
				'ProductSnapshot %s must be a valid http(s):// URL; got "%s".',
				$field,
				$value
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Domain layer is WordPress-independent per coding-standards.md §1.3 B; wp_parse_url() would introduce a forbidden dependency.
		$scheme = strtolower( (string) parse_url( $value, PHP_URL_SCHEME ) );
		if ( $scheme !== 'http' && $scheme !== 'https' ) {
			$message = sprintf(
				'ProductSnapshot %s must use the http or https scheme; got "%s".',
				$field,
				$value
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
	}

	/**
	 * Validate and normalize the gallery-images list.
	 *
	 * @param array<int|string, mixed> $images Raw gallery list.
	 *
	 * @return list<string>
	 *
	 * @throws InvalidArgumentException When any entry is not a
	 *                                  string, is empty after
	 *                                  trimming, or fails URL
	 *                                  validation.
	 */
	private static function normalizeGalleryImages( array $images ): array {
		$normalized = array();
		foreach ( $images as $index => $image ) {
			if ( ! is_string( $image ) ) {
				$message = sprintf(
					'ProductSnapshot galleryImages[%s] must be a string.',
					(string) $index
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[] = self::normalizeRequiredUrl(
				sprintf( 'galleryImages[%s]', (string) $index ),
				$image
			);
		}

		return $normalized;
	}

	/**
	 * Validate and normalize the optional rating field.
	 *
	 * Accepts the same decimal-string grammar `PriceInfo::amount`
	 * accepts. The upper bound is deliberately not enforced here;
	 * see the file-level docblock for the rationale.
	 *
	 * @param string|null $rating Raw rating, or null.
	 *
	 * @throws InvalidArgumentException When `$rating` is not a
	 *                                  non-negative decimal string.
	 */
	private static function normalizeRating( ?string $rating ): ?string {
		if ( $rating === null ) {
			return null;
		}

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

		if ( $trimmed[0] === '+' ) {
			$trimmed = substr( $trimmed, 1 );
		}

		if ( preg_match( '/\A(?:\d+|\d+\.\d+|\.\d+)\z/', $trimmed ) !== 1 ) {
			$message = sprintf(
				'ProductSnapshot rating must be a non-negative decimal string; got "%s".',
				$rating
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate the optional reviewCount field.
	 *
	 * @param int|null $reviewCount Raw review count, or null.
	 *
	 * @throws InvalidArgumentException When `$reviewCount` is negative.
	 */
	private static function normalizeReviewCount( ?int $reviewCount ): ?int {
		if ( $reviewCount === null ) {
			return null;
		}

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

		return $reviewCount;
	}

	/**
	 * Validate and normalize the categories list.
	 *
	 * @param array<int|string, mixed> $categories Raw categories list.
	 *
	 * @return list<string>
	 *
	 * @throws InvalidArgumentException When any entry is not a
	 *                                  string or is empty after
	 *                                  trimming.
	 */
	private static function normalizeCategories( array $categories ): array {
		$normalized = array();
		foreach ( $categories as $index => $category ) {
			if ( ! is_string( $category ) ) {
				$message = sprintf(
					'ProductSnapshot categories[%s] must be a string.',
					(string) $index
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$trimmed = trim( $category );
			if ( $trimmed === '' ) {
				$message = sprintf(
					'ProductSnapshot categories[%s] must be a non-empty string.',
					(string) $index
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[] = $trimmed;
		}

		return $normalized;
	}

	/**
	 * Validate and normalize the attributes map.
	 *
	 * Keys MUST be non-empty strings and values MUST be scalar or
	 * null. Nested arrays / objects are rejected because §7.4
	 * expects a flat attribute surface.
	 *
	 * @param array<int|string, mixed> $attributes Raw attribute map.
	 *
	 * @return array<string, scalar|null>
	 *
	 * @throws InvalidArgumentException When any key is not a
	 *                                  non-empty string or any
	 *                                  value is not scalar / null.
	 */
	private static function normalizeAttributes( array $attributes ): array {
		$normalized = array();
		foreach ( $attributes as $key => $value ) {
			if ( ! is_string( $key ) ) {
				$message = sprintf(
					'ProductSnapshot attributes keys must be strings; got %s.',
					gettype( $key )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$trimmedKey = trim( $key );
			if ( $trimmedKey === '' ) {
				$message = 'ProductSnapshot attributes keys must be non-empty strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( $value !== null && ! is_scalar( $value ) ) {
				$message = sprintf(
					'ProductSnapshot attributes["%s"] must be scalar or null; got %s.',
					$trimmedKey,
					gettype( $value )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[ $trimmedKey ] = $value;
		}

		return $normalized;
	}

	/**
	 * Validate the rawMeta map's keys.
	 *
	 * Values are NOT validated because §7.4 expressly preserves
	 * provider-native data here for downstream debugging; the
	 * rendering layer is forbidden from branching on contents, so
	 * shape constraints would only obstruct legitimate use.
	 *
	 * @param array<int|string, mixed> $rawMeta Raw metadata map.
	 *
	 * @return array<string, mixed>
	 *
	 * @throws InvalidArgumentException When any key is not a string.
	 */
	private static function normalizeRawMeta( array $rawMeta ): array {
		$normalized = array();
		foreach ( $rawMeta as $key => $value ) {
			if ( ! is_string( $key ) ) {
				$message = sprintf(
					'ProductSnapshot rawMeta keys must be strings; got %s.',
					gettype( $key )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[ $key ] = $value;
		}

		return $normalized;
	}

	/**
	 * Normalize a timestamp to UTC so equality checks and
	 * round-trips do not depend on the caller's timezone.
	 *
	 * @param DateTimeImmutable $value Timestamp to normalize.
	 */
	private static function normalizeToUtc( DateTimeImmutable $value ): DateTimeImmutable {
		return $value->setTimezone( new DateTimeZone( 'UTC' ) );
	}
}
