<?php
/**
 * Price information value object.
 *
 * Immutable value object that represents the price snapshot a
 * provider returned for a product at a specific point in time. The
 * type intentionally carries the provider's own narrative context
 * (label, observation timestamp, regulatory disclaimer) alongside
 * the raw amount so consumers — REST responses, render pipelines,
 * sync diagnostics — can present the price faithfully without having
 * to re-derive these fields from a sibling object.
 *
 * Field semantics match `specifications/provider-api-contracts.md`
 * §7.5 exactly:
 *
 *   - `amount` is the price as a decimal string (for example
 *     `"1980"`, `"1980.50"`, `"0"`). The DTO holds it as a string
 *     rather than `int` / `float` because:
 *
 *       * Provider responses arrive as strings or as decimals with
 *         locale-sensitive formatting that PHP's `float` cannot
 *         round-trip without loss (the well-known JPY 1980 vs USD
 *         19.80 vs EUR 19,80 spread).
 *
 *       * The §2.3 "正規化済みの最小共通モデル" rule asks providers
 *         to return a stable representation; a string preserves the
 *         exact digits the provider sent, leaving any
 *         currency-specific arithmetic to the rendering layer that
 *         knows the locale.
 *
 *     The DTO validates that the supplied string parses as a finite,
 *     non-negative decimal so a programming error (NaN, scientific
 *     notation, embedded whitespace) surfaces here rather than at
 *     the rendering boundary.
 *
 *   - `currency` is an ISO 4217 alphabetic code (`JPY`, `USD`,
 *     `EUR`) when known. The §7.5 contract explicitly allows null
 *     ("通貨不明時は `currency = null` 可") for providers whose
 *     responses do not carry a currency tag at all. The DTO does not
 *     verify the code against an exhaustive ISO list — that would
 *     bind us to a specific data source — but does normalize to
 *     uppercase and reject the empty string before / after trimming.
 *
 *   - `label` is the provider's own short description of the price
 *     ("通常価格", "セール価格", "定期おトク便", ...). Held as
 *     a plain string at this layer; the application layer is free
 *     to use it verbatim or map it through a translation table.
 *     Nullable per §7.5.
 *
 *   - `observedAt` is the UTC timestamp at which the provider
 *     returned this price. Required so render pipelines can detect
 *     stale snapshots and so audit / diagnostics surfaces can
 *     correlate a price-display incident with the underlying fetch
 *     event.
 *
 *   - `isTaxIncluded` reflects whether the `amount` already includes
 *     any sales / consumption tax the provider's locale levies.
 *     Required because a "1980 円" snapshot is ambiguous without it
 *     — the same digits mean different things on Amazon JP (tax
 *     included) versus a B2B catalog (tax excluded).
 *
 *   - `disclaimerRequired` is true when the provider's terms of
 *     service or local regulation oblige the consumer surface to
 *     render a specific notice alongside the price (the canonical
 *     example is Amazon's affiliate program "価格は変動する場合が
 *     あります" line). Per §7.5 "表示側は `disclaimerRequired` を
 *     尊重する": the rendering layer MUST display the disclaimer
 *     when this flag is set.
 *
 *   - `disclaimerText` is the disclaimer body itself when the
 *     provider supplies it verbatim. May be null even when
 *     `disclaimerRequired` is true — in that case the rendering
 *     layer falls back to a AFFILUN-canonical message keyed off the
 *     provider, not off this string.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because a price snapshot represents a single observation: any
 * change in the underlying value should be modeled as a fresh fetch
 * (and therefore a fresh `PriceInfo` instance carrying its own
 * `observedAt`), not as an in-place edit of an existing one.
 *
 * @package Affilun\Domain\Pricing
 */

declare(strict_types=1);

namespace Affilun\Domain\Pricing;

use DateTimeImmutable;
use DateTimeInterface;
use DateTimeZone;
use InvalidArgumentException;

/**
 * Immutable price snapshot returned by a provider for a single
 * product at a single point in time.
 *
 * See the file-level docblock for the mapping to
 * `provider-api-contracts.md` §7.5, the rationale for holding
 * `amount` as a string, and the disclaimer-handling contract that
 * binds the rendering layer.
 */
final class PriceInfo {

	/**
	 * Decimal price amount as a stable string. See the file-level
	 * docblock for the rationale.
	 */
	private string $amount;

	/**
	 * ISO 4217 alphabetic currency code in uppercase, or null when
	 * the provider response did not carry a currency tag.
	 */
	private ?string $currency;

	/**
	 * Provider-supplied short label for the price, or null when
	 * the provider did not supply one.
	 */
	private ?string $label;

	/**
	 * UTC timestamp at which the provider returned this price.
	 */
	private DateTimeImmutable $observedAt;

	/**
	 * Whether `amount` already includes locally-applicable tax.
	 */
	private bool $isTaxIncluded;

	/**
	 * Whether the rendering layer MUST display a disclaimer
	 * alongside this price per provider terms or local regulation.
	 */
	private bool $disclaimerRequired;

	/**
	 * Provider-supplied disclaimer body, or null when the rendering
	 * layer is expected to fall back to a AFFILUN-canonical message.
	 */
	private ?string $disclaimerText;

	/**
	 * Construct a price snapshot with explicit values for every
	 * field.
	 *
	 * The constructor is exhaustive so a programming error (missing
	 * disclaimer flag on a write, swapped currency / label) surfaces
	 * at the type system boundary rather than at the rendering
	 * layer. Named-argument construction is the ergonomic entry
	 * point for most call sites.
	 *
	 * Validation:
	 *
	 *   - `amount` is trimmed and validated as a finite non-negative
	 *     decimal. Scientific notation, NaN, embedded whitespace,
	 *     and negative values are rejected.
	 *   - `currency`, when non-null, is trimmed and uppercased; an
	 *     empty result raises {@see InvalidArgumentException}.
	 *   - `label` and `disclaimerText`, when non-null, are trimmed
	 *     and rejected when the trim leaves an empty string —
	 *     "present but empty" is treated as a programming error.
	 *   - `observedAt` is normalized to UTC on storage so equality
	 *     checks and round-trips do not depend on the caller's
	 *     timezone.
	 *
	 * @param string            $amount             Decimal price as a string.
	 * @param string|null       $currency           ISO 4217 code, or null.
	 * @param string|null       $label              Provider label, or null.
	 * @param DateTimeImmutable $observedAt         Observation timestamp.
	 * @param bool              $isTaxIncluded      Tax inclusion flag.
	 * @param bool              $disclaimerRequired Disclaimer obligation flag.
	 * @param string|null       $disclaimerText     Provider-supplied disclaimer body, or null.
	 *
	 * @throws InvalidArgumentException When `amount` is not a finite
	 *                                  non-negative decimal, or when
	 *                                  any nullable string is the
	 *                                  empty string after trimming.
	 */
	public function __construct(
		string $amount,
		?string $currency,
		?string $label,
		DateTimeImmutable $observedAt,
		bool $isTaxIncluded,
		bool $disclaimerRequired,
		?string $disclaimerText
	) {
		$this->amount             = self::normalizeAmount( $amount );
		$this->currency           = self::normalizeCurrency( $currency );
		$this->label              = self::normalizeOptional( 'label', $label );
		$this->observedAt         = self::normalizeToUtc( $observedAt );
		$this->isTaxIncluded      = $isTaxIncluded;
		$this->disclaimerRequired = $disclaimerRequired;
		$this->disclaimerText     = self::normalizeOptional( 'disclaimerText', $disclaimerText );
	}

	/**
	 * Decimal price amount as a stable string.
	 */
	public function amount(): string {
		return $this->amount;
	}

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

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

	/**
	 * UTC observation timestamp.
	 */
	public function observedAt(): DateTimeImmutable {
		return $this->observedAt;
	}

	/**
	 * Whether `amount` already includes locally-applicable tax.
	 */
	public function isTaxIncluded(): bool {
		return $this->isTaxIncluded;
	}

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

	/**
	 * Provider-supplied disclaimer body, or null when the rendering
	 * layer should fall back to a AFFILUN-canonical message.
	 */
	public function disclaimerText(): ?string {
		return $this->disclaimerText;
	}

	/**
	 * Format the observation timestamp as a canonical MySQL
	 * `DATETIME` string in UTC.
	 *
	 * Exposed as a public helper so REST mappers, render pipelines,
	 * and persistence-layer adapters can produce a stable string
	 * representation without re-deriving the format.
	 *
	 * @param DateTimeInterface|null $value Timestamp to format, or null.
	 *
	 * @return string|null `Y-m-d H:i:s` UTC string, or null when
	 *                     `$value` is null.
	 */
	public static function formatDateTime( ?DateTimeInterface $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$utc = $value instanceof DateTimeImmutable
			? $value->setTimezone( new DateTimeZone( 'UTC' ) )
			: DateTimeImmutable::createFromInterface( $value )->setTimezone( new DateTimeZone( 'UTC' ) );

		return $utc->format( 'Y-m-d H:i:s' );
	}

	/**
	 * Validate and normalize the `amount` field at construction.
	 *
	 * Accepts a decimal-string representation of a non-negative
	 * finite number. The check is deliberately conservative:
	 *
	 *   - The string is trimmed first; a fully-empty string after
	 *     trimming is rejected.
	 *   - A leading `+` sign is allowed and dropped on storage.
	 *   - Optional fractional part separated by a literal `.` is
	 *     allowed; thousands separators and locale-specific commas
	 *     are not — providers MUST normalize before constructing
	 *     this DTO, per §2.3.
	 *   - Scientific notation (`1.98e3`), hex / octal / binary
	 *     literals, embedded whitespace, NaN, and negative numbers
	 *     are rejected.
	 *
	 * @param string $amount Raw amount as supplied by the caller.
	 *
	 * @return string Canonicalized decimal-string representation.
	 *
	 * @throws InvalidArgumentException When `$amount` does not match
	 *                                  the accepted decimal grammar.
	 */
	private static function normalizeAmount( string $amount ): string {
		$trimmed = trim( $amount );
		if ( $trimmed === '' ) {
			$message = 'PriceInfo amount must be a non-empty decimal string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		// Strip a single leading '+' for ergonomic acceptance of
		// '+1980' as equivalent to '1980'; minus signs are NOT
		// accepted because §7.5 prices are non-negative.
		if ( $trimmed[0] === '+' ) {
			$trimmed = substr( $trimmed, 1 );
		}

		if ( preg_match( '/\A(?:\d+|\d+\.\d+|\.\d+)\z/', $trimmed ) !== 1 ) {
			$message = sprintf(
				'PriceInfo amount must be a non-negative decimal string; got "%s".',
				$amount
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize the `currency` field at construction.
	 *
	 * Null passes through unchanged. A non-null value is trimmed
	 * and uppercased; an empty result is rejected.
	 *
	 * @param string|null $currency Raw currency code, or null.
	 *
	 * @return string|null Uppercased ISO 4217 code, or null.
	 *
	 * @throws InvalidArgumentException When `$currency` is the
	 *                                  empty string after trimming.
	 */
	private static function normalizeCurrency( ?string $currency ): ?string {
		if ( $currency === null ) {
			return null;
		}

		$trimmed = trim( $currency );
		if ( $trimmed === '' ) {
			$message = 'PriceInfo currency must be null or a non-empty ISO 4217 code.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return strtoupper( $trimmed );
	}

	/**
	 * Validate and normalize a nullable string field that, when
	 * non-null, must carry non-empty content.
	 *
	 * Used for the `label` and `disclaimerText` fields where
	 * "present but empty" is a programming error rather than a
	 * meaningful state.
	 *
	 * @param string      $field Field name for the error message.
	 * @param string|null $value Raw value, or null.
	 *
	 * @return string|null Trimmed value, or null.
	 *
	 * @throws InvalidArgumentException When `$value` is the empty
	 *                                  string after trimming.
	 */
	private static function normalizeOptional( string $field, ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'PriceInfo %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;
	}

	/**
	 * Normalize a timestamp to UTC so equality checks and
	 * round-trips do not depend on the caller's timezone.
	 *
	 * @param DateTimeImmutable $value Timestamp to normalize.
	 */
	private static function normalizeToUtc( DateTimeImmutable $value ): DateTimeImmutable {
		return $value->setTimezone( new DateTimeZone( 'UTC' ) );
	}
}
