<?php
/**
 * Rakuten provider credentials value object.
 *
 * Immutable value object that holds the credentials a
 * {@see \CommerceLinkBuilder\Infrastructure\Provider\Rakuten\RakutenProvider}
 * implementation needs to call the Rakuten Web Service. The fields
 * mirror `specifications/provider-api-contracts.md` §9.2 verbatim:
 *
 *   - `applicationId` is the Rakuten Application ID (per the §9.2
 *     "必須" list). Required by every Rakuten Web Service request:
 *     the IchibaItem Search endpoint validates the value as the
 *     `applicationId` query parameter and rejects calls that omit
 *     it.
 *
 *   - `affiliateId` is the Rakuten Affiliate ID (per the §9.2
 *     "必須" list). Required for affiliate-link minting and
 *     attached to search / lookup responses so the resulting
 *     detail URLs carry the affiliate-tracking parameters this
 *     site is registered under. Per §9.2 the field is mandatory at
 *     the credentials level even when a specific request would not
 *     need it; the provider implementation is responsible for
 *     conditionally forwarding it on the wire.
 *
 *   - `developerId` is the optional Rakuten Developer ID per the
 *     §9.2 "任意" list. Held as a nullable string because most
 *     deployments do not configure it; provider implementations
 *     forward it only when present.
 *
 * 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 two named accessors and the explicit
 *     `developerId()` are the only ways to read the values, and
 *     each one returns the field verbatim — provider
 *     implementations that build a query string read field-by-
 *     field and pass values to `$wpdb->prepare()` /
 *     `http_build_query()` rather than dumping the DTO.
 *   - Does NOT implement `Serializable` / PHP magic serialization
 *     hooks. Persistence of credentials is a Phase 8 admin-
 *     settings concern and goes through the dedicated WordPress
 *     options API, not through `serialize()` of this object.
 *
 * Construction validates each field is a non-empty trimmed string
 * (or null in the optional `developerId` case). The DTO does not
 * verify the values against Rakuten's actual format requirements
 * (Application ID is a 19-digit number; Affiliate ID is a hex
 * string with dashes) because 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 CommerceLinkBuilder\Infrastructure\Provider\Rakuten
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Provider\Rakuten;

use InvalidArgumentException;

/**
 * Immutable Rakuten Web Service credentials.
 *
 * See the file-level docblock for the mapping to
 * `provider-api-contracts.md` §9.2, 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 RakutenCredentials {

	/**
	 * Rakuten Application ID per §9.2 "必須". Validated as a
	 * non-empty trimmed string at construction time.
	 */
	private string $applicationId;

	/**
	 * Rakuten Affiliate ID per §9.2 "必須". Validated as a
	 * non-empty trimmed string at construction time.
	 */
	private string $affiliateId;

	/**
	 * Rakuten Developer ID per §9.2 "任意", or null when the
	 * deployment has not configured one.
	 */
	private ?string $developerId;

	/**
	 * Construct a Rakuten credentials snapshot.
	 *
	 * Both required fields are trimmed at construction time and
	 * rejected when empty after trimming. The optional
	 * `developerId` is 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, per
	 * the convention every other Domain / Infrastructure DTO in
	 * this codebase follows.
	 *
	 * Format validation (Application ID must be a 19-digit number,
	 * Affiliate ID must match Rakuten's hex-with-dashes shape, and
	 * so on) is deliberately NOT performed here. See the file-level
	 * docblock for the rationale.
	 *
	 * @param string      $applicationId Rakuten Application ID.
	 * @param string      $affiliateId   Rakuten Affiliate ID.
	 * @param string|null $developerId   Rakuten Developer ID, or null.
	 *
	 * @throws InvalidArgumentException When `$applicationId` or
	 *                                  `$affiliateId` is empty
	 *                                  after trimming, or when
	 *                                  `$developerId` is the empty
	 *                                  string after trimming.
	 */
	public function __construct(
		string $applicationId,
		string $affiliateId,
		?string $developerId = null
	) {
		$this->applicationId = self::normalizeRequired( 'applicationId', $applicationId );
		$this->affiliateId   = self::normalizeRequired( 'affiliateId', $affiliateId );
		$this->developerId   = self::normalizeOptional( 'developerId', $developerId );
	}

	/**
	 * Rakuten Application ID per §9.2 "必須".
	 *
	 * Returned verbatim — the value is the canonical form supplied
	 * to the constructor, with surrounding whitespace already
	 * trimmed off. Provider implementations that build a request
	 * pass this directly to the `applicationId` query parameter
	 * via {@see \CommerceLinkBuilder\Domain\Http\HttpRequest::withQueryParameter()}.
	 */
	public function applicationId(): string {
		return $this->applicationId;
	}

	/**
	 * Rakuten Affiliate ID per §9.2 "必須".
	 *
	 * Returned verbatim. Provider implementations append this to
	 * affiliate-tracked URLs and to search-request query strings
	 * when the request shape supports it.
	 */
	public function affiliateId(): string {
		return $this->affiliateId;
	}

	/**
	 * Rakuten Developer ID per §9.2 "任意", or null.
	 *
	 * Provider implementations forward this only when non-null.
	 * Most deployments leave the value unconfigured, so consuming
	 * surfaces SHOULD treat null as the common case rather than as
	 * an exceptional one.
	 */
	public function developerId(): ?string {
		return $this->developerId;
	}

	/**
	 * Report whether a Developer ID is configured.
	 *
	 * Convenience guard so call sites can branch on
	 * "is the developer id set?" without manually comparing
	 * {@see self::developerId()} against null. The dedicated
	 * helper keeps those branches readable and gives a stable
	 * surface for tests that assert on the configured / not-
	 * configured axis without binding to the underlying nullable
	 * accessor.
	 */
	public function hasDeveloperId(): bool {
		return $this->developerId !== 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
	 * (`applicationId` / `affiliateId`) 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(
				'RakutenCredentials %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(
				'RakutenCredentials %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;
	}
}
