<?php
/**
 * Grid layout value object.
 *
 * Immutable value object that carries the §8.3 `layout` slot a
 * §8.3 ProductGridViewModel exposes to its template. The type
 * collects the two layout axes the §8.3 canonical structure
 * enumerates — `columns` and `gap` — into a single validated value
 * so renderers and templates can branch on the grid geometry
 * without re-deriving the column count or the gap-token from a
 * sibling EmbedConfig at render time.
 *
 * Field semantics match `specifications/rendering-and-template-spec.md`
 * §8.3 exactly (the `layout` sub-object in the canonical
 * ProductGridViewModel structure):
 *
 *   - `columns` is the §8.3 column count the renderer stamps onto
 *     the grid root (typically as the `clb-columns-{n}` BEM
 *     modifier on the root class string). Bounded to the 1..6
 *     inclusive range so a configuration typo never produces a
 *     zero-column or twelve-column layout the CSS does not cover.
 *
 *   - `gap` is the §8.3 gap-token the renderer maps to a CSS gap
 *     size. Closed-set: `'none'`, `'small'`, `'medium'`, `'large'`.
 *     Held as a token rather than a CSS length so the template
 *     layer is free to map the token to either a fixed length or
 *     a CSS-variable lookup.
 *
 * The canonical "frontend defaults" branch is exposed as a static
 * factory {@see self::defaults()} so call sites do not have to
 * spell out the §8.3 example values (`columns = 3`,
 * `gap = 'medium'`) explicitly.
 *
 * Instances are immutable.
 *
 * @package CommerceLinkBuilder\Application\Render\ViewModel\Parts
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Render\ViewModel\Parts;

use InvalidArgumentException;

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

	/**
	 * §8.3 `gap = "none"` token.
	 */
	public const GAP_NONE = 'none';

	/**
	 * §8.3 `gap = "small"` token.
	 */
	public const GAP_SMALL = 'small';

	/**
	 * §8.3 `gap = "medium"` token.
	 */
	public const GAP_MEDIUM = 'medium';

	/**
	 * §8.3 `gap = "large"` token.
	 */
	public const GAP_LARGE = 'large';

	/**
	 * Inclusive lower bound for the §8.3 `columns` axis.
	 */
	private const MIN_COLUMNS = 1;

	/**
	 * Inclusive upper bound for the §8.3 `columns` axis.
	 */
	private const MAX_COLUMNS = 6;

	/**
	 * Closed set of accepted §8.3 gap tokens, in declaration
	 * order. Maintained as a class-level constant so the validator
	 * and the test suite can iterate the same source of truth.
	 *
	 * @var list<string>
	 */
	private const VALID_GAPS = array(
		self::GAP_NONE,
		self::GAP_SMALL,
		self::GAP_MEDIUM,
		self::GAP_LARGE,
	);

	/**
	 * §8.3 grid column count, bounded to {@see self::MIN_COLUMNS}
	 * .. {@see self::MAX_COLUMNS} inclusive.
	 */
	private int $columns;

	/**
	 * §8.3 gap token, one of {@see self::VALID_GAPS}.
	 */
	private string $gap;

	/**
	 * Construct a grid-layout view with explicit values for every
	 * axis.
	 *
	 * Validation summary:
	 *
	 *   - `columns` MUST satisfy
	 *     {@see self::MIN_COLUMNS} <= `columns` <=
	 *     {@see self::MAX_COLUMNS}; out-of-range values raise
	 *     {@see InvalidArgumentException}.
	 *   - `gap` is trimmed and matched against
	 *     {@see self::VALID_GAPS}; out-of-set tokens are rejected
	 *     with the offending value verbatim and the accepted-token
	 *     list for diagnosis.
	 *
	 * @param int    $columns §8.3 grid column count.
	 * @param string $gap     §8.3 gap token.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct( int $columns, string $gap ) {
		$this->columns = self::normalizeColumns( $columns );
		$this->gap     = self::normalizeGap( $gap );
	}

	/**
	 * Construct a grid-layout view with the canonical §8.3
	 * example values.
	 *
	 * Returns a view with `columns = 3` and `gap = 'medium'` —
	 * the §8.3 example structure's `layout` slot, verbatim.
	 */
	public static function defaults(): self {
		return new self( 3, self::GAP_MEDIUM );
	}

	/**
	 * Closed set of accepted §8.3 gap tokens, in declaration
	 * order.
	 *
	 * @return list<string>
	 */
	public static function validGaps(): array {
		return self::VALID_GAPS;
	}

	/**
	 * §8.3 grid column count.
	 */
	public function columns(): int {
		return $this->columns;
	}

	/**
	 * §8.3 gap token.
	 */
	public function gap(): string {
		return $this->gap;
	}

	/**
	 * Report whether this view equals the supplied one.
	 *
	 * Equality is structural: every field must match with strict
	 * comparison.
	 *
	 * @param self $other Other view to compare against.
	 */
	public function equals( self $other ): bool {
		return $this->columns === $other->columns
			&& $this->gap === $other->gap;
	}

	/**
	 * Project this view to its §8.3 canonical array shape.
	 *
	 * @return array{columns: int, gap: string}
	 */
	public function toArray(): array {
		return array(
			'columns' => $this->columns,
			'gap'     => $this->gap,
		);
	}

	/**
	 * Validate and normalize the `columns` field at construction.
	 *
	 * @param int $columns Raw column count as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$columns` is outside
	 *                                  the {@see self::MIN_COLUMNS}
	 *                                  .. {@see self::MAX_COLUMNS}
	 *                                  range.
	 */
	private static function normalizeColumns( int $columns ): int {
		if ( $columns < self::MIN_COLUMNS || $columns > self::MAX_COLUMNS ) {
			$message = sprintf(
				'GridLayout columns must be between %d and %d inclusive; got %d.',
				self::MIN_COLUMNS,
				self::MAX_COLUMNS,
				$columns
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $columns;
	}

	/**
	 * Validate and normalize the `gap` 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
	 * appear in {@see self::VALID_GAPS}.
	 *
	 * @param string $gap Raw token as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$gap` is not a
	 *                                  member of the closed set.
	 */
	private static function normalizeGap( string $gap ): string {
		$trimmed = trim( $gap );

		if ( ! in_array( $trimmed, self::VALID_GAPS, true ) ) {
			$message = sprintf(
				'GridLayout gap must be one of [%s]; got "%s".',
				implode( ', ', self::VALID_GAPS ),
				$gap
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}
}
