<?php
/**
 * Badge view object.
 *
 * Immutable value object that carries the §8.2 badge-display
 * fields the rendering pipeline needs in order to populate a single
 * entry of the `badges` slot of a §8.2 ProductCardViewModel. The
 * type collects the closed-set badge kind, the pre-formatted label,
 * and the optional pre-tokenized CSS class string into a single
 * validated value so renderers and templates can emit a complete
 * badge 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`
 * §8.2 exactly (the `badges` array entries in the canonical
 * ProductCardViewModel structure):
 *
 *   - `kind` is the closed-set badge classification token the
 *     renderer keys downstream styling off. The DTO maps the §8.2
 *     badge axes onto the canonical machine tokens `'provider'`
 *     (Amazon / 楽天市場 / Yahoo!ショッピング provider badge),
 *     `'sale'` (price-reduction badge), `'new'` (recently-added
 *     product badge), `'ranking'` (ranking-position badge), and
 *     `'custom'` (catch-all for site-specific badges).
 *
 *   - `label` is the §8.2 pre-formatted badge label
 *     ("セール", "NEW", "ランキング 1 位", ...). Required,
 *     non-empty after trimming.
 *
 *   - `className` is the §8.2 pre-tokenized BEM class string
 *     ("affilun-badge affilun-badge-sale"). Nullable because the renderer
 *     is free to derive a default class string from the `kind`
 *     token when none is supplied.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because a badge 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 badge-view used by §8.2 ProductCardViewModel.
 *
 * See the file-level docblock for the mapping to
 * `rendering-and-template-spec.md` §8.2 and the per-field
 * validation rules.
 */
final class BadgeView {

	/**
	 * Provider-identification badge kind.
	 */
	public const KIND_PROVIDER = 'provider';

	/**
	 * Price-reduction badge kind.
	 */
	public const KIND_SALE = 'sale';

	/**
	 * Recently-added product badge kind.
	 */
	public const KIND_NEW = 'new';

	/**
	 * Ranking-position badge kind.
	 */
	public const KIND_RANKING = 'ranking';

	/**
	 * Catch-all site-specific badge kind.
	 */
	public const KIND_CUSTOM = 'custom';

	/**
	 * Closed set of accepted badge-kind tokens, in declaration
	 * order.
	 *
	 * @var list<string>
	 */
	private const VALID_KINDS = array(
		self::KIND_PROVIDER,
		self::KIND_SALE,
		self::KIND_NEW,
		self::KIND_RANKING,
		self::KIND_CUSTOM,
	);

	/**
	 * One of the {@see self::KIND_*} tokens.
	 */
	private string $kind;

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

	/**
	 * Pre-tokenized BEM class string, or null.
	 */
	private ?string $className;

	/**
	 * Construct a badge view with explicit values for every
	 * field.
	 *
	 * Validation summary:
	 *
	 *   - `kind` is trimmed and matched against
	 *     {@see self::VALID_KINDS}; out-of-set tokens raise
	 *     {@see InvalidArgumentException}.
	 *   - `label` is trimmed and rejected when empty after
	 *     trimming.
	 *   - `className`, when non-null, is trimmed and rejected
	 *     when the trim leaves an empty string — "present but
	 *     empty" is treated as a programming error.
	 *
	 * @param string      $kind      One of {@see self::VALID_KINDS}.
	 * @param string      $label     Pre-formatted badge label.
	 * @param string|null $className Pre-tokenized BEM class string, or null.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct(
		string $kind,
		string $label,
		?string $className
	) {
		$this->kind      = self::normalizeKind( $kind );
		$this->label     = self::normalizeRequiredString( 'label', $label );
		$this->className = self::normalizeOptionalString( 'className', $className );
	}

	/**
	 * Closed set of accepted badge-kind tokens, in declaration
	 * order.
	 *
	 * @return list<string>
	 */
	public static function validKinds(): array {
		return self::VALID_KINDS;
	}

	/**
	 * Badge-kind classification token.
	 */
	public function kind(): string {
		return $this->kind;
	}

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

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

	/**
	 * 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->kind === $other->kind
			&& $this->label === $other->label
			&& $this->className === $other->className;
	}

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

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

		return $trimmed;
	}

	/**
	 * 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(
				'BadgeView %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 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(
				'BadgeView %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;
	}
}
