<?php
/**
 * Availability view object.
 *
 * Immutable value object that carries the §10.6 availability-display
 * fields the rendering pipeline needs in order to populate the
 * `availability` slot of a §8.2 ProductCardViewModel. The type
 * collects the visibility flag, the closed-set status token, and
 * the optional pre-formatted label into a single validated value so
 * renderers and templates do not have to chase the same fields
 * across an AvailabilityInfo, an EmbedConfig, and a
 * ProviderCapabilities map at render time.
 *
 * Field semantics match `specifications/rendering-and-template-spec.md`
 * §10.6 exactly:
 *
 *   - `visible` is the §10.6 "初期版では任意表示" branch toggle.
 *     When false the renderer suppresses the availability region
 *     entirely; when true the renderer emits the pre-formatted
 *     label keyed off the `status` token.
 *
 *   - `status` is the closed-set availability token the renderer
 *     keys downstream styling and aria-text off. The §10.6
 *     "表示例" enumerates "在庫あり", "在庫切れ", "予約受付中",
 *     "不明"; this DTO maps those onto the canonical machine
 *     tokens `'in_stock'`, `'out_of_stock'`, `'limited'`, and
 *     `'unknown'` so the rendering layer can localize the label
 *     without parsing the display string.
 *
 *   - `label` is the §10.6 pre-formatted display label
 *     ("在庫あり", "Out of stock", ...). Nullable because the
 *     renderer is free to derive a default label from the
 *     `status` token when none is supplied.
 *
 * The "hidden" branch is exposed as a static factory
 * {@see self::hidden()} so callers do not have to spell out the
 * canonical "status defaults to unknown" arguments to construct an
 * availability-suppressed view.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because an availability 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 availability-view used by §8.2 ProductCardViewModel
 * and the §10.6 availability rendering rules.
 *
 * See the file-level docblock for the mapping to
 * `rendering-and-template-spec.md` §10.6 and the per-field
 * validation rules.
 */
final class AvailabilityView {

	/**
	 * §10.6 "在庫あり" status token.
	 */
	public const STATUS_IN_STOCK = 'in_stock';

	/**
	 * §10.6 "在庫切れ" status token.
	 */
	public const STATUS_OUT_OF_STOCK = 'out_of_stock';

	/**
	 * §10.6 "予約受付中" / limited-availability status token.
	 */
	public const STATUS_LIMITED = 'limited';

	/**
	 * §10.6 "不明" status token (default for the hidden branch).
	 */
	public const STATUS_UNKNOWN = 'unknown';

	/**
	 * Closed set of accepted §10.6 status tokens, in declaration
	 * order.
	 *
	 * @var list<string>
	 */
	private const VALID_STATUSES = array(
		self::STATUS_IN_STOCK,
		self::STATUS_OUT_OF_STOCK,
		self::STATUS_LIMITED,
		self::STATUS_UNKNOWN,
	);

	/**
	 * Whether the availability region should be rendered at all.
	 */
	private bool $visible;

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

	/**
	 * Pre-formatted availability label, or null.
	 */
	private ?string $label;

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

	/**
	 * Construct an availability view with the region suppressed.
	 *
	 * Convenience factory for the §10.6 "初期版では任意表示" hidden
	 * branch so call sites do not have to spell out the canonical
	 * "status defaults to unknown" arguments. Returns a view with
	 * `visible = false`, `status = 'unknown'`, `label = null`.
	 */
	public static function hidden(): self {
		return new self( false, self::STATUS_UNKNOWN, null );
	}

	/**
	 * Closed set of accepted §10.6 status tokens, in declaration
	 * order.
	 *
	 * @return list<string>
	 */
	public static function validStatuses(): array {
		return self::VALID_STATUSES;
	}

	/**
	 * Whether the availability region should be rendered at all.
	 */
	public function visible(): bool {
		return $this->visible;
	}

	/**
	 * §10.6 status token.
	 */
	public function status(): string {
		return $this->status;
	}

	/**
	 * Pre-formatted availability label, or null.
	 */
	public function label(): ?string {
		return $this->label;
	}

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

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

		if ( ! in_array( $trimmed, self::VALID_STATUSES, true ) ) {
			$message = sprintf(
				'AvailabilityView status must be one of [%s]; got "%s".',
				implode( ', ', self::VALID_STATUSES ),
				$status
			);
			// 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(
				'AvailabilityView %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;
	}
}
