<?php
/**
 * REST §6.3 SearchResultItem mapper.
 *
 * Pure-function service that converts the
 * {@see \Affilun\Domain\Product\ProductSnapshot}
 * domain value object into the read-only associative-array shape
 * documented in `specifications/rest-api-spec.md` §6.3
 * ("SearchResultItem") and used by §8.1
 * (`GET /affilun/v1/products/search`).
 *
 * Design constraints:
 *
 *   - The mapper is a stateless pure function. It never reaches
 *     into infrastructure (no repository, no HTTP client, no global
 *     state); whether the snapshot corresponds to a persisted
 *     product is forwarded by the controller via the optional
 *     `$savedId` parameter. This keeps the mapper unit-testable
 *     without booting WordPress and keeps the controller free to
 *     resolve persistence state however it wants.
 *
 *   - The §6.3 `id` field is `null` when the controller has not
 *     resolved a saved-product id for the snapshot, and the
 *     positive integer post id otherwise. The §6.3 `is_saved`
 *     boolean tracks the same axis: it is `true` exactly when
 *     `$savedId !== null`. Validating `$savedId >= 1` at the
 *     mapper boundary surfaces a programming error here rather
 *     than at the REST boundary, where a `0` id would silently
 *     misrepresent persistence state.
 *
 *   - The §6.3 `price.observed_at` field is sourced from
 *     {@see \Affilun\Domain\Pricing\PriceInfo::observedAt()}
 *     directly because the snapshot itself carries the
 *     observation timestamp the provider returned — there is no
 *     `lastSyncedAt`-style proxy on the search path.
 *
 *   - The §6.3 `provider_meta` field is the open-ended extension
 *     point §6.3 reserves for provider-specific metadata
 *     (`review_average`, `review_count`, ...). Phase 6 returns it
 *     as the empty array `array()`; Phase 9+ slices wire the
 *     per-provider extraction. The field is present-but-empty so
 *     clients see the §6.3 key set verbatim and can render a
 *     "no provider meta" state without a key-existence branch.
 *
 *   - The §6.3 `availability_status` field surfaces the snapshot's
 *     {@see \Affilun\Domain\Pricing\AvailabilityInfo}
 *     status token verbatim. The snapshot's
 *     {@see AvailabilityInfo::__construct()} validates against the
 *     §7.6 closed token set, so the mapper does not re-validate.
 *
 *   - All output keys are snake_case because §6.3 / §8.1 specify
 *     the wire shape literally with snake_case keys.
 *
 * @package Affilun\Presentation\Rest\Mapper
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Mapper;

use Affilun\Domain\Pricing\PriceInfo;
use Affilun\Domain\Product\ProductSnapshot;
use DateTimeImmutable;
use InvalidArgumentException;

/**
 * Stateless serializer that produces the §6.3 SearchResultItem
 * array shape from a {@see ProductSnapshot}.
 *
 * See the file-level docblock for the rationale, the
 * `$savedId` validation contract, and the Phase-6
 * `provider_meta = array()` default.
 */
final class SearchResultItemMapper {

	/**
	 * Convert a {@see ProductSnapshot} into the §6.3 array shape.
	 *
	 * The returned array's top-level key set and ordering match
	 * §6.3 / §8.1 verbatim; deviating from that order is a
	 * breaking change for REST clients.
	 *
	 * @param ProductSnapshot $snapshot Search-result snapshot.
	 * @param int|null        $savedId  Persisted product id, or null
	 *                                  when the snapshot has not
	 *                                  been saved.
	 *
	 * @return array<string, mixed>
	 *
	 * @throws InvalidArgumentException When `$savedId` is non-null
	 *                                  but not a positive integer
	 *                                  (`>= 1`).
	 */
	public function map( ProductSnapshot $snapshot, ?int $savedId = null ): array {
		if ( $savedId !== null && $savedId < 1 ) {
			$message = sprintf(
				'SearchResultItemMapper savedId must be a positive integer when non-null, got %d.',
				$savedId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return array(
			'id'                  => $savedId,
			'provider'            => $snapshot->provider(),
			'provider_item_id'    => $snapshot->providerProductId()->value(),
			'title'               => $snapshot->title(),
			'brand'               => $snapshot->brand(),
			'image_primary_url'   => $snapshot->primaryImage(),
			'price'               => self::buildPriceFromSnapshot( $snapshot->price() ),
			'availability_status' => $snapshot->availability()->status(),
			'links'               => array(
				'detail_url' => $snapshot->detailUrl(),
			),
			'provider_meta'       => array(),
			'is_saved'            => $savedId !== null,
		);
	}

	/**
	 * Build the §6.3 `price` sub-object from a {@see PriceInfo}, or
	 * null when the snapshot carries no price.
	 *
	 * @param PriceInfo|null $price Source price snapshot, or null.
	 *
	 * @return array<string, mixed>|null
	 */
	private static function buildPriceFromSnapshot( ?PriceInfo $price ): ?array {
		if ( $price === null ) {
			return null;
		}

		return array(
			'amount'      => $price->amount(),
			'currency'    => $price->currency() ?? 'UNKNOWN',
			'label'       => $price->label(),
			'observed_at' => self::formatRfc3339( $price->observedAt() ),
		);
	}

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