<?php
/**
 * REST §6.1 ProductSummary 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.1
 * ("ProductSummary"). The shape is the canonical contract REST
 * controllers serialize when returning a product summary under the
 * §4.1 `data` envelope or as a list item under the §4.2 `data.items`
 * 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.1 shape requires beyond what
 *     {@see ProductRecord} carries is forwarded by the controller
 *     via the
 *     {@see \Affilun\Presentation\Rest\Mapper\ImageHints}
 *     hint VO. This keeps the mapper unit-testable without booting
 *     WordPress and keeps the controller free to resolve image
 *     metadata however it wants (ProductMetaRepository, cache,
 *     fixture, ...).
 *
 *   - The §6.1 `image_primary_url` field is `null` when the
 *     controller does not pass an `ImageHints` VO. That mirrors the
 *     §6.1 contract that "missing image data" is represented as
 *     `null`, never as an empty string or an absent key.
 *
 *   - The §6.1 `availability_status` field falls back to the §7.6
 *     `unknown` token when {@see ProductRecord::availabilityStatus()}
 *     is null. The §6.1 schema does not allow a null
 *     `availability_status`; "unknown" is the canonical sentinel.
 *
 *   - The §6.1 `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.1 `id` field surfaces as `null` when the record has
 *     not been persisted. Phase-6 callers route persisted records
 *     through this mapper, so the `null` branch is primarily a
 *     defensive contract: clients that receive `id === null`
 *     interpret the row as an "unsaved snapshot" and decline to
 *     reference it by id.
 *
 *   - All output keys are snake_case because §6.1 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.1 ProductSummary array
 * shape from a {@see ProductRecord}.
 *
 * See the file-level docblock for the rationale, the
 * lastSyncedAt-as-`price.observed_at` decision, and the
 * `availability_status` / `id` fallback contract.
 */
final class ProductSummaryMapper {

	/**
	 * Convert a {@see ProductRecord} into the §6.1 array shape.
	 *
	 * The returned array's top-level key set and ordering match
	 * §6.1 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.
	 *
	 * @return array<string, mixed>
	 */
	public function map( ProductRecord $record, ?ImageHints $imageHints = null ): array {
		return array(
			'id'                  => $record->id(),
			'provider'            => $record->provider(),
			'provider_item_id'    => $record->providerItemId(),
			'title'               => $record->title(),
			'brand'               => $record->brand(),
			'image_primary_url'   => $imageHints !== null ? $imageHints->primaryUrl() : null,
			'price'               => self::buildPrice( $record ),
			'availability_status' => $record->availabilityStatus() ?? 'unknown',
			'last_synced_at'      => self::formatRfc3339( $record->lastSyncedAt() ),
			'sync_state'          => $record->syncState(),
		);
	}

	/**
	 * Build the §6.1 `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() );
	}
}
