<?php
/**
 * Button view object.
 *
 * Immutable value object that carries the §10.7 button-display
 * fields the rendering pipeline needs in order to populate a single
 * entry of the `buttons` slot of a §8.2 ProductCardViewModel. The
 * type collects the provider key, pre-formatted label, target URL,
 * window-target attribute, pre-tokenized `rel` value, pre-tokenized
 * CSS class string, and pre-formatted aria-label into a single
 * validated value so renderers and templates can emit a complete
 * `<a class="..." rel="..." aria-label="...">` element without
 * re-deriving any of those fields from a sibling RenderContext or
 * ProviderCapabilities map at render time.
 *
 * Field semantics match `specifications/rendering-and-template-spec.md`
 * §10.7 exactly:
 *
 *   - `provider` is the lowercase provider key the button targets
 *     (`amazon`, `rakuten`, `yahoo_shopping`, ...). Required,
 *     non-empty after trimming. The closed set lives at the
 *     Provider registry, not on this DTO.
 *
 *   - `label` is the §10.7 "ボタン属性 / label" pre-formatted
 *     button label ("Amazon", "楽天市場で見る"). Required,
 *     non-empty after trimming.
 *
 *   - `url` is the §10.7 "ボタン属性 / url" target URL. Required,
 *     non-empty after trimming, and MUST start with `http://` or
 *     `https://` so a renderer cannot accidentally emit a
 *     `javascript:` or `data:` URI.
 *
 *   - `target` is the §10.7 "ボタン属性 / target" window-target
 *     attribute. Nullable because the link-attribute builder may
 *     defer the attribute on AMP / feed surfaces. When non-null,
 *     it MUST be one of `'_blank'`, `'_self'`, `'_parent'`, or
 *     `'_top'`.
 *
 *   - `rel` is the §10.7 "ボタン属性 / rel" pre-tokenized
 *     attribute value ("nofollow sponsored noopener noreferrer").
 *     Held verbatim because the link-attribute builder is
 *     responsible for the policy-to-token mapping; this DTO only
 *     verifies non-emptiness.
 *
 *   - `className` is the §10.7 "ボタン属性 / class_name"
 *     pre-tokenized BEM class string ("affilun-button
 *     affilun-button-amazon"). Held verbatim, non-empty.
 *
 *   - `ariaLabel` is the §10.7 "aria-label 例" pre-formatted
 *     accessibility label ("Sample Product を Amazon で見る").
 *     Required, non-empty after trimming.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because a button view is composed once per render at the
 * ViewModel-factory boundary.
 *
 * @package Affilun\Application\Render\ViewModel\Parts
 */

declare(strict_types=1);

namespace Affilun\Application\Render\ViewModel\Parts;

use InvalidArgumentException;

/**
 * Immutable button-view used by §8.2 ProductCardViewModel and the
 * §10.7 button rendering rules.
 *
 * See the file-level docblock for the mapping to
 * `rendering-and-template-spec.md` §10.7 and the per-field
 * validation rules.
 */
final class ButtonView {

	/**
	 * §10.7 `target="_blank"` token.
	 */
	public const TARGET_BLANK = '_blank';

	/**
	 * §10.7 `target="_self"` token.
	 */
	public const TARGET_SELF = '_self';

	/**
	 * §10.7 `target="_parent"` token.
	 */
	public const TARGET_PARENT = '_parent';

	/**
	 * §10.7 `target="_top"` token.
	 */
	public const TARGET_TOP = '_top';

	/**
	 * Closed set of accepted §10.7 target tokens, in declaration
	 * order.
	 *
	 * @var list<string>
	 */
	private const VALID_TARGETS = array(
		self::TARGET_BLANK,
		self::TARGET_SELF,
		self::TARGET_PARENT,
		self::TARGET_TOP,
	);

	/**
	 * Lowercase provider key the button targets.
	 */
	private string $provider;

	/**
	 * Pre-formatted button label.
	 */
	private string $label;

	/**
	 * Absolute target URL (`http://` or `https://`).
	 */
	private string $url;

	/**
	 * One of the {@see self::TARGET_*} tokens, or null.
	 */
	private ?string $target;

	/**
	 * Pre-tokenized `rel` attribute value.
	 */
	private string $rel;

	/**
	 * Pre-tokenized BEM class string.
	 */
	private string $className;

	/**
	 * Pre-formatted accessibility label.
	 */
	private string $ariaLabel;

	/**
	 * Construct a button view with explicit values for every
	 * field.
	 *
	 * Validation summary:
	 *
	 *   - `provider`, `label`, `rel`, `className`, `ariaLabel` are
	 *     trimmed and rejected when empty after trimming.
	 *   - `url` is trimmed, rejected when empty, and rejected when
	 *     it does not start with `http://` or `https://`.
	 *   - `target`, when non-null, is trimmed and matched against
	 *     {@see self::VALID_TARGETS}.
	 *
	 * @param string      $provider  Lowercase provider key.
	 * @param string      $label     Pre-formatted button label.
	 * @param string      $url       Absolute target URL.
	 * @param string|null $target    Window-target attribute, or null.
	 * @param string      $rel       Pre-tokenized `rel` value.
	 * @param string      $className Pre-tokenized BEM class string.
	 * @param string      $ariaLabel Pre-formatted aria-label.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct(
		string $provider,
		string $label,
		string $url,
		?string $target,
		string $rel,
		string $className,
		string $ariaLabel
	) {
		$this->provider  = self::normalizeRequiredString( 'provider', $provider );
		$this->label     = self::normalizeRequiredString( 'label', $label );
		$this->url       = self::normalizeUrl( $url );
		$this->target    = self::normalizeTarget( $target );
		$this->rel       = self::normalizeRequiredString( 'rel', $rel );
		$this->className = self::normalizeRequiredString( 'className', $className );
		$this->ariaLabel = self::normalizeRequiredString( 'ariaLabel', $ariaLabel );
	}

	/**
	 * Closed set of accepted §10.7 target tokens, in declaration
	 * order.
	 *
	 * @return list<string>
	 */
	public static function validTargets(): array {
		return self::VALID_TARGETS;
	}

	/**
	 * Lowercase provider key the button targets.
	 */
	public function provider(): string {
		return $this->provider;
	}

	/**
	 * Pre-formatted button label.
	 */
	public function label(): string {
		return $this->label;
	}

	/**
	 * Absolute target URL.
	 */
	public function url(): string {
		return $this->url;
	}

	/**
	 * Window-target attribute, or null.
	 */
	public function target(): ?string {
		return $this->target;
	}

	/**
	 * Pre-tokenized `rel` attribute value.
	 */
	public function rel(): string {
		return $this->rel;
	}

	/**
	 * Pre-tokenized BEM class string.
	 */
	public function className(): string {
		return $this->className;
	}

	/**
	 * Pre-formatted accessibility label.
	 */
	public function ariaLabel(): string {
		return $this->ariaLabel;
	}

	/**
	 * 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->provider === $other->provider
			&& $this->label === $other->label
			&& $this->url === $other->url
			&& $this->target === $other->target
			&& $this->rel === $other->rel
			&& $this->className === $other->className
			&& $this->ariaLabel === $other->ariaLabel;
	}

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

	/**
	 * Validate and normalize the `url` field at construction.
	 *
	 * The string is trimmed first; an empty result is rejected.
	 * The trimmed value MUST start with `http://` or `https://`
	 * so a renderer cannot accidentally emit a `javascript:` or
	 * `data:` URI.
	 *
	 * @param string $url Raw URL as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$url` is empty after
	 *                                  trimming or does not start
	 *                                  with `http://` / `https://`.
	 */
	private static function normalizeUrl( string $url ): string {
		$trimmed = trim( $url );
		if ( $trimmed === '' ) {
			$message = 'ButtonView url must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( strpos( $trimmed, 'http://' ) !== 0 && strpos( $trimmed, 'https://' ) !== 0 ) {
			$message = sprintf(
				'ButtonView url must start with "http://" or "https://"; got "%s".',
				$url
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize the `target` field at construction.
	 *
	 * Null passes through unchanged. A non-null value is trimmed
	 * and matched against {@see self::VALID_TARGETS}; out-of-set
	 * tokens raise {@see InvalidArgumentException}.
	 *
	 * @param string|null $target Raw token, or null.
	 *
	 * @throws InvalidArgumentException When `$target` is not a
	 *                                  member of the closed set.
	 */
	private static function normalizeTarget( ?string $target ): ?string {
		if ( $target === null ) {
			return null;
		}

		$trimmed = trim( $target );

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

		return $trimmed;
	}
}
