<?php
/**
 * Rendering defaults value object.
 *
 * Immutable Phase-8 value object that carries the §13.5
 * Rendering tab settings of
 * `specifications/admin-ui-spec.md`. The fields mirror the
 * tab's UI controls one-to-one:
 *
 *   - `defaultTemplate` is the §6.4 template key the
 *     {@see \CommerceLinkBuilder\Application\Render\EmbedConfigInput}
 *     defaults to when an embed configuration does not name one
 *     explicitly. Validated as a non-empty trimmed string here;
 *     full template-key grammar is enforced at the
 *     {@see \CommerceLinkBuilder\Domain\Render\TemplateKey}
 *     boundary.
 *
 *   - `themeOverrideEnabled` flips whether theme template
 *     overrides are searched for at render time.
 *
 *   - `showProviderBadgeByDefault` / `showDisclaimerByDefault`
 *     are the per-block default toggles that the §8.2
 *     ProductCardViewModel meta slot consumes when an embed
 *     does not override them.
 *
 *   - `lazyLoadImages` controls whether `loading="lazy"` is
 *     emitted on product images.
 *
 *   - `placeholderImagePolicy` is a closed set:
 *     `'none' | 'transparent' | 'low-res'` (matching the §13.5
 *     "Placeholder Image Policy" control).
 *
 * Instances are immutable; per-field withers return fresh
 * instances. Persistence shape is canonicalized by
 * {@see self::toArray()} / {@see self::fromArray()}.
 *
 * @package CommerceLinkBuilder\Application\Settings
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Settings;

use InvalidArgumentException;

/**
 * Immutable §13.5 Rendering tab settings bundle.
 *
 * See the file-level docblock for the field-by-field mapping
 * to the §13.5 admin-ui controls and the closed-set rule that
 * gates `placeholderImagePolicy`.
 */
final class RenderingDefaults {

	/**
	 * Closed set of allowed `placeholderImagePolicy` values.
	 */
	private const PLACEHOLDER_POLICIES = array( 'none', 'transparent', 'low-res' );

	/**
	 * §6.4 template key used as the embed-config default.
	 */
	private string $defaultTemplate;

	/**
	 * Whether theme template overrides are searched for at
	 * render time.
	 */
	private bool $themeOverrideEnabled;

	/**
	 * Whether the §8.2 provider-badge slot is shown by default.
	 */
	private bool $showProviderBadgeByDefault;

	/**
	 * Whether the §8.2 disclaimer slot is shown by default.
	 */
	private bool $showDisclaimerByDefault;

	/**
	 * Whether `loading="lazy"` is emitted on product images.
	 */
	private bool $lazyLoadImages;

	/**
	 * Placeholder image policy (closed set:
	 * `'none' | 'transparent' | 'low-res'`).
	 */
	private string $placeholderImagePolicy;

	/**
	 * Construct a rendering-defaults snapshot with explicit
	 * values for every field.
	 *
	 * @param string $defaultTemplate            §6.4 template key.
	 * @param bool   $themeOverrideEnabled       Whether theme overrides are searched.
	 * @param bool   $showProviderBadgeByDefault Whether the provider badge is shown by default.
	 * @param bool   $showDisclaimerByDefault    Whether the disclaimer is shown by default.
	 * @param bool   $lazyLoadImages             Whether image lazy-loading is emitted.
	 * @param string $placeholderImagePolicy    Placeholder image policy.
	 *
	 * @throws InvalidArgumentException When `$defaultTemplate`
	 *                                  is empty after trimming
	 *                                  or when
	 *                                  `$placeholderImagePolicy`
	 *                                  is off the closed set.
	 */
	public function __construct(
		string $defaultTemplate,
		bool $themeOverrideEnabled,
		bool $showProviderBadgeByDefault,
		bool $showDisclaimerByDefault,
		bool $lazyLoadImages,
		string $placeholderImagePolicy
	) {
		$this->defaultTemplate            = self::normalizeDefaultTemplate( $defaultTemplate );
		$this->themeOverrideEnabled       = $themeOverrideEnabled;
		$this->showProviderBadgeByDefault = $showProviderBadgeByDefault;
		$this->showDisclaimerByDefault    = $showDisclaimerByDefault;
		$this->lazyLoadImages             = $lazyLoadImages;
		$this->placeholderImagePolicy     = self::normalizePlaceholderImagePolicy( $placeholderImagePolicy );
	}

	/**
	 * Construct a rendering-defaults snapshot with the canonical
	 * §13.5 baseline values.
	 */
	public static function defaults(): self {
		return new self(
			'single-default-card',
			true,
			false,
			true,
			true,
			'transparent'
		);
	}

	/**
	 * §6.4 template key used as the embed-config default.
	 */
	public function defaultTemplate(): string {
		return $this->defaultTemplate;
	}

	/**
	 * Whether theme template overrides are searched.
	 */
	public function themeOverrideEnabled(): bool {
		return $this->themeOverrideEnabled;
	}

	/**
	 * Whether the §8.2 provider-badge slot is shown by default.
	 */
	public function showProviderBadgeByDefault(): bool {
		return $this->showProviderBadgeByDefault;
	}

	/**
	 * Whether the §8.2 disclaimer slot is shown by default.
	 */
	public function showDisclaimerByDefault(): bool {
		return $this->showDisclaimerByDefault;
	}

	/**
	 * Whether image lazy-loading is emitted.
	 */
	public function lazyLoadImages(): bool {
		return $this->lazyLoadImages;
	}

	/**
	 * Placeholder image policy.
	 */
	public function placeholderImagePolicy(): string {
		return $this->placeholderImagePolicy;
	}

	/**
	 * Produce a copy with `defaultTemplate` replaced.
	 *
	 * @param string $defaultTemplate New template key.
	 *
	 * @throws InvalidArgumentException When `$defaultTemplate`
	 *                                  is empty after trimming.
	 */
	public function withDefaultTemplate( string $defaultTemplate ): self {
		$clone                  = clone $this;
		$clone->defaultTemplate = self::normalizeDefaultTemplate( $defaultTemplate );
		return $clone;
	}

	/**
	 * Produce a copy with `themeOverrideEnabled` replaced.
	 *
	 * @param bool $themeOverrideEnabled New flag.
	 */
	public function withThemeOverrideEnabled( bool $themeOverrideEnabled ): self {
		$clone                       = clone $this;
		$clone->themeOverrideEnabled = $themeOverrideEnabled;
		return $clone;
	}

	/**
	 * Produce a copy with `showProviderBadgeByDefault` replaced.
	 *
	 * @param bool $showProviderBadgeByDefault New flag.
	 */
	public function withShowProviderBadgeByDefault( bool $showProviderBadgeByDefault ): self {
		$clone                             = clone $this;
		$clone->showProviderBadgeByDefault = $showProviderBadgeByDefault;
		return $clone;
	}

	/**
	 * Produce a copy with `showDisclaimerByDefault` replaced.
	 *
	 * @param bool $showDisclaimerByDefault New flag.
	 */
	public function withShowDisclaimerByDefault( bool $showDisclaimerByDefault ): self {
		$clone                          = clone $this;
		$clone->showDisclaimerByDefault = $showDisclaimerByDefault;
		return $clone;
	}

	/**
	 * Produce a copy with `lazyLoadImages` replaced.
	 *
	 * @param bool $lazyLoadImages New flag.
	 */
	public function withLazyLoadImages( bool $lazyLoadImages ): self {
		$clone                 = clone $this;
		$clone->lazyLoadImages = $lazyLoadImages;
		return $clone;
	}

	/**
	 * Produce a copy with `placeholderImagePolicy` replaced.
	 *
	 * @param string $placeholderImagePolicy New policy value.
	 *
	 * @throws InvalidArgumentException When `$placeholderImagePolicy`
	 *                                  is off the closed set.
	 */
	public function withPlaceholderImagePolicy( string $placeholderImagePolicy ): self {
		$clone                         = clone $this;
		$clone->placeholderImagePolicy = self::normalizePlaceholderImagePolicy( $placeholderImagePolicy );
		return $clone;
	}

	/**
	 * Report whether this snapshot equals the supplied one.
	 *
	 * Equality is structural: every field must match under
	 * strict comparison.
	 *
	 * @param self $other Other snapshot to compare against.
	 */
	public function equals( self $other ): bool {
		return $this->defaultTemplate === $other->defaultTemplate
			&& $this->themeOverrideEnabled === $other->themeOverrideEnabled
			&& $this->showProviderBadgeByDefault === $other->showProviderBadgeByDefault
			&& $this->showDisclaimerByDefault === $other->showDisclaimerByDefault
			&& $this->lazyLoadImages === $other->lazyLoadImages
			&& $this->placeholderImagePolicy === $other->placeholderImagePolicy;
	}

	/**
	 * Serialize this snapshot for OptionStore persistence.
	 *
	 * @return array<string, mixed>
	 */
	public function toArray(): array {
		return array(
			'default_template'               => $this->defaultTemplate,
			'theme_override_enabled'         => $this->themeOverrideEnabled,
			'show_provider_badge_by_default' => $this->showProviderBadgeByDefault,
			'show_disclaimer_by_default'     => $this->showDisclaimerByDefault,
			'lazy_load_images'               => $this->lazyLoadImages,
			'placeholder_image_policy'       => $this->placeholderImagePolicy,
		);
	}

	/**
	 * Construct a snapshot from a {@see self::toArray()} payload.
	 *
	 * @param array<string, mixed> $data Persisted payload.
	 *
	 * @throws InvalidArgumentException When `$data` is malformed.
	 */
	public static function fromArray( array $data ): self {
		$defaultTemplate            = self::requireString( $data, 'default_template' );
		$themeOverrideEnabled       = self::requireBool( $data, 'theme_override_enabled' );
		$showProviderBadgeByDefault = self::requireBool( $data, 'show_provider_badge_by_default' );
		$showDisclaimerByDefault    = self::requireBool( $data, 'show_disclaimer_by_default' );
		$lazyLoadImages             = self::requireBool( $data, 'lazy_load_images' );
		$placeholderImagePolicy     = self::requireString( $data, 'placeholder_image_policy' );

		return new self(
			$defaultTemplate,
			$themeOverrideEnabled,
			$showProviderBadgeByDefault,
			$showDisclaimerByDefault,
			$lazyLoadImages,
			$placeholderImagePolicy
		);
	}

	/**
	 * Validate and normalize the `defaultTemplate` field at
	 * construction.
	 *
	 * @param string $defaultTemplate Raw template key.
	 *
	 * @throws InvalidArgumentException When empty after trimming.
	 */
	private static function normalizeDefaultTemplate( string $defaultTemplate ): string {
		$trimmed = trim( $defaultTemplate );
		if ( $trimmed === '' ) {
			$message = 'RenderingDefaults defaultTemplate 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 `placeholderImagePolicy` field
	 * at construction.
	 *
	 * @param string $placeholderImagePolicy Raw policy value.
	 *
	 * @throws InvalidArgumentException When off the closed set.
	 */
	private static function normalizePlaceholderImagePolicy( string $placeholderImagePolicy ): string {
		if ( ! in_array( $placeholderImagePolicy, self::PLACEHOLDER_POLICIES, true ) ) {
			$message = sprintf(
				'RenderingDefaults placeholderImagePolicy must be one of %s.',
				implode( ', ', self::PLACEHOLDER_POLICIES )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $placeholderImagePolicy;
	}

	/**
	 * Read a required string field from a {@see self::fromArray()}
	 * payload.
	 *
	 * @param array<string, mixed> $data Persisted payload.
	 * @param string               $key  Field name.
	 *
	 * @throws InvalidArgumentException When the field is missing
	 *                                  or not a string.
	 */
	private static function requireString( array $data, string $key ): string {
		if ( ! array_key_exists( $key, $data ) || ! is_string( $data[ $key ] ) ) {
			$message = sprintf(
				'RenderingDefaults payload must carry a string `%s`.',
				$key
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $data[ $key ];
	}

	/**
	 * Read a required bool field from a {@see self::fromArray()}
	 * payload.
	 *
	 * @param array<string, mixed> $data Persisted payload.
	 * @param string               $key  Field name.
	 *
	 * @throws InvalidArgumentException When the field is missing
	 *                                  or not a bool.
	 */
	private static function requireBool( array $data, string $key ): bool {
		if ( ! array_key_exists( $key, $data ) || ! is_bool( $data[ $key ] ) ) {
			$message = sprintf(
				'RenderingDefaults payload must carry a bool `%s`.',
				$key
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $data[ $key ];
	}
}
