<?php
/**
 * Price view object.
 *
 * Immutable value object that carries the §10.5 price-display
 * fields the rendering pipeline needs in order to populate the
 * `price` slot of a §8.2 ProductCardViewModel. The type collects
 * the visibility flag, the pre-formatted label / amount / currency
 * triple, the observation timestamp label, and the disclaimer
 * obligation flag into a single validated value so renderers and
 * templates do not have to chase the same fields across a
 * PriceInfo, an EmbedConfig, and a DisclaimerBuilder at render
 * time.
 *
 * Field semantics match `specifications/rendering-and-template-spec.md`
 * §10.5 exactly:
 *
 *   - `visible` is the §10.5 "価格がない場合は価格領域を非表示"
 *     branch toggle. When false the renderer suppresses the price
 *     region entirely; when true the renderer emits the labelled
 *     amount, the currency, and (when present) the observation
 *     timestamp.
 *
 *   - `label` is the §10.5 "数値価格" pre-formatted label
 *     ("¥4,980", "$49.80"). Required when `visible` is true so
 *     the renderer always has a single canonical string to stamp;
 *     nullable when `visible` is false because there is nothing
 *     to display.
 *
 *   - `amount` is the underlying decimal-string amount the §10.5
 *     "数値価格" label was derived from ("4980.00"). Held in
 *     addition to `label` so structured consumers (REST mappers,
 *     analytics surfaces) can read the raw value without parsing
 *     the localized label. Nullable across both visibility states
 *     because some surfaces only carry the formatted label.
 *
 *   - `currency` is the §10.5 "通貨" ISO 4217 code ("JPY", "USD").
 *     Required when `visible` is true so the renderer can stamp a
 *     `data-currency` attribute alongside the localized label;
 *     nullable when `visible` is false.
 *
 *   - `observedAtLabel` is the §10.5 "観測時刻" pre-formatted
 *     timestamp label the renderer displays alongside the price
 *     ("2026-04-23 10:00"). Nullable because not every surface
 *     wants the observation visible.
 *
 *   - `disclaimerRequired` is the §10.5 / §10.8 "注記が必要な
 *     場合は必ず表示可能な構造を持つ" obligation flag. When true
 *     the renderer MUST surface a disclaimer alongside the price;
 *     the disclaimer body itself lives on the PriceInfo /
 *     DisclaimerBuilder, not on this DTO.
 *
 * The "hidden" branch is exposed as a static factory
 * {@see self::hidden()} so callers do not have to spell out five
 * `null` arguments to construct a price-suppressed view.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because a price 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 price-view used by §8.2 ProductCardViewModel and the
 * §10.5 price rendering rules.
 *
 * See the file-level docblock for the mapping to
 * `rendering-and-template-spec.md` §10.5 and the per-field
 * validation rules.
 */
final class PriceView {

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

	/**
	 * Pre-formatted price label, or null when not displayed.
	 */
	private ?string $label;

	/**
	 * Underlying decimal-string amount, or null.
	 */
	private ?string $amount;

	/**
	 * ISO 4217 currency code, or null.
	 */
	private ?string $currency;

	/**
	 * Pre-formatted observation timestamp label, or null.
	 */
	private ?string $observedAtLabel;

	/**
	 * Whether the renderer MUST surface a disclaimer alongside
	 * the price.
	 */
	private bool $disclaimerRequired;

	/**
	 * Construct a price view with explicit values for every
	 * field.
	 *
	 * Validation summary:
	 *
	 *   - When `visible` is true, `label` and `currency` MUST be
	 *     non-null; the renderer requires both to emit the §10.5
	 *     "数値価格" + "通貨" pair.
	 *   - Every nullable string, when non-null, is trimmed and
	 *     rejected when the trim leaves an empty string —
	 *     "present but empty" is treated as a programming error.
	 *   - When `visible` is false the string fields MAY be null;
	 *     non-null values are still validated for non-emptiness.
	 *
	 * @param bool        $visible            Whether the region renders.
	 * @param string|null $label              Pre-formatted price label.
	 * @param string|null $amount             Decimal-string amount.
	 * @param string|null $currency           ISO 4217 currency code.
	 * @param string|null $observedAtLabel    Pre-formatted observation label.
	 * @param bool        $disclaimerRequired Disclaimer obligation flag.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct(
		bool $visible,
		?string $label,
		?string $amount,
		?string $currency,
		?string $observedAtLabel,
		bool $disclaimerRequired
	) {
		$normalizedLabel           = self::normalizeOptionalString( 'label', $label );
		$normalizedAmount          = self::normalizeOptionalString( 'amount', $amount );
		$normalizedCurrency        = self::normalizeOptionalString( 'currency', $currency );
		$normalizedObservedAtLabel = self::normalizeOptionalString( 'observedAtLabel', $observedAtLabel );

		if ( $visible && $normalizedLabel === null ) {
			$message = 'PriceView label must be non-null when visible is true.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $visible && $normalizedCurrency === null ) {
			$message = 'PriceView currency must be non-null when visible is true.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->visible            = $visible;
		$this->label              = $normalizedLabel;
		$this->amount             = $normalizedAmount;
		$this->currency           = $normalizedCurrency;
		$this->observedAtLabel    = $normalizedObservedAtLabel;
		$this->disclaimerRequired = $disclaimerRequired;
	}

	/**
	 * Construct a price view with the price region suppressed.
	 *
	 * Convenience factory for the §10.5 "価格がない場合は価格
	 * 領域を非表示" branch so call sites do not have to spell out
	 * five `null` arguments.
	 */
	public static function hidden(): self {
		return new self( false, null, null, null, null, false );
	}

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

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

	/**
	 * Underlying decimal-string amount, or null.
	 */
	public function amount(): ?string {
		return $this->amount;
	}

	/**
	 * ISO 4217 currency code, or null.
	 */
	public function currency(): ?string {
		return $this->currency;
	}

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

	/**
	 * Whether the renderer MUST surface a disclaimer alongside
	 * the price.
	 */
	public function disclaimerRequired(): bool {
		return $this->disclaimerRequired;
	}

	/**
	 * 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->label === $other->label
			&& $this->amount === $other->amount
			&& $this->currency === $other->currency
			&& $this->observedAtLabel === $other->observedAtLabel
			&& $this->disclaimerRequired === $other->disclaimerRequired;
	}

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