<?php
/**
 * Persisted-record → ProductSnapshot projector.
 *
 * Phase 2 (KI-009) Application-layer utility that projects a
 * persisted {@see ProductRecord} into the canonical
 * {@see ProductSnapshot} value object the §22 rendering pipeline
 * consumes. Centralizes the field-mapping that
 * {@see \CommerceLinkBuilder\Presentation\Block\ProductCardBlockRenderer}
 * and
 * {@see \CommerceLinkBuilder\Presentation\Block\ProductGridBlockRenderer}
 * previously duplicated verbatim, so a future change to the
 * snapshot shape (additional optional fields, richer availability
 * mapping, gallery hydration) lands in exactly one place.
 *
 * Field-mapping rules (mirror of the original duplicated bodies):
 *
 *   - `providerProductId` is composed from the record's provider
 *     key + provider-side item id.
 *
 *   - `price` is non-null only when the record carries a numeric
 *     `priceAmount`. Its `observedAt` falls back to the current
 *     UTC instant when the record has no `lastSyncedAt`. The
 *     `isTaxIncluded` / `disclaimerRequired` flags default to
 *     false and the `disclaimerText` is null because the
 *     persisted row does not carry those nuances; provider-side
 *     hydration is reserved for a follow-up slice.
 *
 *   - `availability` is {@see AvailabilityInfo::unknown()} when
 *     the record has no availability status; otherwise a fresh
 *     {@see AvailabilityInfo} with `isPurchasable = true` because
 *     the persisted row already represents the post-normalization
 *     state — the §13.3 graceful-degradation path uses the
 *     `unknown()` branch above to surface the "we have no idea"
 *     state explicitly rather than encoding it as a boolean.
 *
 *   - `fetchedAt` falls back to the current UTC instant when the
 *     record has no `lastSyncedAt`, matching the original
 *     duplicated behavior so the snapshot always carries a
 *     non-null observation timestamp.
 *
 *   - Image gallery, attributes, badges, disclaimers, and
 *     `extra` are intentionally seeded empty / null. The §9
 *     ProductMetaRepository hydration is a separate slice; this
 *     projector deliberately stays in lock-step with the
 *     persisted-row surface so the editor preview path and the
 *     block render path agree on the snapshot shape.
 *
 * The class is `final` and exposes a single `static project()`
 * method because it carries no state and has no collaborators
 * worth injecting. Callers that want a richer hydration path
 * (with provider gallery, meta, etc.) should compose a
 * dedicated UseCase rather than extending this projector.
 *
 * @package CommerceLinkBuilder\Application\Product
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Product;

use CommerceLinkBuilder\Domain\Pricing\AvailabilityInfo;
use CommerceLinkBuilder\Domain\Pricing\PriceInfo;
use CommerceLinkBuilder\Domain\Product\ProductSnapshot;
use CommerceLinkBuilder\Domain\Provider\ProviderProductId;
use CommerceLinkBuilder\Infrastructure\Persistence\ProductRecord;
use DateTimeImmutable;
use DateTimeZone;

/**
 * Projects a persisted {@see ProductRecord} into the canonical
 * {@see ProductSnapshot} value object consumed by the §22
 * rendering pipeline.
 *
 * See the file-level docblock for the field-mapping rules and
 * the rationale for keeping image / meta hydration out of scope.
 */
final class SnapshotProjector {

	/**
	 * Project the supplied persisted record into a
	 * {@see ProductSnapshot}.
	 *
	 * Pure function: the same input always produces the same
	 * output (modulo the `now()` fallbacks documented on the
	 * file-level docblock, which only fire when the record has
	 * no `lastSyncedAt`).
	 *
	 * @param ProductRecord $record Resolved persisted record.
	 */
	public static function project( ProductRecord $record ): ProductSnapshot {
		$providerProductId = new ProviderProductId(
			$record->provider(),
			$record->providerItemId()
		);

		$price = null;
		if ( $record->priceAmount() !== null ) {
			$observedAt = $record->lastSyncedAt() ?? self::nowUtc();
			$price      = new PriceInfo(
				$record->priceAmount(),
				$record->priceCurrency(),
				$record->priceLabel(),
				$observedAt,
				false,
				false,
				null
			);
		}

		$availabilityStatus = $record->availabilityStatus();
		$availability       = $availabilityStatus === null
			? AvailabilityInfo::unknown()
			: new AvailabilityInfo( $availabilityStatus, null, true );

		$fetchedAt = $record->lastSyncedAt() ?? self::nowUtc();

		return new ProductSnapshot(
			$record->provider(),
			$providerProductId,
			$record->title(),
			$record->brand(),
			$record->description(),
			$record->detailUrl(),
			$record->affiliateUrl(),
			$record->imagePrimaryUrl(),
			array(),
			$price,
			$availability,
			null,
			null,
			array(),
			array(),
			$fetchedAt,
			array()
		);
	}

	/**
	 * Current UTC instant used as the fallback for the
	 * `observedAt` / `fetchedAt` slots when the record carries
	 * no `lastSyncedAt`.
	 *
	 * Held as a private helper so the two fallback sites use a
	 * single derivation and a future Clock-port migration has a
	 * single point of change.
	 */
	private static function nowUtc(): DateTimeImmutable {
		return new DateTimeImmutable( 'now', new DateTimeZone( 'UTC' ) );
	}
}
