<?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 \Affilun\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 \Affilun\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).
 *
 *   - `defaultColorScheme` is a closed set:
 *     `'light' | 'dark' | 'system'`. `system` follows
 *     `prefers-color-scheme` in the front-end card stylesheet.
 *
 * Instances are immutable; per-field withers return fresh
 * instances. Persistence shape is canonicalized by
 * {@see self::toArray()} / {@see self::fromArray()}.
 *
 * @package Affilun\Application\Settings
 */

declare(strict_types=1);

namespace Affilun\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' );

	/**
	 * Closed set of default card design presets.
	 */
	private const DESIGN_PRESETS = array( 'default', 'flat', 'outlined', 'soft' );

	/**
	 * Closed set of default button layouts.
	 */
	private const BUTTON_LAYOUTS = array( 'wrap', 'stacked', 'full' );

	/**
	 * Closed set of default button shapes.
	 */
	private const BUTTON_SHAPES = array( 'square', 'rounded', 'pill' );

	/**
	 * Closed set of default button tones.
	 */
	private const BUTTON_TONES = array( 'brand', 'muted', 'monochrome' );

	/**
	 * Closed set of default card color schemes.
	 */
	private const COLOR_SCHEMES = array( 'light', 'dark', 'system' );

	/**
	 * §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;

	/**
	 * Default card design preset.
	 */
	private string $defaultDesignPreset;

	/**
	 * Default button layout.
	 */
	private string $defaultButtonLayout;

	/**
	 * Default button shape.
	 */
	private string $defaultButtonShape;

	/**
	 * Default button tone.
	 */
	private string $defaultButtonTone;

	/**
	 * Default card color scheme.
	 */
	private string $defaultColorScheme;

	/**
	 * 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.
	 * @param string $defaultDesignPreset        Default card design preset.
	 * @param string $defaultButtonLayout        Default button layout.
	 * @param string $defaultButtonShape         Default button shape.
	 * @param string $defaultButtonTone          Default button tone.
	 * @param string $defaultColorScheme         Default card color scheme.
	 *
	 * @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,
		string $defaultDesignPreset = 'default',
		string $defaultButtonLayout = 'wrap',
		string $defaultButtonShape = 'rounded',
		string $defaultButtonTone = 'brand',
		string $defaultColorScheme = 'light'
	) {
		$this->defaultTemplate            = self::normalizeDefaultTemplate( $defaultTemplate );
		$this->themeOverrideEnabled       = $themeOverrideEnabled;
		$this->showProviderBadgeByDefault = $showProviderBadgeByDefault;
		$this->showDisclaimerByDefault    = $showDisclaimerByDefault;
		$this->lazyLoadImages             = $lazyLoadImages;
		$this->placeholderImagePolicy     = self::normalizePlaceholderImagePolicy( $placeholderImagePolicy );
		$this->defaultDesignPreset        = self::normalizeClosedSet( 'defaultDesignPreset', $defaultDesignPreset, self::DESIGN_PRESETS );
		$this->defaultButtonLayout        = self::normalizeClosedSet( 'defaultButtonLayout', $defaultButtonLayout, self::BUTTON_LAYOUTS );
		$this->defaultButtonShape         = self::normalizeClosedSet( 'defaultButtonShape', $defaultButtonShape, self::BUTTON_SHAPES );
		$this->defaultButtonTone          = self::normalizeClosedSet( 'defaultButtonTone', $defaultButtonTone, self::BUTTON_TONES );
		$this->defaultColorScheme         = self::normalizeClosedSet( 'defaultColorScheme', $defaultColorScheme, self::COLOR_SCHEMES );
	}

	/**
	 * 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;
	}

	/**
	 * Default card design preset.
	 */
	public function defaultDesignPreset(): string {
		return $this->defaultDesignPreset;
	}

	/**
	 * Default button layout.
	 */
	public function defaultButtonLayout(): string {
		return $this->defaultButtonLayout;
	}

	/**
	 * Default button shape.
	 */
	public function defaultButtonShape(): string {
		return $this->defaultButtonShape;
	}

	/**
	 * Default button tone.
	 */
	public function defaultButtonTone(): string {
		return $this->defaultButtonTone;
	}

	/**
	 * Default card color scheme.
	 */
	public function defaultColorScheme(): string {
		return $this->defaultColorScheme;
	}

	/**
	 * 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;
	}

	/**
	 * Produce a copy with `defaultColorScheme` replaced.
	 *
	 * @param string $defaultColorScheme New color-scheme value.
	 *
	 * @throws InvalidArgumentException When `$defaultColorScheme`
	 *                                  is off the closed set.
	 */
	public function withDefaultColorScheme( string $defaultColorScheme ): self {
		$clone                     = clone $this;
		$clone->defaultColorScheme = self::normalizeClosedSet( 'defaultColorScheme', $defaultColorScheme, self::COLOR_SCHEMES );
		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
			&& $this->defaultDesignPreset === $other->defaultDesignPreset
			&& $this->defaultButtonLayout === $other->defaultButtonLayout
			&& $this->defaultButtonShape === $other->defaultButtonShape
			&& $this->defaultButtonTone === $other->defaultButtonTone
			&& $this->defaultColorScheme === $other->defaultColorScheme;
	}

	/**
	 * 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,
			'default_design_preset'          => $this->defaultDesignPreset,
			'default_button_layout'          => $this->defaultButtonLayout,
			'default_button_shape'           => $this->defaultButtonShape,
			'default_button_tone'            => $this->defaultButtonTone,
			'default_color_scheme'           => $this->defaultColorScheme,
		);
	}

	/**
	 * 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' );
		$defaultDesignPreset        = self::optionalString( $data, 'default_design_preset', 'default' );
		$defaultButtonLayout        = self::optionalString( $data, 'default_button_layout', 'wrap' );
		$defaultButtonShape         = self::optionalString( $data, 'default_button_shape', 'rounded' );
		$defaultButtonTone          = self::optionalString( $data, 'default_button_tone', 'brand' );
		$defaultColorScheme         = self::optionalString( $data, 'default_color_scheme', 'light' );

		return new self(
			$defaultTemplate,
			$themeOverrideEnabled,
			$showProviderBadgeByDefault,
			$showDisclaimerByDefault,
			$lazyLoadImages,
			$placeholderImagePolicy,
			$defaultDesignPreset,
			$defaultButtonLayout,
			$defaultButtonShape,
			$defaultButtonTone,
			$defaultColorScheme
		);
	}

	/**
	 * 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 an optional string field from a persisted payload.
	 *
	 * @param array<string, mixed> $data     Persisted payload.
	 * @param string               $key      Field name.
	 * @param string               $fallback Fallback value.
	 */
	private static function optionalString( array $data, string $key, string $fallback ): string {
		$value = $data[ $key ] ?? null;
		return is_string( $value ) ? $value : $fallback;
	}

	/**
	 * Validate a closed-set string field.
	 *
	 * @param string       $field   Field name.
	 * @param string       $value   Candidate value.
	 * @param list<string> $allowed Allowed values.
	 */
	private static function normalizeClosedSet( string $field, string $value, array $allowed ): string {
		if ( ! in_array( $value, $allowed, true ) ) {
			$message = sprintf(
				'RenderingDefaults %s must be one of %s.',
				$field,
				implode( ', ', $allowed )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $value;
	}

	/**
	 * 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 ];
	}
}
