<?php
/**
 * Availability information value object.
 *
 * Immutable value object that represents the stock / purchasability
 * state a provider returned for a product at a specific point in
 * time. The type intentionally stays small — three fields — because
 * §7.6 of `specifications/provider-api-contracts.md` deliberately
 * keeps the cross-provider availability surface narrow: the canonical
 * status set, an optional human-readable label, and a single flag
 * that tells the rendering layer whether a "buy" affordance should
 * be enabled.
 *
 * Field semantics match `provider-api-contracts.md` §7.6 exactly:
 *
 *   - `status` is one of the six tokens from §7.6
 *     ({@see self::STATUS_IN_STOCK}, {@see self::STATUS_OUT_OF_STOCK},
 *     {@see self::STATUS_LIMITED}, {@see self::STATUS_PREORDER},
 *     {@see self::STATUS_UNKNOWN}, {@see self::STATUS_UNAVAILABLE}).
 *     The DTO enforces the set at construction time: any other token
 *     raises {@see \InvalidArgumentException} so a provider
 *     implementation that forgets to map its native vocabulary
 *     (例: 楽天の "在庫あり" / Yahoo の "limited") fails loudly here
 *     rather than silently producing a status the rendering layer
 *     does not know how to display.
 *
 *   - `label` is the provider's own short description of the state
 *     ("在庫あり", "残り少ない", "Currently unavailable", ...).
 *     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 because providers do not always supply a
 *     human-readable label alongside the structured status.
 *
 *   - `isPurchasable` reflects whether the provider considers the
 *     product currently buyable. Required because the §7.6 status
 *     set alone does not answer the question — a provider may
 *     return `limited` while still allowing checkout, or return
 *     `preorder` with checkout disabled, and the rendering layer
 *     must not infer the answer from the status string alone.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because an availability snapshot represents a single observation:
 * any change in the underlying state should be modeled as a fresh
 * fetch, not as an in-place edit of an existing snapshot.
 *
 * @package Affilun\Domain\Pricing
 */

declare(strict_types=1);

namespace Affilun\Domain\Pricing;

use InvalidArgumentException;

/**
 * Immutable availability 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.6 and the rationale for keeping the
 * status set closed.
 */
final class AvailabilityInfo {

	/**
	 * Provider reports the product is currently in stock.
	 *
	 * Exposed as a class constant so call sites can reference the
	 * exact token without duplicating the literal, and so a future
	 * spec change has a single location to update.
	 */
	public const STATUS_IN_STOCK = 'in_stock';

	/**
	 * Provider reports the product is currently out of stock.
	 */
	public const STATUS_OUT_OF_STOCK = 'out_of_stock';

	/**
	 * Provider reports the product has limited stock remaining.
	 */
	public const STATUS_LIMITED = 'limited';

	/**
	 * Provider reports the product is available for pre-order ahead
	 * of its release.
	 */
	public const STATUS_PREORDER = 'preorder';

	/**
	 * Provider response did not carry a structured availability
	 * signal; the consuming surface should display the price /
	 * detail link without an availability claim.
	 */
	public const STATUS_UNKNOWN = 'unknown';

	/**
	 * Provider reports the product is not available at all (delisted,
	 * region-restricted, or otherwise structurally inaccessible).
	 */
	public const STATUS_UNAVAILABLE = 'unavailable';

	/**
	 * Closed set of {@see self::STATUS_*} tokens accepted by the
	 * constructor. Maintained as a class-level constant so the
	 * validator and the test suite can iterate the same source of
	 * truth.
	 *
	 * @var list<string>
	 */
	private const VALID_STATUSES = array(
		self::STATUS_IN_STOCK,
		self::STATUS_OUT_OF_STOCK,
		self::STATUS_LIMITED,
		self::STATUS_PREORDER,
		self::STATUS_UNKNOWN,
		self::STATUS_UNAVAILABLE,
	);

	/**
	 * One of the {@see self::STATUS_*} tokens. Validated at
	 * construction time so an out-of-set value never reaches the
	 * rendering layer.
	 */
	private string $status;

	/**
	 * Provider-supplied human-readable label, or null when the
	 * provider response did not carry one.
	 */
	private ?string $label;

	/**
	 * Whether the rendering layer should enable a "buy" affordance
	 * for this product. Independent of {@see self::$status} per the
	 * §7.6 contract; see the file-level docblock.
	 */
	private bool $isPurchasable;

	/**
	 * Construct an availability snapshot with explicit values for
	 * every field.
	 *
	 * The constructor is exhaustive so a programming error (missing
	 * purchasable flag, swapped status / label) surfaces at the type
	 * system boundary rather than at the rendering layer.
	 *
	 * Validation:
	 *
	 *   - `status` is trimmed and matched against the closed set in
	 *     {@see self::VALID_STATUSES}; any other token raises
	 *     {@see \InvalidArgumentException} carrying the offending
	 *     value verbatim.
	 *   - `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 string      $status        One of {@see self::STATUS_*}.
	 * @param string|null $label         Provider label, or null.
	 * @param bool        $isPurchasable Whether the rendering layer
	 *                                   should enable a buy affordance.
	 *
	 * @throws InvalidArgumentException When `$status` is not a member
	 *                                  of the §7.6 closed set, or
	 *                                  when `$label` is the empty
	 *                                  string after trimming.
	 */
	public function __construct(
		string $status,
		?string $label,
		bool $isPurchasable
	) {
		$this->status        = self::normalizeStatus( $status );
		$this->label         = self::normalizeOptional( 'label', $label );
		$this->isPurchasable = $isPurchasable;
	}

	/**
	 * Construct an `unknown` snapshot — the canonical fallback for
	 * provider responses that did not carry any availability signal.
	 *
	 * The §7.6 contract pairs `unknown` with no buy affordance: the
	 * rendering layer cannot honestly enable checkout when the
	 * provider has not confirmed the product is purchasable, so the
	 * helper hard-codes `isPurchasable = false`. Callers that want
	 * to override either field should construct the DTO directly.
	 */
	public static function unknown(): self {
		return new self( self::STATUS_UNKNOWN, null, false );
	}

	/**
	 * Closed set of accepted status tokens, in declaration order.
	 *
	 * Exposed for tests and for diagnostics surfaces that want to
	 * enumerate the valid tokens (for example, populating an admin
	 * filter dropdown) without duplicating the list.
	 *
	 * @return list<string>
	 */
	public static function validStatuses(): array {
		return self::VALID_STATUSES;
	}

	/**
	 * One of the {@see self::STATUS_*} tokens.
	 */
	public function status(): string {
		return $this->status;
	}

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

	/**
	 * Whether the rendering layer should enable a "buy" affordance.
	 */
	public function isPurchasable(): bool {
		return $this->isPurchasable;
	}

	/**
	 * Convenience guard: report whether the status equals
	 * {@see self::STATUS_IN_STOCK}.
	 *
	 * Exposed because the §7.6 status set has a single dominant
	 * "happy path" token that consuming surfaces routinely branch
	 * on; a dedicated helper keeps those branches readable without
	 * forcing every call site to compare against the raw string
	 * constant.
	 */
	public function isInStock(): bool {
		return $this->status === self::STATUS_IN_STOCK;
	}

	/**
	 * Validate and normalize the `status` field at construction.
	 *
	 * The status is trimmed first so a carelessly-decoded payload
	 * (trailing newline, padded space) is normalized to the same
	 * canonical form a freshly-constructed status would carry. The
	 * trimmed value MUST appear in {@see self::VALID_STATUSES};
	 * anything else raises {@see InvalidArgumentException} carrying
	 * the offending input verbatim and a comma-separated list of
	 * the accepted tokens for diagnosis.
	 *
	 * @param string $status Raw status as supplied by the caller.
	 *
	 * @return string Canonicalized status.
	 *
	 * @throws InvalidArgumentException When `$status` is not a
	 *                                  member of the §7.6 closed
	 *                                  set.
	 */
	private static function normalizeStatus( string $status ): string {
		$trimmed = trim( $status );

		if ( ! in_array( $trimmed, self::VALID_STATUSES, true ) ) {
			$message = sprintf(
				'AvailabilityInfo 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.
	 *
	 * Used for the `label` field 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(
				'AvailabilityInfo %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;
	}
}
