<?php
/**
 * REST §6.2 ProductDetail mapper.
 *
 * Pure-function service that converts the
 * {@see \Affilun\Infrastructure\Persistence\ProductRecord}
 * row-shaped DTO into the read-only associative-array shape
 * documented in `specifications/rest-api-spec.md` §6.2
 * ("ProductDetail"). The shape is the canonical contract REST
 * controllers serialize when returning a single product detail
 * under the §4.1 `data` envelope.
 *
 * Design constraints:
 *
 *   - The mapper is a stateless pure function. It never reaches
 *     into infrastructure (no repository, no HTTP client, no global
 *     state); any data that the §6.2 shape requires beyond what
 *     {@see ProductRecord} carries is forwarded by the controller
 *     via the
 *     {@see \Affilun\Presentation\Rest\Mapper\ImageHints}
 *     and
 *     {@see \Affilun\Presentation\Rest\Mapper\UsageHints}
 *     hint VOs. This keeps the mapper unit-testable without booting
 *     WordPress and keeps the controller free to resolve image and
 *     usage metadata however it wants.
 *
 *   - The §6.2 `images` sub-object is constructed entirely from the
 *     {@see ImageHints} VO. When the controller does not pass a
 *     hint, `images.primary` is `null` and `images.gallery` is the
 *     empty list — never absent — so the on-the-wire JSON shape
 *     stays §6.2-compliant regardless of resolution state.
 *
 *   - The §6.2 `availability_status` field falls back to the §7.6
 *     `unknown` token when {@see ProductRecord::availabilityStatus()}
 *     is null. The §6.2 schema does not allow a null
 *     `availability_status`; "unknown" is the canonical sentinel.
 *
 *   - The §6.2 `price.observed_at` field is, in Phase 6, sourced
 *     from {@see ProductRecord::lastSyncedAt()} as a proxy for the
 *     price-observation timestamp. The persistence schema (per
 *     `specifications/database-schema.md` §4.2) does not yet carry
 *     a separate `price_observed_at` column; introducing one is a
 *     Phase-9-or-later schema change. Until then, "the price was
 *     observed when the snapshot was last synced" is the most
 *     truthful approximation a Phase-6 caller can offer.
 *
 *   - The §6.2 `sync.stale_after` field is always `null` in Phase
 *     6. Resolving the staleness deadline requires the retention
 *     policy from settings plus the per-provider freshness window;
 *     that wiring lands in Phase 11 (diagnostics / ops). Until
 *     then, the field is present-but-null so clients see the §6.2
 *     key set verbatim and can render a "stale-after unknown"
 *     state without a key-existence branch.
 *
 *   - The §6.2 `usage.embed_count` and `usage.post_ids` fields
 *     default to `0` and `[]` respectively when the controller
 *     does not pass a {@see UsageHints} VO. Phase-6 controllers do
 *     not yet populate usage data — sync / usage tracking is wired
 *     in Phase 9 — so the defaults are the canonical "no usage
 *     resolved yet" shape.
 *
 *   - The §6.2 `id` field surfaces as `null` when the record has
 *     not been persisted, mirroring the
 *     {@see ProductSummaryMapper} contract.
 *
 *   - All output keys are snake_case because §6.2 specifies the
 *     wire shape literally with snake_case keys.
 *
 * @package Affilun\Presentation\Rest\Mapper
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Mapper;

use Affilun\Infrastructure\Persistence\ProductRecord;
use DateTimeImmutable;

/**
 * Stateless serializer that produces the §6.2 ProductDetail array
 * shape from a {@see ProductRecord}.
 *
 * See the file-level docblock for the rationale, the
 * lastSyncedAt-as-`price.observed_at` decision, the Phase-6
 * `stale_after = null` contract, and the Phase-6 usage-default
 * contract.
 */
final class ProductDetailMapper {

	/**
	 * Convert a {@see ProductRecord} into the §6.2 array shape.
	 *
	 * The returned array's top-level key set and ordering match
	 * §6.2 verbatim; deviating from that order is a breaking
	 * change for REST clients.
	 *
	 * @param ProductRecord   $record     Persisted (or unsaved) product record.
	 * @param ImageHints|null $imageHints Pre-resolved image hint, or null
	 *                                    when the controller has not
	 *                                    resolved any image data.
	 * @param UsageHints|null $usageHints Pre-resolved usage hint, or null
	 *                                    when the controller has not
	 *                                    resolved any usage data
	 *                                    (the Phase-6 default).
	 *
	 * @return array<string, mixed>
	 */
	public function map(
		ProductRecord $record,
		?ImageHints $imageHints = null,
		?UsageHints $usageHints = null
	): array {
		return array(
			'id'                  => $record->id(),
			'provider'            => $record->provider(),
			'provider_item_id'    => $record->providerItemId(),
			'title'               => $record->title(),
			'brand'               => $record->brand(),
			'description'         => $record->description(),
			'images'              => array(
				'primary' => $imageHints?->primaryUrl(),
				'gallery' => $imageHints?->galleryUrls() ?? array(),
			),
			'price'               => self::buildPrice( $record ),
			'availability_status' => $record->availabilityStatus() ?? 'unknown',
			'links'               => array(
				'detail_url'    => $record->detailUrl(),
				'affiliate_url' => $record->affiliateUrl(),
			),
			'sync'                => array(
				'last_synced_at'          => self::formatRfc3339( $record->lastSyncedAt() ),
				'last_sync_attempted_at'  => self::formatRfc3339( $record->lastSyncAttemptedAt() ),
				'sync_state'              => $record->syncState(),
				'lifecycle_state'         => $record->lifecycleState(),
				'stale_after'             => null,
				'last_sync_error_code'    => $record->lastSyncErrorCode(),
				'last_sync_error_message' => $record->lastSyncErrorMessage(),
			),
			'usage'               => array(
				'embed_count' => $usageHints?->embedCount() ?? 0,
				'post_ids'    => $usageHints?->postIds() ?? array(),
			),
		);
	}

	/**
	 * Build the §6.2 `price` sub-object from a {@see ProductRecord},
	 * or null when the record carries no price amount.
	 *
	 * The `observed_at` field is sourced from
	 * {@see ProductRecord::lastSyncedAt()} as a Phase-6 proxy; see
	 * the file-level docblock for the rationale.
	 *
	 * @param ProductRecord $record Source record.
	 *
	 * @return array<string, mixed>|null
	 */
	private static function buildPrice( ProductRecord $record ): ?array {
		$amount = $record->priceAmount();
		if ( $amount === null ) {
			return null;
		}

		return array(
			'amount'      => $amount,
			'currency'    => $record->priceCurrency() ?? 'UNKNOWN',
			'label'       => $record->priceLabel(),
			'observed_at' => self::formatRfc3339( $record->lastSyncedAt() ),
		);
	}

	/**
	 * Format a {@see DateTimeImmutable} as an RFC 3339 / ISO 8601
	 * UTC string per §2.4 ("原則: UTC で返す"), or null when
	 * `$dt` is null.
	 *
	 * @param DateTimeImmutable|null $dt Timestamp to format, or null.
	 */
	private static function formatRfc3339( ?DateTimeImmutable $dt ): ?string {
		if ( $dt === null ) {
			return null;
		}

		return gmdate( 'Y-m-d\TH:i:s\Z', $dt->getTimestamp() );
	}
}
