<?php
/**
 * Image view object.
 *
 * Immutable value object that carries the per-image display fields
 * the rendering pipeline needs in order to populate the §10.4 image
 * slot of a §8.2 ProductCardViewModel. The type collects the URL,
 * alt text, intrinsic dimensions, and the `loading` strategy into a
 * single validated value so renderers and templates can emit an
 * `<img>` element without re-deriving the lazy-load posture from a
 * sibling RenderContext at render time.
 *
 * Field semantics match `specifications/rendering-and-template-spec.md`
 * §10.4 exactly:
 *
 *   - `url` is the absolute image URL the renderer stamps onto the
 *     `<img src="...">` attribute. Required, non-empty after
 *     trimming. The DTO does not parse the URL beyond rejecting
 *     the empty string — provider-specific scheme and host
 *     validation lives at the Image fetcher / sanitizer layer.
 *
 *   - `alt` is the §10.4 "原則として商品タイトル" alternative text
 *     the renderer stamps onto `<img alt="...">`. Required (an
 *     empty alt is conveyed by passing a single space or an
 *     explicit non-empty placeholder; this DTO always carries
 *     non-empty content because the renderer should never emit a
 *     bare `alt=""` for product imagery).
 *
 *   - `width` and `height` are the intrinsic image dimensions in
 *     CSS pixels. Held as `?int` because not every provider
 *     response carries them, and not every surface (feed, AMP)
 *     wants them stamped. When non-null, both must be strictly
 *     positive — zero and negative values are rejected as a
 *     programming error.
 *
 *   - `loading` is the §10.4 `loading` attribute value the
 *     renderer stamps onto `<img loading="...">`. The closed set
 *     is `'lazy'` (default for §5.2 frontend rendering),
 *     `'eager'` (above-the-fold images), and `'auto'` (defer to
 *     the user agent). Validated at construction so an
 *     out-of-set value never reaches the renderer.
 *
 * Instances are immutable and have no `with*()` mutation helpers
 * because an image view is composed once per render at the
 * ViewModel-factory boundary.
 *
 * @package CommerceLinkBuilder\Application\Render\ViewModel\Parts
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Render\ViewModel\Parts;

use InvalidArgumentException;

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

	/**
	 * §10.4 `loading="lazy"` token.
	 */
	public const LOADING_LAZY = 'lazy';

	/**
	 * §10.4 `loading="eager"` token.
	 */
	public const LOADING_EAGER = 'eager';

	/**
	 * §10.4 `loading="auto"` token (defer to the user agent).
	 */
	public const LOADING_AUTO = 'auto';

	/**
	 * Closed set of accepted §10.4 `loading` tokens, in
	 * declaration order.
	 *
	 * @var list<string>
	 */
	private const VALID_LOADING = array(
		self::LOADING_LAZY,
		self::LOADING_EAGER,
		self::LOADING_AUTO,
	);

	/**
	 * Absolute image URL.
	 */
	private string $url;

	/**
	 * Alternative text for the image.
	 */
	private string $alt;

	/**
	 * Intrinsic image width in CSS pixels, or null.
	 */
	private ?int $width;

	/**
	 * Intrinsic image height in CSS pixels, or null.
	 */
	private ?int $height;

	/**
	 * One of the {@see self::LOADING_*} tokens.
	 */
	private string $loading;

	/**
	 * Construct an image view with explicit values for every
	 * field.
	 *
	 * Validation summary:
	 *
	 *   - `url` and `alt` are trimmed and rejected when empty
	 *     after trimming.
	 *   - `width` and `height`, when non-null, must be strictly
	 *     positive; zero and negative values are rejected.
	 *   - `loading` is trimmed and matched against
	 *     {@see self::VALID_LOADING}; out-of-set tokens raise
	 *     {@see InvalidArgumentException}.
	 *
	 * @param string   $url     Absolute image URL.
	 * @param string   $alt     Alternative text.
	 * @param int|null $width   Intrinsic width, or null.
	 * @param int|null $height  Intrinsic height, or null.
	 * @param string   $loading One of {@see self::VALID_LOADING}.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct(
		string $url,
		string $alt,
		?int $width,
		?int $height,
		string $loading
	) {
		$this->url     = self::normalizeRequiredString( 'url', $url );
		$this->alt     = self::normalizeRequiredString( 'alt', $alt );
		$this->width   = self::normalizeOptionalDimension( 'width', $width );
		$this->height  = self::normalizeOptionalDimension( 'height', $height );
		$this->loading = self::normalizeLoading( $loading );
	}

	/**
	 * Closed set of accepted §10.4 `loading` tokens, in
	 * declaration order.
	 *
	 * @return list<string>
	 */
	public static function validLoading(): array {
		return self::VALID_LOADING;
	}

	/**
	 * Absolute image URL.
	 */
	public function url(): string {
		return $this->url;
	}

	/**
	 * Alternative text for the image.
	 */
	public function alt(): string {
		return $this->alt;
	}

	/**
	 * Intrinsic image width in CSS pixels, or null.
	 */
	public function width(): ?int {
		return $this->width;
	}

	/**
	 * Intrinsic image height in CSS pixels, or null.
	 */
	public function height(): ?int {
		return $this->height;
	}

	/**
	 * §10.4 `loading` attribute value.
	 */
	public function loading(): string {
		return $this->loading;
	}

	/**
	 * 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->url === $other->url
			&& $this->alt === $other->alt
			&& $this->width === $other->width
			&& $this->height === $other->height
			&& $this->loading === $other->loading;
	}

	/**
	 * Validate and normalize a required string field at
	 * construction.
	 *
	 * @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(
				'ImageView %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 a nullable image-dimension field.
	 *
	 * Null passes through unchanged. A non-null value MUST be
	 * strictly positive.
	 *
	 * @param string   $field Field name for the error message.
	 * @param int|null $value Raw value, or null.
	 *
	 * @throws InvalidArgumentException When `$value` is not
	 *                                  strictly positive.
	 */
	private static function normalizeOptionalDimension( string $field, ?int $value ): ?int {
		if ( $value === null ) {
			return null;
		}

		if ( $value < 1 ) {
			$message = sprintf(
				'ImageView %s must be null or a strictly positive integer; got %d.',
				$field,
				$value
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $value;
	}

	/**
	 * Validate and normalize the `loading` field at construction.
	 *
	 * The token is trimmed first so a carelessly-decoded payload
	 * is normalized to the same canonical form a freshly-
	 * constructed token would carry. The trimmed value MUST
	 * appear in {@see self::VALID_LOADING}.
	 *
	 * @param string $loading Raw token as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$loading` is not a
	 *                                  member of the closed set.
	 */
	private static function normalizeLoading( string $loading ): string {
		$trimmed = trim( $loading );

		if ( ! in_array( $trimmed, self::VALID_LOADING, true ) ) {
			$message = sprintf(
				'ImageView loading must be one of [%s]; got "%s".',
				implode( ', ', self::VALID_LOADING ),
				$loading
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}
}
