<?php
/**
 * Product grid view model factory.
 *
 * Phase-10 §9.2 ViewModel factory that translates a list of
 * domain-layer
 * {@see \CommerceLinkBuilder\Domain\Product\ProductSnapshot}
 * inputs (some of which may be null when the upstream lookup
 * could not resolve the requested product id) into a
 * §8.3 {@see ProductGridViewModel} the multi-product grid
 * template can render verbatim.
 *
 * Mapping responsibilities follow `specifications/rendering-and-template-spec.md`
 * §9.2 (factory inputs / outputs / responsibilities), §8.3
 * (target ProductGridViewModel shape), and §13.5 (graceful
 * degradation completion criteria for grids):
 *
 *   - The factory is a pure orchestrator: it owns no I/O of its
 *     own and delegates per-item card rendering verbatim to the
 *     injected
 *     {@see ProductCardViewModelFactory}. The card factory's
 *     §13.3 graceful-degradation contract — null snapshot →
 *     {@see FallbackCardViewModel} with reason
 *     `REASON_PRODUCT_DELETED`; empty title →
 *     `REASON_INSUFFICIENT_DATA`; unavailable status →
 *     `REASON_PROVIDER_UNAVAILABLE`; caller-driven
 *     `prefersFallback` → `REASON_INSUFFICIENT_DATA` — is reused
 *     unchanged so a grid containing a mix of healthy and broken
 *     products downgrades cell-by-cell rather than taking the
 *     whole grid out (the §13.5 完了条件 "欠損商品や broken 状態
 *     でも安全に劣化表示できる" guarantee).
 *
 *   - Manual-order preservation: the input snapshot list is the
 *     authoritative display order. The factory iterates by
 *     index and emits items in the same order so caller-curated
 *     grids (e.g. ranking layouts) render exactly as configured.
 *
 *   - Robustness: the grid never short-circuits. Every input
 *     slot becomes some renderable VM — either a
 *     {@see ProductCardViewModel} (healthy) or a
 *     {@see FallbackCardViewModel} (downgraded). An entirely
 *     null snapshot list still produces a valid grid (all
 *     fallback cells); only an empty snapshot list (no slots at
 *     all) is rejected as a programming error at the factory
 *     boundary, since §8.3 requires at least one item.
 *
 *   - Template split: the §8.3 canonical structure carries two
 *     distinct template keys — one at the grid level
 *     (e.g. `'grid-default'`, supplied via
 *     `EmbedConfigInput::template()`) and a separate per-item
 *     template the inner {@see ProductCardViewModel} entries
 *     reference (e.g. `'single-compact-card'` per the §6.4
 *     canonical key list, mirroring the §8.3 example's inner
 *     `compact-card` reference). The grid-level template drives
 *     the §8.3 root class string and the {@see ProductGridViewModel}'s
 *     own `template` field; the item-level template drives each
 *     inner card's template (and its own BEM class composition
 *     inside the card factory). Because these axes are
 *     independent the {@see self::build()} signature accepts
 *     them as separate arguments rather than coupling them.
 *
 *   - Per-item context: each card receives an item-specific
 *     {@see RenderContext} produced via
 *     {@see RenderContext::withTemplate()} so the inner card
 *     factory's `context->template()` reads the item template,
 *     while every other axis of the caller-supplied context
 *     (surface, locale, AMP / feed flags, tracking,
 *     lazy-loading, link target / rel policy, debug, request
 *     id) is preserved verbatim. The per-item
 *     {@see EmbedConfigInput} mirrors the same template
 *     substitution while preserving the caller's button
 *     configs, meta toggles, custom root class, and
 *     `prefersFallback` flag.
 *
 *   - Meta toggle reuse: the §8.2 / §8.3 visibility toggles are
 *     a single shared concept; the same {@see MetaFlags} VO
 *     instance is forwarded both to the per-item card factory
 *     (where it drives §10.x display rules) and to the
 *     {@see ProductGridViewModel}'s own `meta` slot.
 *
 *   - Class composition: the §8.3 `classes.root` is composed as
 *     `'clb-grid clb-grid-{template} clb-columns-{columns}'`
 *     where `{template}` is the trimmed grid-level template key
 *     and `{columns}` is the layout column count. When
 *     `EmbedConfigInput::customRootClass()` is non-null it is
 *     appended after the canonical tokens. The §8.3
 *     `classes.item` is the fixed token `'clb-grid-item'`.
 *
 *   - N+1 avoidance: this factory does NOT load products. The
 *     caller is expected to bulk-load via
 *     {@see \CommerceLinkBuilder\Domain\Product\ProductRepository::findByIds()}
 *     (Phase-10 slice 10a) and convert the resulting records to
 *     snapshots before invoking {@see self::build()}. Holding
 *     the load boundary at the controller / use-case layer keeps
 *     the factory itself pure and lets a single bulk query
 *     populate every cell of an N-item grid.
 *
 * Instances carry only the injected per-item card factory and
 * the {@see self::build()} method is otherwise pure: same input,
 * same output, no I/O, no globals.
 *
 * @package CommerceLinkBuilder\Application\Render\ViewModel
 * @see ProductCardViewModelFactory
 * @see ProductGridViewModel
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Render\ViewModel;

use CommerceLinkBuilder\Application\Render\EmbedConfigInput;
use CommerceLinkBuilder\Application\Render\ViewModel\Parts\GridClasses;
use CommerceLinkBuilder\Application\Render\ViewModel\Parts\GridLayout;
use CommerceLinkBuilder\Domain\Affiliate\AffiliateLinkResult;
use CommerceLinkBuilder\Domain\Product\ProductSnapshot;
use CommerceLinkBuilder\Domain\Render\RenderContext;
use InvalidArgumentException;

/**
 * Phase-10 §9.2 ViewModel factory composing a §8.3
 * {@see ProductGridViewModel} from a list of snapshots.
 *
 * See the file-level docblock for the mapping to
 * `rendering-and-template-spec.md` §9.2 / §8.3 / §13.5 and the
 * grid-level vs item-level template split.
 */
final class ProductGridViewModelFactory {

	/**
	 * §6.4 default per-item template key.
	 *
	 * The §8.3 canonical example references the inner
	 * `compact-card` template; the §6.4 canonical key list spells
	 * the same template as `single-compact-card`, which is the
	 * value used here so the default agrees with the
	 * registry-canonical naming.
	 */
	public const DEFAULT_ITEM_TEMPLATE = 'single-compact-card';

	/**
	 * §8.3 fixed grid-item BEM class string.
	 */
	private const GRID_ITEM_CLASS = 'clb-grid-item';

	/**
	 * Phase-5 per-item card factory the grid factory delegates
	 * each cell render to.
	 */
	private ProductCardViewModelFactory $cardFactory;

	/**
	 * Construct a grid factory with an explicit per-item card
	 * factory.
	 *
	 * @param ProductCardViewModelFactory $cardFactory Per-item
	 *                                                 §9.1 card
	 *                                                 factory.
	 */
	public function __construct( ProductCardViewModelFactory $cardFactory ) {
		$this->cardFactory = $cardFactory;
	}

	/**
	 * Build the §8.3 {@see ProductGridViewModel} the renderer
	 * will consume.
	 *
	 * Validation summary:
	 *
	 *   - `$snapshots` MUST be a zero-based contiguous list whose
	 *     entries are each null or a {@see ProductSnapshot}. An
	 *     empty list is rejected (a §8.3 grid requires at least
	 *     one item slot); null entries are accepted and produce a
	 *     {@see FallbackCardViewModel} via the per-item card
	 *     factory's §13.3 `REASON_PRODUCT_DELETED` branch.
	 *
	 *   - `$affiliateResultsByItem` MUST be a zero-based
	 *     contiguous list whose length equals the snapshot list.
	 *     Each inner entry is forwarded verbatim to the per-item
	 *     card factory and is validated there against the
	 *     `EmbedConfigInput::buttonConfigs()` length contract.
	 *
	 *   - `$itemTemplate` is trimmed and rejected when empty
	 *     after trimming; format validation lives at the
	 *     TemplateResolver.
	 *
	 * @param list<?ProductSnapshot>          $snapshots              Per-item snapshots, in display order.
	 * @param EmbedConfigInput                $gridConfig             Shared per-instance display configuration.
	 * @param RenderContext                   $context                Active render context.
	 * @param GridLayout                      $layout                 §8.3 `layout` slot.
	 * @param list<list<AffiliateLinkResult>> $affiliateResultsByItem Per-item pre-built affiliate results.
	 * @param string                          $itemTemplate           §6.4 per-item template key.
	 *
	 * @throws InvalidArgumentException When any input violates
	 *                                  the rules above.
	 */
	public function build(
		array $snapshots,
		EmbedConfigInput $gridConfig,
		RenderContext $context,
		GridLayout $layout,
		array $affiliateResultsByItem,
		string $itemTemplate = self::DEFAULT_ITEM_TEMPLATE
	): ProductGridViewModel {
		$snapshots              = self::normalizeSnapshotList( $snapshots );
		$affiliateResultsByItem = self::normalizeAffiliateResultList(
			$affiliateResultsByItem,
			count( $snapshots )
		);

		if ( $snapshots === array() ) {
			$message = 'ProductGridViewModelFactory snapshots must be a non-empty list.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$itemTemplateNormalized = self::normalizeTemplate( 'itemTemplate', $itemTemplate );
		$gridTemplateNormalized = self::normalizeTemplate( 'gridConfig.template', $gridConfig->template() );

		$itemContext = $context->withTemplate( $itemTemplateNormalized );
		$itemConfig  = new EmbedConfigInput(
			$itemTemplateNormalized,
			$gridConfig->buttonConfigs(),
			$gridConfig->meta(),
			$gridConfig->customRootClass(),
			$gridConfig->prefersFallback()
		);

		$items = array();
		foreach ( $snapshots as $index => $snapshot ) {
			$items[] = $this->cardFactory->build(
				$snapshot,
				$itemConfig,
				$itemContext,
				$affiliateResultsByItem[ $index ]
			);
		}

		$classes = new GridClasses(
			self::composeRootClass(
				$gridTemplateNormalized,
				$layout->columns(),
				$gridConfig->customRootClass()
			),
			self::GRID_ITEM_CLASS
		);

		return new ProductGridViewModel(
			ProductGridViewModel::TYPE,
			$gridTemplateNormalized,
			$layout,
			$items,
			$gridConfig->meta(),
			$classes
		);
	}

	/**
	 * Validate and normalize the `$snapshots` argument shape.
	 *
	 * Accepts a zero-based contiguous list whose entries are each
	 * null or a {@see ProductSnapshot}; associative arrays,
	 * sparse keys, and wrong-typed object entries are all
	 * rejected.
	 *
	 * @param array<int|string, mixed> $snapshots Raw snapshot list.
	 *
	 * @return list<?ProductSnapshot>
	 *
	 * @throws InvalidArgumentException When `$snapshots` violates
	 *                                  the rules above.
	 */
	private static function normalizeSnapshotList( array $snapshots ): array {
		$expected = 0;
		$result   = array();
		foreach ( $snapshots as $key => $value ) {
			if ( $key !== $expected ) {
				$message = 'ProductGridViewModelFactory snapshots 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 !== null && ! $value instanceof ProductSnapshot ) {
				$actual_type = is_object( $value ) ? get_class( $value ) : gettype( $value );
				$message     = sprintf(
					'ProductGridViewModelFactory snapshots entries must be null or ProductSnapshot;'
						. ' 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;
	}

	/**
	 * Validate and normalize the `$affiliateResultsByItem`
	 * argument shape.
	 *
	 * Accepts a zero-based contiguous list whose entries are each
	 * a `list<AffiliateLinkResult>`. The outer length MUST equal
	 * `$expectedLength`. Inner-list entries are forwarded to the
	 * per-item card factory verbatim and re-validated there.
	 *
	 * @param array<int|string, mixed> $affiliateResultsByItem Raw outer list.
	 * @param int                      $expectedLength         Required outer length.
	 *
	 * @return list<list<AffiliateLinkResult>>
	 *
	 * @throws InvalidArgumentException When the outer shape
	 *                                  violates the rules above.
	 */
	private static function normalizeAffiliateResultList(
		array $affiliateResultsByItem,
		int $expectedLength
	): array {
		if ( count( $affiliateResultsByItem ) !== $expectedLength ) {
			$message = sprintf(
				'ProductGridViewModelFactory expected %d affiliateResultsByItem entries to match snapshots; got %d.',
				$expectedLength,
				count( $affiliateResultsByItem )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$expected = 0;
		$result   = array();
		foreach ( $affiliateResultsByItem as $key => $value ) {
			if ( $key !== $expected ) {
				$message = 'ProductGridViewModelFactory affiliateResultsByItem 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_array( $value ) ) {
				$message = sprintf(
					'ProductGridViewModelFactory affiliateResultsByItem entries must be lists; got %s at index %d.',
					gettype( $value ),
					$expected
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$result[] = array_values( $value );
			++$expected;
		}

		return $result;
	}

	/**
	 * Validate and normalize a non-empty trimmed template field.
	 *
	 * @param string $field Field name for the error message.
	 * @param string $value Raw template key as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$value` is empty
	 *                                  after trimming.
	 */
	private static function normalizeTemplate( string $field, string $value ): string {
		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'ProductGridViewModelFactory %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;
	}

	/**
	 * Compose the §8.3 `classes.root` BEM string.
	 *
	 * Produces `'clb-grid clb-grid-{template} clb-columns-{columns}'`
	 * and appends `$customRootClass` when non-null.
	 *
	 * @param string      $template        Trimmed grid-level template key.
	 * @param int         $columns         §8.3 grid column count.
	 * @param string|null $customRootClass Optional caller-supplied extra class.
	 */
	private static function composeRootClass(
		string $template,
		int $columns,
		?string $customRootClass
	): string {
		$root = sprintf( 'clb-grid clb-grid-%s clb-columns-%d', $template, $columns );

		if ( $customRootClass !== null ) {
			$root .= ' ' . $customRootClass;
		}

		return $root;
	}
}
