<?php
/**
 * REST mapper image-hints value object.
 *
 * Immutable value object that carries optional, pre-resolved image
 * data — primary image URL plus a gallery list — that REST
 * controllers may compute upfront from
 * {@see \CommerceLinkBuilder\Infrastructure\Persistence\ProductMetaRepository}
 * (or any other source) and forward to the
 * {@see \CommerceLinkBuilder\Presentation\Rest\Mapper\ProductSummaryMapper}
 * and
 * {@see \CommerceLinkBuilder\Presentation\Rest\Mapper\ProductDetailMapper}
 * so the mappers themselves stay pure VO→array translators that
 * never reach into infrastructure.
 *
 * The shape is the §6.1 / §6.2 image surface of
 * `specifications/rest-api-spec.md`:
 *
 *   - `primaryUrl` is the §6.1 `image_primary_url` and §6.2
 *     `images.primary` value. Nullable, because not every record
 *     carries a primary image and clients are expected to render a
 *     placeholder in that case.
 *
 *   - `galleryUrls` is the §6.2 `images.gallery` list, in the order
 *     the controller resolved it. The list is enforced as a
 *     sequential `list<string>` so the on-the-wire JSON renders as
 *     an array, not an object — and so a programming error that
 *     accidentally produces a sparse array surfaces here rather
 *     than at the REST boundary.
 *
 * Both fields are validated as `http://` or `https://` URLs at
 * construction time; "present but malformed" is treated as a
 * programming error rather than a silently-passed value.
 *
 * @package CommerceLinkBuilder\Presentation\Rest\Mapper
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Rest\Mapper;

use InvalidArgumentException;

/**
 * Immutable image-hints carrier consumed by the §6.1 / §6.2
 * REST mappers.
 *
 * See the file-level docblock for the mapping to
 * `rest-api-spec.md` §6.1 / §6.2 and the per-field validation
 * rules.
 */
final class ImageHints {

	/**
	 * §6.1 `image_primary_url` / §6.2 `images.primary` value, or
	 * null when the controller could not resolve one.
	 */
	private ?string $primaryUrl;

	/**
	 * §6.2 `images.gallery` list, in resolution order.
	 *
	 * @var list<string>
	 */
	private array $galleryUrls;

	/**
	 * Construct an image-hints VO with explicit values for every
	 * field.
	 *
	 * @param string|null  $primaryUrl  Primary image URL, or null.
	 * @param list<string> $galleryUrls Gallery image URLs, in order.
	 *
	 * @throws InvalidArgumentException When `$primaryUrl` is non-null
	 *                                  but not an `http(s)://` URL,
	 *                                  when `$galleryUrls` is not a
	 *                                  sequential list, or when any
	 *                                  gallery entry is not a string
	 *                                  or not an `http(s)://` URL.
	 */
	public function __construct( ?string $primaryUrl, array $galleryUrls = array() ) {
		if ( $primaryUrl !== null ) {
			self::assertHttpUrl( 'primaryUrl', $primaryUrl );
		}

		$expectedIndex = 0;
		foreach ( $galleryUrls as $key => $value ) {
			if ( $key !== $expectedIndex ) {
				$message = 'ImageHints galleryUrls must be a sequential list (0-indexed, contiguous).';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			if ( ! is_string( $value ) ) {
				$message = sprintf(
					'ImageHints galleryUrls entry at index %d must be a string.',
					$expectedIndex
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			self::assertHttpUrl( sprintf( 'galleryUrls[%d]', $expectedIndex ), $value );
			++$expectedIndex;
		}

		$this->primaryUrl = $primaryUrl;
		/** @var list<string> $galleryUrls */
		$this->galleryUrls = $galleryUrls;
	}

	/**
	 * Construct an empty image-hints VO with no primary image and
	 * no gallery entries. Used by controllers that have not yet
	 * resolved any image data.
	 */
	public static function none(): self {
		return new self( null, array() );
	}

	/**
	 * §6.1 `image_primary_url` / §6.2 `images.primary` value, or
	 * null when not resolved.
	 */
	public function primaryUrl(): ?string {
		return $this->primaryUrl;
	}

	/**
	 * §6.2 `images.gallery` list, in resolution order.
	 *
	 * @return list<string>
	 */
	public function galleryUrls(): array {
		return $this->galleryUrls;
	}

	/**
	 * Structural equality: two hints are equal when both the
	 * primary URL and the gallery list match exactly (including
	 * order).
	 *
	 * @param self $other Other hints to compare with.
	 */
	public function equals( self $other ): bool {
		return $this->primaryUrl === $other->primaryUrl
			&& $this->galleryUrls === $other->galleryUrls;
	}

	/**
	 * Validate that `$value` is a non-empty `http://` or
	 * `https://` URL.
	 *
	 * @param string $field Field name for the error message.
	 * @param string $value Candidate URL.
	 *
	 * @throws InvalidArgumentException When `$value` is not a valid
	 *                                  `http(s)://` URL.
	 */
	private static function assertHttpUrl( string $field, string $value ): void {
		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf( 'ImageHints %s must be a non-empty http(s):// URL.', $field );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( filter_var( $trimmed, FILTER_VALIDATE_URL ) === false ) {
			$message = sprintf( 'ImageHints %s must be a valid http(s):// URL; got "%s".', $field, $value );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Pure VO validation; wp_parse_url() is unavailable in unit-test contexts that bypass the WP shim.
		$scheme = strtolower( (string) parse_url( $trimmed, PHP_URL_SCHEME ) );
		if ( $scheme !== 'http' && $scheme !== 'https' ) {
			$message = sprintf( 'ImageHints %s must use the http or https scheme; got "%s".', $field, $value );
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}
	}
}
