<?php
/**
 * Product card view model.
 *
 * Immutable aggregator value object that carries the §8.2
 * canonical ProductCardViewModel structure the rendering pipeline
 * passes to a single-product card template. The type collects the
 * pre-validated VO parts — {@see ProductInfoView}, {@see ImageSet},
 * {@see PriceView}, {@see AvailabilityView}, the buttons /
 * badges / disclaimers lists, the {@see MetaFlags} visibility
 * toggles, and the {@see CssClasses} pre-tokenized class strings —
 * into a single validated value so renderers and templates do not
 * have to chase the same fields across a Product entity, an
 * EmbedConfig, a ProviderCapabilities map, and a sibling
 * RenderContext at render time.
 *
 * Field semantics match `specifications/rendering-and-template-spec.md`
 * §8.2 exactly:
 *
 *   - `type` is the §8.2 fixed discriminator string `'product-card'`.
 *     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 \Affilun\Application\Render\TemplateResolver}
 *     resolves the actual file. Held as a plain string at this
 *     layer; the TemplateResolver enforces the
 *     `^[a-z][a-z0-9-]{1,63}$` grammar at the resolution boundary.
 *     The aggregator only validates non-empty so a caller that
 *     forgot to pick a template fails loudly.
 *
 *   - `product`, `images`, `price`, `availability` are the §8.2
 *     `product` / `images` / `price` / `availability` slots,
 *     carried as the dedicated VO parts so the per-axis validation
 *     happens at the parts boundary and this aggregator can rely
 *     on the parts being internally consistent.
 *
 *   - `buttons` is the §8.2 `buttons` array of zero or more
 *     {@see ButtonView} entries. The list MAY be empty per §13.3
 *     "ボタンなし" graceful degradation; non-empty lists MUST be
 *     zero-based contiguous and every entry MUST be a
 *     {@see ButtonView}.
 *
 *   - `badges` is the §8.2 `badges` array of zero or more
 *     {@see BadgeView} entries. The list MAY be empty when no
 *     badges are configured; non-empty lists MUST be zero-based
 *     contiguous and every entry MUST be a {@see BadgeView}.
 *
 *   - `disclaimers` is the §8.2 `disclaimers` array of zero or
 *     more pre-formatted disclaimer strings (the disclaimer
 *     generator at the factory layer is responsible for the
 *     localization / template-substitution pass). The list MAY be
 *     empty; non-empty lists MUST be zero-based contiguous, and
 *     every entry MUST be a non-empty string after trimming.
 *
 *   - `meta` is the §8.2 `meta` slot, carried as a {@see MetaFlags}
 *     view so the per-axis visibility toggles agree with the §10.x
 *     display rules.
 *
 *   - `classes` is the §8.2 `classes` slot, carried as a
 *     {@see CssClasses} view so the renderer can stamp the BEM
 *     class strings verbatim.
 *
 * Instances are immutable. The aggregator exposes a single
 * {@see self::withTemplate()} mutation helper because the
 * ViewModel factory frequently picks a more specific template
 * variant (downgrading to a fallback-aware default, for example)
 * after the rest of the payload has already been composed.
 *
 * @package Affilun\Application\Render\ViewModel
 */

declare(strict_types=1);

namespace Affilun\Application\Render\ViewModel;

use Affilun\Application\Render\ViewModel\Parts\AvailabilityView;
use Affilun\Application\Render\ViewModel\Parts\BadgeView;
use Affilun\Application\Render\ViewModel\Parts\ButtonView;
use Affilun\Application\Render\ViewModel\Parts\CssClasses;
use Affilun\Application\Render\ViewModel\Parts\ImageSet;
use Affilun\Application\Render\ViewModel\Parts\MetaFlags;
use Affilun\Application\Render\ViewModel\Parts\PriceView;
use Affilun\Application\Render\ViewModel\Parts\ProductInfoView;
use InvalidArgumentException;

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

	/**
	 * §8.2 fixed `type` discriminator string.
	 *
	 * Exposed as a class constant so call sites can reference the
	 * exact token without duplicating the literal, and so a
	 * future spec change has a single location to update.
	 */
	public const TYPE = 'product-card';

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

	/**
	 * §8.2 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. Held as a plain
	 * string; the TemplateResolver enforces the grammar at the
	 * resolution boundary.
	 */
	private string $template;

	/**
	 * §8.2 `product` slot.
	 */
	private ProductInfoView $product;

	/**
	 * §8.2 `images` slot.
	 */
	private ImageSet $images;

	/**
	 * §8.2 `price` slot.
	 */
	private PriceView $price;

	/**
	 * §8.2 `availability` slot.
	 */
	private AvailabilityView $availability;

	/**
	 * §8.2 `buttons` slot, list-shaped.
	 *
	 * @var list<ButtonView>
	 */
	private array $buttons;

	/**
	 * §8.2 `badges` slot, list-shaped.
	 *
	 * @var list<BadgeView>
	 */
	private array $badges;

	/**
	 * §8.2 `disclaimers` slot, list-shaped.
	 *
	 * @var list<string>
	 */
	private array $disclaimers;

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

	/**
	 * §8.2 `classes` slot.
	 */
	private CssClasses $classes;

	/**
	 * Construct a product card view model with explicit values
	 * for every field.
	 *
	 * The constructor is exhaustive so a programming error
	 * (forgetting to compose a slot, swapping `buttons` /
	 * `badges`) surfaces at the type system boundary rather than
	 * at the renderer. Named-argument construction is the
	 * ergonomic entry point for most call sites; the
	 * {@see self::create()} factory covers the common case of
	 * "type defaults to the §8.2 discriminator".
	 *
	 * 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.
	 *   - `buttons`, `badges`, `disclaimers` MUST be zero-based
	 *     contiguous lists. Empty lists are accepted; non-list
	 *     shapes (associative arrays, sparse keys) and wrong-typed
	 *     entries are rejected. Disclaimer strings are trimmed and
	 *     rejected when empty after trimming.
	 *
	 * @param string           $type         §8.2 discriminator string.
	 * @param string           $template     §6.4 template key.
	 * @param ProductInfoView  $product      §8.2 `product` slot.
	 * @param ImageSet         $images       §8.2 `images` slot.
	 * @param PriceView        $price        §8.2 `price` slot.
	 * @param AvailabilityView $availability §8.2 `availability` slot.
	 * @param list<ButtonView> $buttons      §8.2 `buttons` slot.
	 * @param list<BadgeView>  $badges       §8.2 `badges` slot.
	 * @param list<string>     $disclaimers  §8.2 `disclaimers` slot.
	 * @param MetaFlags        $meta         §8.2 `meta` slot.
	 * @param CssClasses       $classes      §8.2 `classes` slot.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct(
		string $type,
		string $template,
		ProductInfoView $product,
		ImageSet $images,
		PriceView $price,
		AvailabilityView $availability,
		array $buttons,
		array $badges,
		array $disclaimers,
		MetaFlags $meta,
		CssClasses $classes
	) {
		$this->type         = self::normalizeType( $type );
		$this->template     = self::normalizeTemplate( $template );
		$this->product      = $product;
		$this->images       = $images;
		$this->price        = $price;
		$this->availability = $availability;
		$this->buttons      = self::normalizeButtonList( $buttons );
		$this->badges       = self::normalizeBadgeList( $badges );
		$this->disclaimers  = self::normalizeDisclaimerList( $disclaimers );
		$this->meta         = $meta;
		$this->classes      = $classes;
	}

	/**
	 * Construct a product card view model with the §8.2
	 * discriminator string defaulted.
	 *
	 * Convenience factory so call sites do not have to spell out
	 * the fixed `type` argument; mirrors the parameter order used
	 * by the ViewModel factory at the §9.1 boundary.
	 *
	 * @param ProductInfoView  $product      §8.2 `product` slot.
	 * @param ImageSet         $images       §8.2 `images` slot.
	 * @param PriceView        $price        §8.2 `price` slot.
	 * @param AvailabilityView $availability §8.2 `availability` slot.
	 * @param MetaFlags        $meta         §8.2 `meta` slot.
	 * @param CssClasses       $classes      §8.2 `classes` slot.
	 * @param string           $template     §6.4 template key.
	 * @param list<ButtonView> $buttons      §8.2 `buttons` slot.
	 * @param list<BadgeView>  $badges       §8.2 `badges` slot.
	 * @param list<string>     $disclaimers  §8.2 `disclaimers` slot.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules documented
	 *                                  on the constructor.
	 */
	public static function create(
		ProductInfoView $product,
		ImageSet $images,
		PriceView $price,
		AvailabilityView $availability,
		MetaFlags $meta,
		CssClasses $classes,
		string $template,
		array $buttons = array(),
		array $badges = array(),
		array $disclaimers = array()
	): self {
		return new self(
			self::TYPE,
			$template,
			$product,
			$images,
			$price,
			$availability,
			$buttons,
			$badges,
			$disclaimers,
			$meta,
			$classes
		);
	}

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

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

	/**
	 * §8.2 `product` slot.
	 */
	public function product(): ProductInfoView {
		return $this->product;
	}

	/**
	 * §8.2 `images` slot.
	 */
	public function images(): ImageSet {
		return $this->images;
	}

	/**
	 * §8.2 `price` slot.
	 */
	public function price(): PriceView {
		return $this->price;
	}

	/**
	 * §8.2 `availability` slot.
	 */
	public function availability(): AvailabilityView {
		return $this->availability;
	}

	/**
	 * §8.2 `buttons` slot.
	 *
	 * @return list<ButtonView>
	 */
	public function buttons(): array {
		return $this->buttons;
	}

	/**
	 * §8.2 `badges` slot.
	 *
	 * @return list<BadgeView>
	 */
	public function badges(): array {
		return $this->badges;
	}

	/**
	 * §8.2 `disclaimers` slot.
	 *
	 * @return list<string>
	 */
	public function disclaimers(): array {
		return $this->disclaimers;
	}

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

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

	/**
	 * Report whether this aggregator is the §8.4 fallback variant.
	 *
	 * Always false for {@see ProductCardViewModel}; the
	 * {@see FallbackCardViewModel} sibling returns true. Used by
	 * the renderer's polymorphic dispatch to short-circuit the
	 * full-card pipeline when a fallback payload arrives.
	 */
	public function isFallback(): bool {
		return false;
	}

	/**
	 * Produce a copy of this view model with `buttons` replaced.
	 *
	 * @param list<ButtonView> $buttons Replacement button list.
	 */
	public function withButtons( array $buttons ): self {
		$clone          = clone $this;
		$clone->buttons = self::normalizeButtonList( $buttons );
		return $clone;
	}

	/**
	 * Produce a copy of this view model with `badges` replaced.
	 *
	 * @param list<BadgeView> $badges Replacement badge list.
	 */
	public function withBadges( array $badges ): self {
		$clone         = clone $this;
		$clone->badges = self::normalizeBadgeList( $badges );
		return $clone;
	}

	/**
	 * Produce a copy of this view model with `template` replaced.
	 *
	 * Used by the ViewModel factory when it picks a more specific
	 * template variant after the rest of the payload has already
	 * been composed. Other fields are preserved verbatim.
	 *
	 * @param string $template New §6.4 template key.
	 *
	 * @throws InvalidArgumentException When `$template` is empty
	 *                                  after trimming.
	 */
	public function withTemplate( string $template ): self {
		$clone           = clone $this;
		$clone->template = self::normalizeTemplate( $template );
		return $clone;
	}

	/**
	 * Report whether this view model equals the supplied one.
	 *
	 * Equality is structural: every field must match with strict
	 * comparison, every VO part must compare equal under its own
	 * `equals()`, and every list must match element-by-element.
	 *
	 * @param self $other Other view model to compare against.
	 */
	public function equals( self $other ): bool {
		if ( $this->type !== $other->type ) {
			return false;
		}

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

		if ( ! $this->product->equals( $other->product ) ) {
			return false;
		}

		if ( ! $this->images->equals( $other->images ) ) {
			return false;
		}

		if ( ! $this->price->equals( $other->price ) ) {
			return false;
		}

		if ( ! $this->availability->equals( $other->availability ) ) {
			return false;
		}

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

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

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

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

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

		if ( ! $this->meta->equals( $other->meta ) ) {
			return false;
		}

		if ( ! $this->classes->equals( $other->classes ) ) {
			return false;
		}

		return true;
	}

	/**
	 * Validate and normalize the `type` 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 equal
	 * {@see self::TYPE}; anything else raises
	 * {@see InvalidArgumentException}.
	 *
	 * @param string $type Raw discriminator as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$type` is not the
	 *                                  §8.2 discriminator string.
	 */
	private static function normalizeType( string $type ): string {
		$trimmed = trim( $type );

		if ( $trimmed !== self::TYPE ) {
			$message = sprintf(
				'ProductCardViewModel 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.
	 *
	 * Trims surrounding whitespace and rejects the result when it
	 * is the empty string. The TemplateResolver enforces the
	 * `^[a-z][a-z0-9-]{1,63}$` grammar at the resolution boundary.
	 *
	 * @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 = 'ProductCardViewModel 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 `buttons` field at construction.
	 *
	 * Accepts a zero-based contiguous list of {@see ButtonView}
	 * instances. Non-list shapes (associative arrays, sparse keys)
	 * and non-{@see ButtonView} entries are rejected. The empty
	 * list is accepted per §13.3 "ボタンなし" graceful degradation.
	 *
	 * @param array<int|string, mixed> $buttons Raw buttons list.
	 *
	 * @return list<ButtonView>
	 *
	 * @throws InvalidArgumentException When `$buttons` violates
	 *                                  the rules above.
	 */
	private static function normalizeButtonList( array $buttons ): array {
		$expected = 0;
		$result   = array();
		foreach ( $buttons as $key => $value ) {
			if ( $key !== $expected ) {
				$message = 'ProductCardViewModel buttons 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 ButtonView ) {
				$message = sprintf(
					'ProductCardViewModel buttons entries must be ButtonView 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;
	}

	/**
	 * Validate and normalize the `badges` field at construction.
	 *
	 * Accepts a zero-based contiguous list of {@see BadgeView}
	 * instances. Non-list shapes (associative arrays, sparse keys)
	 * and non-{@see BadgeView} entries are rejected. The empty
	 * list is accepted.
	 *
	 * @param array<int|string, mixed> $badges Raw badges list.
	 *
	 * @return list<BadgeView>
	 *
	 * @throws InvalidArgumentException When `$badges` violates
	 *                                  the rules above.
	 */
	private static function normalizeBadgeList( array $badges ): array {
		$expected = 0;
		$result   = array();
		foreach ( $badges as $key => $value ) {
			if ( $key !== $expected ) {
				$message = 'ProductCardViewModel badges 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 BadgeView ) {
				$message = sprintf(
					'ProductCardViewModel badges entries must be BadgeView 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;
	}

	/**
	 * Validate and normalize the `disclaimers` field at
	 * construction.
	 *
	 * Accepts a zero-based contiguous list of non-empty strings.
	 * Non-list shapes (associative arrays, sparse keys), non-string
	 * entries, and empty / whitespace-only strings are rejected.
	 * The empty list is accepted.
	 *
	 * @param array<int|string, mixed> $disclaimers Raw disclaimers list.
	 *
	 * @return list<string>
	 *
	 * @throws InvalidArgumentException When `$disclaimers` violates
	 *                                  the rules above.
	 */
	private static function normalizeDisclaimerList( array $disclaimers ): array {
		$expected = 0;
		$result   = array();
		foreach ( $disclaimers as $key => $value ) {
			if ( $key !== $expected ) {
				$message = 'ProductCardViewModel disclaimers 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 ( ! is_string( $value ) ) {
				$message = sprintf(
					'ProductCardViewModel disclaimers entries must be strings; 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 );
			}

			$trimmed = trim( $value );
			if ( $trimmed === '' ) {
				$message = sprintf(
					'ProductCardViewModel disclaimers entries must be non-empty strings; got empty entry at index %d.',
					$expected
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

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

		return $result;
	}
}
