<?php
/**
 * Image set value object.
 *
 * Immutable value object that groups the §10.4 primary product
 * image and the optional gallery images into a single validated
 * value. The type exists so that §8.2 ProductCardViewModel and the
 * §10.4 image rendering rules can express "primary plus zero or
 * more gallery entries" without forcing every consumer to
 * re-invent the same shape.
 *
 * Field semantics match `specifications/rendering-and-template-spec.md`
 * §10.4 exactly:
 *
 *   - `primary` is the single image the renderer prefers per
 *     §10.4 ("primary image を優先"). Nullable because §10.4 also
 *     specifies the "no image" branch ("画像がない場合はプレース
 *     ホルダまたは画像領域非表示"); the renderer decides whether to
 *     fall back to a placeholder or to suppress the image region
 *     entirely.
 *
 *   - `gallery` is the ordered list of additional images the
 *     renderer MAY display when the §10.4 "gallery は provider が
 *     対応する場合のみ" condition is met. The DTO holds a
 *     `list<ImageView>` (zero-based, contiguous keys); empty list
 *     by default. Nested arrays / non-{@see ImageView} values are
 *     rejected at construction time.
 *
 * Instances are immutable. Helper methods {@see self::hasPrimary()},
 * {@see self::count()}, and {@see self::isEmpty()} are exposed so
 * templates and renderers can branch on the canonical "any image
 * available?" question without manually inspecting both fields.
 *
 * @package Affilun\Application\Render\ViewModel\Parts
 */

declare(strict_types=1);

namespace Affilun\Application\Render\ViewModel\Parts;

use InvalidArgumentException;

/**
 * Immutable image-set 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 ImageSet {

	/**
	 * §10.4 primary image, or null when no image is available.
	 */
	private ?ImageView $primary;

	/**
	 * Ordered list of gallery images.
	 *
	 * @var list<ImageView>
	 */
	private array $gallery;

	/**
	 * Construct an image set with explicit values for every
	 * field.
	 *
	 * Validation summary:
	 *
	 *   - `primary` may be null when no image is available.
	 *   - `gallery` MUST be a list (zero-based, contiguous keys)
	 *     of {@see ImageView} instances; non-{@see ImageView}
	 *     entries and non-list shapes are rejected.
	 *
	 * @param ImageView|null  $primary §10.4 primary image, or null.
	 * @param list<ImageView> $gallery Gallery images.
	 *
	 * @throws InvalidArgumentException When `$gallery` is not a
	 *                                  list of {@see ImageView}.
	 */
	public function __construct( ?ImageView $primary, array $gallery ) {
		$this->primary = $primary;
		$this->gallery = self::normalizeGallery( $gallery );
	}

	/**
	 * §10.4 primary image, or null when no image is available.
	 */
	public function primary(): ?ImageView {
		return $this->primary;
	}

	/**
	 * Ordered list of gallery images.
	 *
	 * @return list<ImageView>
	 */
	public function gallery(): array {
		return $this->gallery;
	}

	/**
	 * Report whether a primary image is available.
	 */
	public function hasPrimary(): bool {
		return $this->primary !== null;
	}

	/**
	 * Total number of images carried by this set, counting the
	 * primary (when present) plus every gallery entry.
	 */
	public function count(): int {
		return ( $this->primary !== null ? 1 : 0 ) + count( $this->gallery );
	}

	/**
	 * Report whether this set carries no images at all — neither
	 * a primary nor any gallery entries.
	 */
	public function isEmpty(): bool {
		return $this->primary === null && $this->gallery === array();
	}

	/**
	 * Report whether this set equals the supplied one.
	 *
	 * Equality is structural: the primary slot must agree on
	 * presence and content, and the gallery lists must match
	 * element-by-element with strict {@see ImageView::equals()}
	 * comparisons.
	 *
	 * @param self $other Other set to compare against.
	 */
	public function equals( self $other ): bool {
		if ( ( $this->primary === null ) !== ( $other->primary === null ) ) {
			return false;
		}

		if ( $this->primary !== null && $other->primary !== null && ! $this->primary->equals( $other->primary ) ) {
			return false;
		}

		if ( count( $this->gallery ) !== count( $other->gallery ) ) {
			return false;
		}

		foreach ( $this->gallery as $index => $image ) {
			if ( ! $image->equals( $other->gallery[ $index ] ) ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Validate and normalize the `gallery` field at construction.
	 *
	 * Accepts a zero-based contiguous list of {@see ImageView}
	 * instances. Non-list shapes (associative arrays, sparse keys)
	 * and non-{@see ImageView} entries are rejected.
	 *
	 * @param array<int|string, mixed> $gallery Raw gallery list.
	 *
	 * @return list<ImageView>
	 *
	 * @throws InvalidArgumentException When `$gallery` violates
	 *                                  the rules above.
	 */
	private static function normalizeGallery( array $gallery ): array {
		$expected = 0;
		$result   = array();
		foreach ( $gallery as $key => $value ) {
			if ( $key !== $expected ) {
				$message = 'ImageSet gallery must be a zero-based contiguous list.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( ! $value instanceof ImageView ) {
				$message = sprintf(
					'ImageSet gallery entries must be ImageView instances; got %s at index %d.',
					is_object( $value ) ? get_class( $value ) : gettype( $value ),
					$expected
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$result[] = $value;
			++$expected;
		}

		return $result;
	}
}
