<?php
/**
 * Provider-scoped product identifier.
 *
 * Immutable value object that pairs a {@see self::provider()} key
 * (`amazon`, `rakuten`, `yahoo_shopping`, ...) with the
 * {@see self::value()} that identifier carries inside that provider's
 * own catalog (Amazon ASIN, 楽天 itemCode, Yahoo!ショッピング
 * itemCode / itemId). The pair, taken together, uniquely identifies an
 * external product across the entire CLB domain — neither half is
 * meaningful in isolation.
 *
 * Field semantics match `specifications/provider-api-contracts.md`
 * §7.3 exactly:
 *
 *   - `provider` is the stable provider key from §4 (lowercase ASCII,
 *     no whitespace, no surrounding punctuation). The DTO does not
 *     enforce the §4.2 character-set rules verbatim because the
 *     authoritative validation lives in the application layer where
 *     provider keys are minted; here we only reject the obviously
 *     wrong (empty string, surrounding whitespace).
 *
 *   - `value` is the provider-native identifier as a plain string.
 *     The DTO does not parse the format — an Amazon ASIN, a 楽天
 *     itemCode, and a Yahoo!ショッピング itemCode all have different
 *     shapes — so it only enforces non-empty content and trimmed
 *     edges. Format validation is the provider implementation's
 *     concern.
 *
 *   - The pair `(provider, value)` is the uniqueness axis per §7.3.
 *     {@see self::equals()} compares both halves with strict equality
 *     so a programming error that mixes ids across providers (passing
 *     an ASIN to the 楽天 provider, for example) surfaces at the
 *     equality check rather than as a silent miss against the wrong
 *     catalog.
 *
 * Instances are immutable and hold only their two scalar fields. There
 * is no `withProvider()` / `withValue()` helper because re-tagging an
 * existing id with a different provider is exactly the bug the §7.3
 * "provider 不一致の ID を他 provider に渡してはならない" rule warns
 * against; if a caller genuinely needs a different identity, they
 * should construct a fresh instance.
 *
 * @package CommerceLinkBuilder\Domain\Provider
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Domain\Provider;

use InvalidArgumentException;

/**
 * Immutable `(provider, value)` pair identifying an external product.
 *
 * See the file-level docblock for the mapping to
 * `provider-api-contracts.md` §7.3 and the rationale for keeping
 * format validation at the provider layer rather than on this DTO.
 */
final class ProviderProductId {

	/**
	 * Stable provider key per §4 (for example `amazon`, `rakuten`,
	 * `yahoo_shopping`). Held as a plain string at this layer; the
	 * application layer is responsible for minting keys that satisfy
	 * the §4.2 character-set rules.
	 */
	private string $provider;

	/**
	 * Provider-native identifier value (Amazon ASIN, 楽天 itemCode,
	 * etc.). The format varies by provider and is enforced by the
	 * provider implementation, not by this DTO.
	 */
	private string $value;

	/**
	 * Construct an identifier with explicit values for both halves.
	 *
	 * Both fields are trimmed of surrounding whitespace before
	 * validation and storage so an id round-tripped through a
	 * carelessly-decoded payload (trailing newline, padded space) is
	 * normalized to the same canonical form a freshly-minted id would
	 * carry. Empty strings — before or after trimming — are rejected
	 * because they cannot satisfy the §7.3 "空文字不可" rule.
	 *
	 * @param string $provider Stable provider key.
	 * @param string $value    Provider-native identifier value.
	 *
	 * @throws InvalidArgumentException When either field is empty
	 *                                  before or after trimming.
	 */
	public function __construct( string $provider, string $value ) {
		$normalizedProvider = trim( $provider );
		$normalizedValue    = trim( $value );

		if ( $normalizedProvider === '' ) {
			$message = 'ProviderProductId requires a non-empty provider key.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $normalizedValue === '' ) {
			$message = 'ProviderProductId requires a non-empty value.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->provider = $normalizedProvider;
		$this->value    = $normalizedValue;
	}

	/**
	 * Stable provider key per §4.
	 */
	public function provider(): string {
		return $this->provider;
	}

	/**
	 * Provider-native identifier value per §7.3.
	 */
	public function value(): string {
		return $this->value;
	}

	/**
	 * Report whether this identifier targets the supplied provider.
	 *
	 * Convenience guard so call sites can reject "provider 不一致"
	 * arguments at the boundary without manually comparing
	 * {@see self::provider()}. Comparison is strict and
	 * case-sensitive — provider keys are lowercase ASCII per §4.2,
	 * so case insensitivity would only mask a programming error.
	 *
	 * @param string $provider Provider key to test against.
	 */
	public function belongsTo( string $provider ): bool {
		return $this->provider === $provider;
	}

	/**
	 * Report whether this identifier equals the supplied one.
	 *
	 * Equality is structural: both halves must match with strict
	 * string equality. This is the canonical comparison for the
	 * §7.3 uniqueness axis — `(provider, value)` taken together —
	 * and is the only way to test identity at this layer because
	 * PHP's `==` / `===` on objects would compare instance handles
	 * rather than the underlying tuple.
	 *
	 * @param ProviderProductId $other Identifier to compare with.
	 */
	public function equals( ProviderProductId $other ): bool {
		return $this->provider === $other->provider
			&& $this->value === $other->value;
	}

	/**
	 * Render the identifier as a stable `provider:value` string.
	 *
	 * Used primarily by diagnostics, logs, and exception messages
	 * where a single human-readable token is more useful than two
	 * separate fields. The format is deliberately simple
	 * (`amazon:B07XYZ`) and not a parser-friendly serialization
	 * format — callers that need to round-trip through a string
	 * should encode the two fields separately, not parse this
	 * output.
	 */
	public function __toString(): string {
		return $this->provider . ':' . $this->value;
	}
}
