<?php
/**
 * Product grid view model.
 *
 * Immutable aggregator value object that carries the §8.3
 * canonical ProductGridViewModel structure the rendering pipeline
 * passes to a multi-product grid template. The type collects the
 * pre-validated VO parts — {@see GridLayout}, the heterogeneous
 * `items` list of {@see ProductCardViewModel} / {@see FallbackCardViewModel}
 * entries, the {@see MetaFlags} visibility toggles, and the
 * {@see GridClasses} pre-tokenized class strings — into a single
 * validated value so renderers and templates do not have to chase
 * the same fields across a Product collection, an EmbedConfig, a
 * ProviderCapabilities map, and a sibling RenderContext at render
 * time.
 *
 * Field semantics match `specifications/rendering-and-template-spec.md`
 * §8.3 exactly:
 *
 *   - `type` is the §8.3 fixed discriminator string `'product-grid'`.
 *     The constructor enforces the value so a programming error in
 *     a calling factory surfaces here rather than producing a
 *     payload a downstream polymorphic dispatch would route to the
 *     wrong template family. Exposed as the class constant
 *     {@see self::TYPE}.
 *
 *   - `template` is the §6.4 template key the renderer should pick
 *     when the {@see \CommerceLinkBuilder\Application\Render\TemplateResolver}
 *     resolves the actual file. Held as a plain string at this
 *     layer; the canonical default is `'grid-default'`.
 *
 *   - `layout` is the §8.3 `layout` slot, carried as a
 *     {@see GridLayout} view so the per-axis validation (column
 *     count range, gap closed-set) happens at the parts boundary.
 *
 *   - `items` is the §8.3 `items` array of one or more
 *     {@see ProductCardViewModel} or {@see FallbackCardViewModel}
 *     entries. The list is heterogeneous on purpose:
 *     {@see ProductCardViewModel} carries the full §8.2 card payload
 *     for a successfully composed product, while
 *     {@see FallbackCardViewModel} carries the §8.4 minimum-viable
 *     payload for a missing or broken product. Renderers and
 *     templates branch on each item's `kind` (or, equivalently,
 *     {@see ProductCardViewModel::isFallback()}) to dispatch to the
 *     correct per-cell template. Keeping the union heterogeneous
 *     at the grid level is the design hinge that lets the §13.5
 *     完了条件 "欠損商品や broken 状態でも安全に劣化表示できる"
 *     guarantee hold for a partially failing product set: a single
 *     bad ID downgrades to a fallback cell instead of taking the
 *     whole grid out. The list MUST be non-empty — an empty grid
 *     is a programming error at the factory layer, not a
 *     renderable payload — and every entry MUST be a
 *     {@see ProductCardViewModel} or a {@see FallbackCardViewModel};
 *     anything else is rejected.
 *
 *   - `meta` is the §8.3 `meta` slot, carried as the same
 *     {@see MetaFlags} VO §8.2 ProductCardViewModel uses so the
 *     grid-level visibility toggles agree with the card-level
 *     §10.x display rules. The §8.3 `meta.count` field is derived
 *     from the items list at this layer ({@see self::count()})
 *     rather than carried as an independent flag, so it cannot
 *     drift out of sync with the actual items length.
 *
 *   - `classes` is the §8.3 `classes` slot, carried as a
 *     {@see GridClasses} view so the renderer can stamp the grid
 *     root and grid-item BEM strings verbatim. A dedicated
 *     grid-shape VO is used here instead of the card-level
 *     {@see CssClasses} because the grid structure only carries
 *     a root and an item class, while the card structure carries
 *     six slot-specific classes.
 *
 * Instances are immutable.
 *
 * @package CommerceLinkBuilder\Application\Render\ViewModel
 * @see ProductCardViewModel
 * @see FallbackCardViewModel
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Render\ViewModel;

use CommerceLinkBuilder\Application\Render\ViewModel\Parts\GridClasses;
use CommerceLinkBuilder\Application\Render\ViewModel\Parts\GridLayout;
use CommerceLinkBuilder\Application\Render\ViewModel\Parts\MetaFlags;
use InvalidArgumentException;

/**
 * Immutable §8.3 ProductGridViewModel aggregator.
 *
 * See the file-level docblock for the mapping to
 * `rendering-and-template-spec.md` §8.3 and the per-field
 * validation rules.
 */
final class ProductGridViewModel {

	/**
	 * §8.3 fixed `type` discriminator string.
	 */
	public const TYPE = 'product-grid';

	/**
	 * §8.3 fixed `kind` cross-VM polymorphic discriminator.
	 *
	 * Held alongside {@see self::TYPE} so a polymorphic dispatch
	 * over the union of all ViewModel aggregators can branch on a
	 * single uniform constant name without inspecting the
	 * concrete class.
	 */
	public const KIND = 'grid';

	/**
	 * Canonical §8.3 default template key.
	 */
	public const DEFAULT_TEMPLATE = 'grid-default';

	/**
	 * §8.3 fixed discriminator string. Validated at construction
	 * time so an out-of-set value never reaches the renderer.
	 */
	private string $type;

	/**
	 * §6.4 template key the renderer should pick.
	 */
	private string $template;

	/**
	 * §8.3 `layout` slot.
	 */
	private GridLayout $layout;

	/**
	 * §8.3 `items` slot, list-shaped, heterogeneous.
	 *
	 * @var list<ProductCardViewModel|FallbackCardViewModel>
	 */
	private array $items;

	/**
	 * §8.3 `meta` slot.
	 */
	private MetaFlags $meta;

	/**
	 * §8.3 `classes` slot.
	 */
	private GridClasses $classes;

	/**
	 * Cached items count, computed once at construction time.
	 */
	private int $count;

	/**
	 * Construct a product grid view model with explicit values
	 * for every field.
	 *
	 * Validation summary:
	 *
	 *   - `type` is trimmed and matched against {@see self::TYPE};
	 *     out-of-set tokens raise {@see InvalidArgumentException}
	 *     carrying the offending value verbatim.
	 *   - `template` is trimmed and rejected when empty after
	 *     trimming. Format validation lives at the TemplateResolver.
	 *   - `items` MUST be a non-empty zero-based contiguous list,
	 *     and every entry MUST be a {@see ProductCardViewModel} or
	 *     a {@see FallbackCardViewModel}. Empty lists, non-list
	 *     shapes (associative arrays, sparse keys), null entries,
	 *     non-object entries, and wrong-typed object entries are
	 *     all rejected.
	 *
	 * @param string                                           $type     §8.3 discriminator string.
	 * @param string                                           $template §6.4 template key.
	 * @param GridLayout                                       $layout   §8.3 `layout` slot.
	 * @param list<ProductCardViewModel|FallbackCardViewModel> $items   §8.3 `items` slot.
	 * @param MetaFlags                                        $meta     §8.3 `meta` slot.
	 * @param GridClasses                                      $classes  §8.3 `classes` slot.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct(
		string $type,
		string $template,
		GridLayout $layout,
		array $items,
		MetaFlags $meta,
		GridClasses $classes
	) {
		$this->type     = self::normalizeType( $type );
		$this->template = self::normalizeTemplate( $template );
		$this->layout   = $layout;
		$this->items    = self::normalizeItemList( $items );
		$this->meta     = $meta;
		$this->classes  = $classes;
		$this->count    = count( $this->items );
	}

	/**
	 * Construct a product grid view model with the §8.3
	 * discriminator string and the canonical default template
	 * defaulted.
	 *
	 * Convenience factory mirroring {@see ProductCardViewModel::create()}.
	 *
	 * @param GridLayout                                       $layout   §8.3 `layout` slot.
	 * @param list<ProductCardViewModel|FallbackCardViewModel> $items   §8.3 `items` slot.
	 * @param MetaFlags                                        $meta     §8.3 `meta` slot.
	 * @param GridClasses                                      $classes  §8.3 `classes` slot.
	 * @param string                                           $template §6.4 template key.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules documented
	 *                                  on the constructor.
	 */
	public static function create(
		GridLayout $layout,
		array $items,
		MetaFlags $meta,
		GridClasses $classes,
		string $template = self::DEFAULT_TEMPLATE
	): self {
		return new self(
			self::TYPE,
			$template,
			$layout,
			$items,
			$meta,
			$classes
		);
	}

	/**
	 * §8.3 fixed `type` discriminator string.
	 */
	public function type(): string {
		return $this->type;
	}

	/**
	 * §8.3 fixed `kind` cross-VM polymorphic discriminator.
	 */
	public function kind(): string {
		return self::KIND;
	}

	/**
	 * §6.4 template key.
	 */
	public function template(): string {
		return $this->template;
	}

	/**
	 * §8.3 `layout` slot.
	 */
	public function layout(): GridLayout {
		return $this->layout;
	}

	/**
	 * §8.3 `items` slot.
	 *
	 * @return list<ProductCardViewModel|FallbackCardViewModel>
	 */
	public function items(): array {
		return $this->items;
	}

	/**
	 * §8.3 `meta` slot.
	 */
	public function meta(): MetaFlags {
		return $this->meta;
	}

	/**
	 * §8.3 `classes` slot.
	 */
	public function classes(): GridClasses {
		return $this->classes;
	}

	/**
	 * §8.3 `meta.count`-equivalent: the number of entries in the
	 * {@see self::items()} list.
	 *
	 * Computed once at construction time and cached so callers can
	 * branch on it without re-counting.
	 */
	public function count(): int {
		return $this->count;
	}

	/**
	 * Validate and normalize the `type` field at construction.
	 *
	 * @param string $type Raw discriminator as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$type` is not the
	 *                                  §8.3 discriminator string.
	 */
	private static function normalizeType( string $type ): string {
		$trimmed = trim( $type );

		if ( $trimmed !== self::TYPE ) {
			$message = sprintf(
				'ProductGridViewModel type must be "%s"; got "%s".',
				self::TYPE,
				$type
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize the `template` field at construction.
	 *
	 * @param string $template Raw template key as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$template` is empty
	 *                                  after trimming.
	 */
	private static function normalizeTemplate( string $template ): string {
		$trimmed = trim( $template );
		if ( $trimmed === '' ) {
			$message = 'ProductGridViewModel template must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize the `items` field at construction.
	 *
	 * Accepts a non-empty zero-based contiguous list whose entries
	 * are each {@see ProductCardViewModel} or
	 * {@see FallbackCardViewModel} instances. Empty lists, non-list
	 * shapes (associative arrays, sparse keys), null entries,
	 * non-object entries, and wrong-typed object entries are all
	 * rejected.
	 *
	 * @param array<int|string, mixed> $items Raw items list.
	 *
	 * @return list<ProductCardViewModel|FallbackCardViewModel>
	 *
	 * @throws InvalidArgumentException When `$items` violates the
	 *                                  rules above.
	 */
	private static function normalizeItemList( array $items ): array {
		if ( $items === array() ) {
			$message = 'ProductGridViewModel items must be a non-empty list.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$expected = 0;
		$result   = array();
		foreach ( $items as $key => $value ) {
			if ( $key !== $expected ) {
				$message = 'ProductGridViewModel items 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 ProductCardViewModel ) && ! ( $value instanceof FallbackCardViewModel ) ) {
				$actual_type = is_object( $value ) ? get_class( $value ) : gettype( $value );
				$message     = sprintf(
					'ProductGridViewModel items entries must be ProductCardViewModel'
						. ' or FallbackCardViewModel; got %s at index %d.',
					$actual_type,
					$expected
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

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

		return $result;
	}
}
