<?php
/**
 * Yahoo!ショッピング provider credentials value object.
 *
 * Immutable value object that holds the credentials a
 * {@see \Affilun\Infrastructure\Provider\YahooShopping\YahooShoppingProvider}
 * implementation needs to call the Yahoo!ショッピング商品検索 v3
 * endpoint. The fields mirror `specifications/provider-api-contracts.md`
 * §9.3 verbatim:
 *
 *   - `clientId` is the Yahoo! Developer Network Client ID (also
 *     known as App ID). Required by every商品検索 v3 request: the
 *     endpoint validates the value as the `appid` query parameter
 *     and rejects calls that omit it. Per §9.3 "必須" list.
 *
 *   - `affiliateSid` is the Value Commerce affiliate `sid`
 *     (site identifier). Required for Yahoo!ショッピング affiliate-
 *     link minting through Value Commerce per §9.3. Held as
 *     nullable here because the credential surface is shared
 *     between the search-only deployment (no affiliate id
 *     configured yet) and the full configuration; provider
 *     implementations enforce required-at-call-time semantics on
 *     {@see YahooShoppingAffiliateLinkBuilder}.
 *
 *   - `affiliatePid` is the Value Commerce affiliate `pid`
 *     (program / placement identifier). Required alongside
 *     `affiliateSid` for affiliate-link minting through Value
 *     Commerce. Held as nullable for the same reason as
 *     `affiliateSid`.
 *
 * Per `provider-api-contracts.md` §10.1 ("例外メッセージに秘密
 * 情報を含めない"), credentials MUST NOT leak through exception
 * messages, log lines, or `__toString()` output. This DTO
 * deliberately:
 *
 *   - Does NOT implement `__toString()` so a `(string) $credentials`
 *     coercion would raise rather than silently expose the values.
 *   - Does NOT expose a generic `toArray()` / `__debugInfo()`
 *     helper. The three named accessors are the only ways to read
 *     the values, and each one returns the field verbatim.
 *   - Does NOT implement `Serializable` / PHP magic serialization
 *     hooks. Persistence of credentials goes through the dedicated
 *     WordPress options API, not through `serialize()` of this
 *     object.
 *
 * Construction validates `clientId` is a non-empty trimmed string.
 * The two affiliate fields are normalized via the same null-or-non-
 * empty-string rule {@see RakutenCredentials} uses for its optional
 * `developerId`: null passes through unchanged; a non-null value is
 * trimmed and rejected when the trim leaves the empty string —
 * "present but empty" is treated as a programming error. The empty-
 * string-to-null normalization the slice 12a brief asks for is
 * achieved by the caller passing `null` rather than `''`; the DTO
 * itself rejects `''` so callers cannot accidentally store a value
 * that would round-trip as "configured" through
 * {@see self::hasAffiliateSid()} / {@see self::hasAffiliatePid()}.
 *
 * Format validation against Yahoo's actual identifier shapes
 * (Client ID is a long alphanumeric token; Value Commerce sid / pid
 * are numeric strings) is deliberately NOT performed here. Format
 * drift is a deployment concern: an admin who saved a malformed
 * value should see a loud authentication failure from the upstream
 * API rather than a silently-rejected save in Phase 8.
 *
 * Instances are immutable. There are no `with*()` mutation
 * helpers because credentials are configured once per deployment
 * and rotating them is a deliberate admin action that produces a
 * fresh DTO from a fresh settings save.
 *
 * @package Affilun\Infrastructure\Provider\YahooShopping
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Provider\YahooShopping;

use InvalidArgumentException;

/**
 * Immutable Yahoo!ショッピング商品検索 v3 credentials.
 *
 * See the file-level docblock for the mapping to
 * `provider-api-contracts.md` §9.3, the secret-material rule from
 * §10.1 that binds the type's accessor surface, and the rationale
 * for keeping format validation out of this DTO.
 */
final class YahooShoppingCredentials {

	/**
	 * Yahoo! Developer Network Client ID per §9.3 "必須".
	 *
	 * Validated as a non-empty trimmed string at construction
	 * time.
	 */
	private string $clientId;

	/**
	 * Value Commerce affiliate `sid` per §9.3, or null when the
	 * deployment has not yet configured one.
	 */
	private ?string $affiliateSid;

	/**
	 * Value Commerce affiliate `pid` per §9.3, or null when the
	 * deployment has not yet configured one.
	 */
	private ?string $affiliatePid;

	/**
	 * Construct a Yahoo!ショッピング credentials snapshot.
	 *
	 * The required `clientId` field is trimmed at construction
	 * time and rejected when empty after trimming. The optional
	 * `affiliateSid` / `affiliatePid` fields are trimmed when
	 * non-null and rejected when the trim leaves the empty
	 * string — "present but empty" is treated as a programming
	 * error rather than a meaningful state, matching the
	 * convention every other Domain / Infrastructure DTO in
	 * this codebase follows.
	 *
	 * Format validation against Yahoo's actual identifier shapes
	 * is deliberately NOT performed here. See the file-level
	 * docblock for the rationale.
	 *
	 * @param string      $clientId     Yahoo! Developer Network Client ID.
	 * @param string|null $affiliateSid Value Commerce affiliate sid, or null.
	 * @param string|null $affiliatePid Value Commerce affiliate pid, or null.
	 *
	 * @throws InvalidArgumentException When `$clientId` is empty
	 *                                  after trimming, or when
	 *                                  either affiliate field is
	 *                                  the empty string after
	 *                                  trimming.
	 */
	public function __construct(
		string $clientId,
		?string $affiliateSid = null,
		?string $affiliatePid = null
	) {
		$this->clientId     = self::normalizeRequired( 'clientId', $clientId );
		$this->affiliateSid = self::normalizeOptional( 'affiliateSid', $affiliateSid );
		$this->affiliatePid = self::normalizeOptional( 'affiliatePid', $affiliatePid );
	}

	/**
	 * Yahoo! Developer Network Client ID per §9.3 "必須".
	 *
	 * Returned verbatim — the value is the canonical form supplied
	 * to the constructor, with surrounding whitespace already
	 * trimmed off. Provider implementations pass this directly to
	 * the `appid` query parameter on every商品検索 v3 request.
	 */
	public function clientId(): string {
		return $this->clientId;
	}

	/**
	 * Value Commerce affiliate `sid` per §9.3, or null when not
	 * configured.
	 *
	 * Provider implementations forward this only when non-null.
	 */
	public function affiliateSid(): ?string {
		return $this->affiliateSid;
	}

	/**
	 * Value Commerce affiliate `pid` per §9.3, or null when not
	 * configured.
	 *
	 * Provider implementations forward this only when non-null.
	 */
	public function affiliatePid(): ?string {
		return $this->affiliatePid;
	}

	/**
	 * Report whether an affiliate `sid` is configured.
	 *
	 * Convenience guard so call sites can branch on
	 * "is the sid set?" without manually comparing
	 * {@see self::affiliateSid()} against null.
	 */
	public function hasAffiliateSid(): bool {
		return $this->affiliateSid !== null;
	}

	/**
	 * Report whether an affiliate `pid` is configured.
	 *
	 * Convenience guard so call sites can branch on
	 * "is the pid set?" without manually comparing
	 * {@see self::affiliatePid()} against null.
	 */
	public function hasAffiliatePid(): bool {
		return $this->affiliatePid !== null;
	}

	/**
	 * Report whether both affiliate fields are configured.
	 *
	 * Convenience for {@see YahooShoppingAffiliateLinkBuilder},
	 * which requires both `sid` and `pid` to mint a Value
	 * Commerce redirect URL. Either field missing forces the
	 * passthrough route.
	 */
	public function hasAffiliateCredentials(): bool {
		return $this->affiliateSid !== null && $this->affiliatePid !== null;
	}

	/**
	 * Validate and normalize a required credential field at
	 * construction.
	 *
	 * Trims surrounding whitespace and rejects the result when it
	 * is the empty string. The error message names the field
	 * (`clientId`) so a misconfiguration surfaces with enough
	 * context to fix without dumping the raw value — per the
	 * §10.1 secret-material rule, the value itself MUST NOT
	 * appear in the message.
	 *
	 * @param string $field Field name for the error message.
	 * @param string $value Raw value as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$value` is empty
	 *                                  after trimming.
	 */
	private static function normalizeRequired( string $field, string $value ): string {
		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'YahooShoppingCredentials %s must be a non-empty string.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize an optional credential field at
	 * construction.
	 *
	 * Null passes through unchanged. A non-null value is trimmed
	 * and rejected when the trim leaves the empty string. As with
	 * {@see self::normalizeRequired()}, the error message names
	 * the field but MUST NOT include the raw value.
	 *
	 * @param string      $field Field name for the error message.
	 * @param string|null $value Raw value as supplied by the caller.
	 *
	 * @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(
				'YahooShoppingCredentials %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;
	}
}
