<?php
/**
 * Product information view object.
 *
 * Immutable value object that carries the per-product display
 * fields the rendering pipeline needs in order to populate the
 * `product` slot of a §8.2 ProductCardViewModel. The type collects
 * the cross-provider product surface — identifier, provider key,
 * provider human-readable label, provider-side item id, title,
 * brand, description, detail / affiliate URLs — into a single
 * validated value so renderers and templates do not have to chase
 * the same fields across a Product entity, an EmbedConfig, and a
 * provider-capabilities map at render time.
 *
 * Field semantics match `specifications/rendering-and-template-spec.md`
 * §8.2 exactly (the `product` sub-object in the canonical
 * ProductCardViewModel structure):
 *
 *   - `id` is the CLB-internal numeric product identifier the
 *     persistence layer assigned. Held as `int` because §7.3 of
 *     `provider-api-contracts.md` and the persistence schema both
 *     describe it as a positive integer surrogate key. The DTO
 *     rejects zero and negative values to surface programming
 *     errors at the type-system boundary.
 *
 *   - `provider` is the lowercase provider key (`amazon`,
 *     `rakuten`, `yahoo_shopping`, ...). Held verbatim as a
 *     non-empty string; the closed set lives at the Provider
 *     registry, not on this DTO.
 *
 *   - `providerLabel` is the human-readable provider name the
 *     templates display in badges and aria-labels ("Amazon",
 *     "楽天市場"). Required, non-empty after trimming.
 *
 *   - `providerItemId` is the provider-side product identifier
 *     (Amazon ASIN, 楽天 itemCode, ...). Required, non-empty
 *     after trimming. The DTO holds it as an opaque string;
 *     provider-specific format validation lives at the
 *     `ProviderProductId` value object.
 *
 *   - `title` is the §10.1 product title, already normalized for
 *     display. Required, non-empty after trimming.
 *
 *   - `brand` is the §10.2 brand name. Nullable because not every
 *     provider supplies one and not every site wants it shown.
 *
 *   - `description` is the §10.3 short product description,
 *     already normalized. Nullable.
 *
 *   - `detailUrl` is the canonical provider-side detail page URL
 *     (the un-tracked link). Nullable because some surfaces only
 *     receive an affiliate URL.
 *
 *   - `affiliateUrl` is the tracked / affiliate URL the §10.7
 *     buttons point at. Nullable because admin previews and
 *     feed renderings often suppress tracking.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because product information is composed once per render at the
 * ViewModel-factory boundary; in-place edits would only invite
 * ambiguity about which fields are authoritative.
 *
 * @package CommerceLinkBuilder\Application\Render\ViewModel\Parts
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Render\ViewModel\Parts;

use InvalidArgumentException;

/**
 * Immutable product-information view used by §8.2
 * ProductCardViewModel.
 *
 * See the file-level docblock for the mapping to
 * `rendering-and-template-spec.md` §8.2 and the per-field
 * validation rules.
 */
final class ProductInfoView {

	/**
	 * CLB-internal numeric product identifier, strictly positive.
	 */
	private int $id;

	/**
	 * Lowercase provider key (`amazon`, `rakuten`, ...).
	 */
	private string $provider;

	/**
	 * Human-readable provider name for display.
	 */
	private string $providerLabel;

	/**
	 * Provider-side product identifier as an opaque string.
	 */
	private string $providerItemId;

	/**
	 * §10.1 normalized product title.
	 */
	private string $title;

	/**
	 * §10.2 brand name, or null when not displayed / unknown.
	 */
	private ?string $brand;

	/**
	 * §10.3 short product description, or null.
	 */
	private ?string $description;

	/**
	 * Canonical provider-side detail page URL, or null.
	 */
	private ?string $detailUrl;

	/**
	 * Tracked / affiliate URL the §10.7 buttons target, or null.
	 */
	private ?string $affiliateUrl;

	/**
	 * Construct a product info view with explicit values for
	 * every field.
	 *
	 * Validation summary:
	 *
	 *   - `id` must be strictly positive; zero and negative values
	 *     are rejected.
	 *   - `provider`, `providerLabel`, `providerItemId`, `title`
	 *     are trimmed and rejected when empty after trimming.
	 *   - `brand`, `description`, `detailUrl`, `affiliateUrl`,
	 *     when non-null, are trimmed and rejected when the trim
	 *     leaves an empty string.
	 *
	 * @param int         $id             CLB-internal product id (>= 1).
	 * @param string      $provider       Lowercase provider key.
	 * @param string      $providerLabel  Human-readable provider name.
	 * @param string      $providerItemId Provider-side item id.
	 * @param string      $title          Normalized product title.
	 * @param string|null $brand          Brand name, or null.
	 * @param string|null $description    Short description, or null.
	 * @param string|null $detailUrl      Detail page URL, or null.
	 * @param string|null $affiliateUrl   Tracked affiliate URL, or null.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct(
		int $id,
		string $provider,
		string $providerLabel,
		string $providerItemId,
		string $title,
		?string $brand,
		?string $description,
		?string $detailUrl,
		?string $affiliateUrl
	) {
		$this->id             = self::normalizeId( $id );
		$this->provider       = self::normalizeRequiredString( 'provider', $provider );
		$this->providerLabel  = self::normalizeRequiredString( 'providerLabel', $providerLabel );
		$this->providerItemId = self::normalizeRequiredString( 'providerItemId', $providerItemId );
		$this->title          = self::normalizeRequiredString( 'title', $title );
		$this->brand          = self::normalizeOptionalString( 'brand', $brand );
		$this->description    = self::normalizeOptionalString( 'description', $description );
		$this->detailUrl      = self::normalizeOptionalString( 'detailUrl', $detailUrl );
		$this->affiliateUrl   = self::normalizeOptionalString( 'affiliateUrl', $affiliateUrl );
	}

	/**
	 * CLB-internal numeric product identifier.
	 */
	public function id(): int {
		return $this->id;
	}

	/**
	 * Lowercase provider key.
	 */
	public function provider(): string {
		return $this->provider;
	}

	/**
	 * Human-readable provider name for display.
	 */
	public function providerLabel(): string {
		return $this->providerLabel;
	}

	/**
	 * Provider-side product identifier.
	 */
	public function providerItemId(): string {
		return $this->providerItemId;
	}

	/**
	 * §10.1 normalized product title.
	 */
	public function title(): string {
		return $this->title;
	}

	/**
	 * §10.2 brand name, or null.
	 */
	public function brand(): ?string {
		return $this->brand;
	}

	/**
	 * §10.3 short product description, or null.
	 */
	public function description(): ?string {
		return $this->description;
	}

	/**
	 * Canonical detail page URL, or null.
	 */
	public function detailUrl(): ?string {
		return $this->detailUrl;
	}

	/**
	 * Tracked / affiliate URL, or null.
	 */
	public function affiliateUrl(): ?string {
		return $this->affiliateUrl;
	}

	/**
	 * Report whether this view equals the supplied one.
	 *
	 * Equality is structural: every field must match with strict
	 * comparison.
	 *
	 * @param self $other Other view to compare against.
	 */
	public function equals( self $other ): bool {
		return $this->id === $other->id
			&& $this->provider === $other->provider
			&& $this->providerLabel === $other->providerLabel
			&& $this->providerItemId === $other->providerItemId
			&& $this->title === $other->title
			&& $this->brand === $other->brand
			&& $this->description === $other->description
			&& $this->detailUrl === $other->detailUrl
			&& $this->affiliateUrl === $other->affiliateUrl;
	}

	/**
	 * Validate the `id` field at construction.
	 *
	 * @param int $id Raw identifier as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$id` is not strictly
	 *                                  positive.
	 */
	private static function normalizeId( int $id ): int {
		if ( $id < 1 ) {
			$message = sprintf(
				'ProductInfoView id must be a strictly positive integer; got %d.',
				$id
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $id;
	}

	/**
	 * Validate and normalize a required string field at
	 * construction.
	 *
	 * Trims surrounding whitespace and rejects the result when it
	 * is the empty string.
	 *
	 * @param string $field Field name for the error message.
	 * @param string $value Raw value as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$value` is empty
	 *                                  after trimming.
	 */
	private static function normalizeRequiredString( string $field, string $value ): string {
		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'ProductInfoView %s must be a non-empty string.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize a nullable string field that, when
	 * non-null, must carry non-empty content.
	 *
	 * @param string      $field Field name for the error message.
	 * @param string|null $value Raw value, or null.
	 *
	 * @throws InvalidArgumentException When `$value` is the empty
	 *                                  string after trimming.
	 */
	private static function normalizeOptionalString( string $field, ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'ProductInfoView %s must be null or a non-empty string.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}
}
