<?php
/**
 * Embed configuration input value object.
 *
 * Immutable Phase-5 input bundle that carries the per-instance
 * display configuration the
 * {@see \CommerceLinkBuilder\Application\Render\ViewModel\ProductCardViewModelFactory}
 * needs to translate a domain-layer
 * {@see \CommerceLinkBuilder\Domain\Product\ProductSnapshot} into a
 * §8.2 {@see \CommerceLinkBuilder\Application\Render\ViewModel\ProductCardViewModel}
 * (or §8.4 fallback).
 *
 * Phase-5 final input contract:
 *
 *   - As of slice 5h this DTO is the canonical Phase-5 input shape
 *     consumed by
 *     {@see \CommerceLinkBuilder\Application\Render\ViewModel\ProductCardViewModelFactory}.
 *     Phase-5 callers (REST controllers, the Gutenberg block
 *     server-render, the shortcode bridge, integration tests)
 *     construct {@see EmbedConfigInput} directly and pass it into
 *     the factory.
 *
 *   - The full §15 `EmbedConfig` value object that wraps a
 *     persistence row (`EmbedRecord`) — including preset id,
 *     provider-specific copy overrides, schema versioning, and
 *     persisted block-attribute serialization — is a Phase-9
 *     concern (sync / cache / persisted-row mapping). The full
 *     EmbedConfig row<→>VO mapping lands in Phase 9; Phase 5
 *     intentionally does not model it.
 *
 * Phase 5 deliberately does not yet model the complete §15
 * `EmbedConfig` shape: that type carries a much wider surface
 * (preset id, provider-specific copy overrides, schema
 * versioning, persisted block-attribute serialization, ...) than
 * the ViewModel factory needs. This DTO carries the minimal
 * closed set of fields the factory consumes today so the
 * factory's signature does not have to change when the full
 * EmbedConfig lands in Phase 9:
 *
 *   - `template` is the §6.4 template key
 *     ({@see \CommerceLinkBuilder\Domain\Render\RenderContext::template()}
 *     mirrors the same axis at the rendering surface). Required,
 *     non-empty after trimming.
 *
 *   - `buttonConfigs` is the ordered `list<ButtonConfigInput>` the
 *     §10.7 buttons-region renders. Empty list is a valid graceful-
 *     degradation outcome per §13.3 ("ボタンなし: detail URL が
 *     あれば最低限のリンクを生成可能") so callers that intentionally
 *     suppress all buttons can pass `array()`.
 *
 *   - `meta` is the §8.2 `meta` slot toggle bundle
 *     ({@see MetaFlags}). Defaults to {@see MetaFlags::defaults()}
 *     when callers want the canonical baseline; non-default
 *     combinations are produced via the {@see MetaFlags} per-axis
 *     wither helpers before being handed to this DTO.
 *
 *   - `customRootClass` is the optional caller-supplied extra CSS
 *     class string the §8.2 `classes.root` field appends after
 *     the canonical `clb-card clb-card-{template}
 *     clb-provider-{provider}` tokens. Nullable because most call
 *     sites do not need it; non-null values are trimmed and
 *     rejected when the trim leaves an empty string — "present
 *     but empty" is treated as a programming error.
 *
 *   - `prefersFallback` is the explicit caller request to render
 *     the §8.4 FallbackCardViewModel even when the snapshot
 *     itself is healthy. Drives the admin "preview fallback"
 *     toggle so block-editor users can validate the fallback
 *     surface without having to break their data. Defaults to
 *     false.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because an embed configuration is composed once per render at
 * the controller / shortcode boundary; in-place edits would only
 * invite ambiguity about which fields are authoritative.
 *
 * @package CommerceLinkBuilder\Application\Render
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Render;

use CommerceLinkBuilder\Application\Render\Builder\ButtonConfigInput;
use CommerceLinkBuilder\Application\Render\ViewModel\Parts\MetaFlags;
use InvalidArgumentException;

/**
 * Immutable Phase-5 embed configuration input bundle.
 *
 * See the file-level docblock for the rationale for carrying only
 * the minimal closed set of fields the §9.1 ViewModel factory
 * actually consumes today and the field-by-field validation rules.
 */
final class EmbedConfigInput {

	/**
	 * §6.4 template key.
	 */
	private string $template;

	/**
	 * Ordered list of button configuration inputs.
	 *
	 * @var list<ButtonConfigInput>
	 */
	private array $buttonConfigs;

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

	/**
	 * Caller-supplied extra CSS class string, or null.
	 */
	private ?string $customRootClass;

	/**
	 * Whether the caller explicitly requests fallback rendering.
	 */
	private bool $prefersFallback;

	/**
	 * Construct an embed configuration input with explicit values
	 * for every field.
	 *
	 * Validation summary:
	 *
	 *   - `template` is trimmed and rejected when empty after
	 *     trimming.
	 *   - `buttonConfigs` MUST be a `list<ButtonConfigInput>`
	 *     (zero-based, contiguous int keys, every entry a
	 *     {@see ButtonConfigInput}); associative and mixed-type
	 *     arrays are rejected.
	 *   - `customRootClass`, when non-null, is trimmed and
	 *     rejected when the trim leaves an empty string.
	 *
	 * @param string                  $template        §6.4 template key.
	 * @param list<ButtonConfigInput> $buttonConfigs   Ordered button config list.
	 * @param MetaFlags               $meta            §8.2 `meta` slot toggle bundle.
	 * @param string|null             $customRootClass Extra CSS class string, or null.
	 * @param bool                    $prefersFallback Whether fallback rendering is requested.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct(
		string $template,
		array $buttonConfigs,
		MetaFlags $meta,
		?string $customRootClass = null,
		bool $prefersFallback = false
	) {
		$this->template        = self::normalizeTemplate( $template );
		$this->buttonConfigs   = self::normalizeButtonConfigs( $buttonConfigs );
		$this->meta            = $meta;
		$this->customRootClass = self::normalizeOptionalString( 'customRootClass', $customRootClass );
		$this->prefersFallback = $prefersFallback;
	}

	/**
	 * Construct an embed configuration input with the canonical
	 * Phase-5 defaults: empty button list, default
	 * {@see MetaFlags}, no custom root class, fallback not
	 * requested.
	 *
	 * @param string $template §6.4 template key. Defaults to the
	 *                        canonical `'single-default-card'`
	 *                        Phase-5 baseline.
	 *
	 * @throws InvalidArgumentException When `$template` is empty
	 *                                  after trimming.
	 */
	public static function defaults( string $template = 'single-default-card' ): self {
		return new self(
			$template,
			array(),
			MetaFlags::defaults(),
			null,
			false
		);
	}

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

	/**
	 * Ordered list of button configuration inputs.
	 *
	 * @return list<ButtonConfigInput>
	 */
	public function buttonConfigs(): array {
		return $this->buttonConfigs;
	}

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

	/**
	 * Caller-supplied extra CSS class string, or null.
	 */
	public function customRootClass(): ?string {
		return $this->customRootClass;
	}

	/**
	 * Whether the caller explicitly requests fallback rendering.
	 */
	public function prefersFallback(): bool {
		return $this->prefersFallback;
	}

	/**
	 * Report whether this input equals the supplied one.
	 *
	 * Equality is structural: scalar fields must match with
	 * strict comparison, the {@see MetaFlags} slot must compare
	 * equal under its own `equals()`, and the
	 * {@see ButtonConfigInput} list must agree on length and
	 * per-entry equality.
	 *
	 * @param self $other Other input to compare against.
	 */
	public function equals( self $other ): bool {
		if ( $this->template !== $other->template ) {
			return false;
		}

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

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

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

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

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

		return true;
	}

	/**
	 * 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 = 'EmbedConfigInput 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 `buttonConfigs` field at
	 * construction.
	 *
	 * The array MUST be a `list<ButtonConfigInput>` (zero-based,
	 * contiguous int keys, every entry a
	 * {@see ButtonConfigInput}); associative and mixed-type
	 * arrays are rejected with a developer-facing message.
	 *
	 * @param array<int|string, mixed> $buttonConfigs Raw array as supplied by the caller.
	 *
	 * @return list<ButtonConfigInput>
	 *
	 * @throws InvalidArgumentException When `$buttonConfigs` is
	 *                                  not a list of
	 *                                  {@see ButtonConfigInput}.
	 */
	private static function normalizeButtonConfigs( array $buttonConfigs ): array {
		$expected = 0;
		$result   = array();
		foreach ( $buttonConfigs as $key => $value ) {
			if ( $key !== $expected ) {
				$message = 'EmbedConfigInput buttonConfigs 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 ButtonConfigInput ) {
				$message = 'EmbedConfigInput buttonConfigs entries must be ButtonConfigInput instances.';
				// 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 a nullable string field that, when
	 * non-null, must carry non-empty content.
	 *
	 * @param string      $field Field name for the error message.
	 * @param string|null $value Raw value, or null.
	 *
	 * @throws InvalidArgumentException When `$value` is the empty
	 *                                  string after trimming.
	 */
	private static function normalizeOptionalString( string $field, ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'EmbedConfigInput %s must be null or a non-empty string.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}
}
