<?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 \Affilun\Presentation\Block\ProductCardBlockRenderer}
 * needs, 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 Affilun\Application\Product
 */

declare(strict_types=1);

namespace Affilun\Application\Product;

use Affilun\Domain\Pricing\AvailabilityInfo;
use Affilun\Domain\Pricing\PriceInfo;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Provider\ProviderProductId;
use Affilun\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();
		$primaryImage = $record->imagePrimaryUrl();
		if ( $primaryImage === null && $record->provider() === 'mercari' ) {
			$primaryImage = self::mercariAmbassadorImageUrlFor( $record->detailUrl() );
		}

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

	/**
	 * Build a Mercari Ambassador image URL from a Mercari product URL.
	 *
	 * @param string $url Mercari product URL.
	 */
	private static function mercariAmbassadorImageUrlFor( string $url ): ?string {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Application helper is loadable in pure-unit tests without WordPress.
		$parts = parse_url( $url );
		if ( ! is_array( $parts ) || ! isset( $parts['path'] ) || ! is_string( $parts['path'] ) ) {
			return null;
		}

		$endpoint = 'https://ambassador-system.mercari.com/v1/i';
		if ( preg_match( '~/shops/product/([^/?#]+)~', $parts['path'], $matches ) === 1 ) {
			return $endpoint . '?id=' . rawurlencode( $matches[1] ) . '&svc=ms';
		}

		if ( preg_match( '~/item/([^/?#]+)~', $parts['path'], $matches ) === 1 ) {
			return $endpoint . '?id=' . rawurlencode( $matches[1] ) . '&svc=m';
		}

		return null;
	}

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