<?php
/**
 * Provider credentials value object.
 *
 * Immutable Phase-8 value object that carries a single
 * affiliate provider's credentials. The shape of the
 * `secrets` map varies per provider — Amazon uses
 * `access_key` / `secret_key` / `associate_tag`, Rakuten
 * uses `application_id` / `affiliate_id`, Yahoo Shopping
 * uses `app_id` (Client ID) — and this VO
 * deliberately does NOT enforce a per-provider sub-schema.
 * That responsibility belongs to a future Phase-3
 * ProviderCapabilities-driven validator; this VO only
 * enforces the structural invariants every provider's
 * credentials share:
 *
 *   - `providerKey` is a non-empty lowercase identifier
 *     matching `^[a-z][a-z0-9_]*$` so it can be used as a
 *     map key under {@see \CommerceLinkBuilder\Application\Settings\PluginSettings}
 *     without further escaping.
 *
 *   - `secrets` is an `array<string, string>` whose keys
 *     are non-empty strings and whose values are strings
 *     (the empty string is allowed because the rotating-
 *     input editor of `specifications/security-and-compliance.md`
 *     §6.3 treats absence as "not yet configured").
 *
 *   - `enabled` is the provider-toggle flag the §13.4
 *     Providers tab exposes; it is independent of credential
 *     completeness ({@see self::isConfigured()}).
 *
 * Per `specifications/security-and-compliance.md` §6.3 / §6.4
 * the plaintext values MUST NEVER be logged, echoed, or
 * round-tripped through `__toString()` / `__debugInfo()`.
 * This VO therefore deliberately:
 *
 *   - Does NOT implement `__toString()` so a `(string) $creds`
 *     coercion would raise a TypeError rather than silently
 *     leak the values.
 *   - Does NOT implement `__debugInfo()` so a `var_dump()` of
 *     the object during development still requires a deliberate
 *     reflection step rather than printing secrets.
 *   - Exposes a {@see self::masked()} helper that returns the
 *     §6.3-masked map. Every REST / UI surface that needs to
 *     surface credential values MUST use {@see self::masked()};
 *     {@see self::secrets()} / {@see self::secret()} are
 *     reserved for the provider-implementation boundary that
 *     actually signs outbound HTTP requests.
 *
 * Construction validates the structural invariants above and
 * raises {@see \InvalidArgumentException} with a developer-
 * facing message that NEVER includes the raw secret values.
 *
 * Instances are immutable; mutation helpers
 * ({@see self::withEnabled()} / {@see self::withSecret()})
 * return fresh instances.
 *
 * @package CommerceLinkBuilder\Application\Settings
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Settings;

use InvalidArgumentException;

/**
 * Immutable per-provider credentials bundle.
 *
 * See the file-level docblock for the structural invariants
 * this VO enforces, the §6.3 masking rule that drives the
 * {@see self::masked()} accessor, and the rationale for not
 * exposing magic-method serialization hooks.
 */
final class ProviderCredentials {

	/**
	 * Lowercase provider identifier matching `^[a-z][a-z0-9_]*$`.
	 */
	private string $providerKey;

	/**
	 * Provider-toggle flag exposed by the §13.4 Providers tab.
	 */
	private bool $enabled;

	/**
	 * Opaque secrets map. Keys are provider-specific (e.g.
	 * `access_key`, `application_id`) and values are stored
	 * verbatim; the empty string is allowed as the "not yet
	 * configured" placeholder.
	 *
	 * @var array<string, string>
	 */
	private array $secrets;

	/**
	 * Construct a credentials snapshot with explicit values for
	 * every field.
	 *
	 * @param string                $providerKey Lowercase provider identifier.
	 * @param bool                  $enabled     Provider-toggle flag.
	 * @param array<string, string> $secrets     Opaque secrets map.
	 *
	 * @throws InvalidArgumentException When `$providerKey` violates
	 *                                  the `^[a-z][a-z0-9_]*$`
	 *                                  grammar, or when `$secrets`
	 *                                  contains a non-string key /
	 *                                  empty key / non-string
	 *                                  value.
	 */
	public function __construct( string $providerKey, bool $enabled, array $secrets ) {
		$this->providerKey = self::normalizeProviderKey( $providerKey );
		$this->enabled     = $enabled;
		$this->secrets     = self::normalizeSecrets( $secrets );
	}

	/**
	 * Construct an Amazon credentials snapshot.
	 *
	 * **KI-019 Phase B (ADR-0022)**: 旧 PA-API 5.0 shape (`access_key`
	 * / `secret_key` / `associate_tag` + AWS SigV4) を完全に置換し、
	 * Amazon Creators API の OAuth 2.0 credentials shape を採用。
	 * 旧 shape の Access Key / Secret Key は本 factory では受け付けず、
	 * 旧 PA-API credentials を保存していた利用者は Associates Central で
	 * Creators API credentials を新規発行する必要がある (自動変換は不可能、
	 * ADR-0022 §2.2)。
	 *
	 * The secrets map carries:
	 *
	 *   - `credential_id` — Creators API Credential ID
	 *     (旧 Access Key の置換、Associates Central で発行)
	 *   - `credential_secret` — Creators API Credential Secret
	 *     (旧 Secret Key の置換、発行時に 1 回だけ表示される)
	 *   - `version` — Credential Version (`2.1` / `2.2` / `2.3` /
	 *     `3.1` / `3.2` / `3.3` の closed enum、token endpoint と
	 *     region 切替に使用、`provider-api-contracts.md` §9.1 の
	 *     "Token 取得エンドポイント" テーブル参照)
	 *   - `partner_tag` — Associates Partner Tag (旧 Associate Tag、
	 *     アフィリエイトリンクの `tag=` パラメータに使用)
	 *   - `marketplace` — Target Amazon marketplace
	 *     (`www.amazon.com` / `www.amazon.co.jp` 等、`x-marketplace`
	 *     ヘッダと request body `marketplace` フィールドの両方に
	 *     セットする)
	 *
	 * Empty strings are permitted so the rotating-input editor can
	 * persist "enabled but not yet configured" intermediate states.
	 *
	 * @param bool   $enabled          Provider-toggle flag.
	 * @param string $credentialId     Creators API Credential ID.
	 * @param string $credentialSecret Creators API Credential Secret.
	 * @param string $version          Credential Version (`2.1` / `2.2` /
	 *                                 `2.3` / `3.1` / `3.2` / `3.3`).
	 * @param string $partnerTag       Associates Partner Tag.
	 * @param string $marketplace      Target Amazon marketplace
	 *                                 (e.g. `www.amazon.co.jp`).
	 */
	public static function amazon(
		bool $enabled,
		string $credentialId,
		string $credentialSecret,
		string $version,
		string $partnerTag,
		string $marketplace
	): self {
		return new self(
			'amazon',
			$enabled,
			array(
				'credential_id'     => $credentialId,
				'credential_secret' => $credentialSecret,
				'version'           => $version,
				'partner_tag'       => $partnerTag,
				'marketplace'       => $marketplace,
			)
		);
	}

	/**
	 * Construct a Rakuten credentials snapshot.
	 *
	 * The secrets map carries `application_id` and
	 * `affiliate_id` — the two fields the §13.4 Rakuten
	 * section exposes.
	 *
	 * @param bool   $enabled       Provider-toggle flag.
	 * @param string $applicationId Rakuten Application ID.
	 * @param string $affiliateId   Rakuten Affiliate ID.
	 */
	public static function rakuten(
		bool $enabled,
		string $applicationId,
		string $affiliateId
	): self {
		return new self(
			'rakuten',
			$enabled,
			array(
				'application_id' => $applicationId,
				'affiliate_id'   => $affiliateId,
			)
		);
	}

	/**
	 * Construct a Yahoo Shopping credentials snapshot.
	 *
	 * The secrets map carries `app_id` (Yahoo Developer Network
	 * Client ID, the only credential the §9.3 商品検索 v3 endpoint
	 * requires). The legacy `affiliate_parameters` slot is still
	 * accepted for backward compatibility with already-persisted
	 * settings rows but is treated as opaque — 商品検索 v3 does
	 * not consume it and the §13.4 form no longer surfaces it.
	 *
	 * @param bool        $enabled              Provider-toggle flag.
	 * @param string      $appId                Yahoo Client ID.
	 * @param string|null $affiliateParameters  Optional opaque
	 *                                          legacy parameter
	 *                                          string; pass null
	 *                                          (the default) to
	 *                                          omit the slot.
	 */
	public static function yahoo(
		bool $enabled,
		string $appId,
		?string $affiliateParameters = null
	): self {
		$secrets = array(
			'app_id' => $appId,
		);
		if ( $affiliateParameters !== null ) {
			$secrets['affiliate_parameters'] = $affiliateParameters;
		}

		return new self(
			'yahoo_shopping',
			$enabled,
			$secrets
		);
	}

	/**
	 * Lowercase provider identifier.
	 */
	public function providerKey(): string {
		return $this->providerKey;
	}

	/**
	 * Provider-toggle flag exposed by the §13.4 Providers tab.
	 */
	public function enabled(): bool {
		return $this->enabled;
	}

	/**
	 * Full secrets map.
	 *
	 * Returned verbatim. Callers that need to surface the values
	 * to UI / REST surfaces MUST use {@see self::masked()}
	 * instead; this accessor is reserved for the provider
	 * implementation boundary that signs outbound HTTP requests.
	 *
	 * @return array<string, string>
	 */
	public function secrets(): array {
		return $this->secrets;
	}

	/**
	 * Look up a single secret by key.
	 *
	 * Returns null when the key is absent so callers can
	 * distinguish "not configured" from "configured as the
	 * empty string".
	 *
	 * @param string $key Secret key.
	 */
	public function secret( string $key ): ?string {
		if ( ! array_key_exists( $key, $this->secrets ) ) {
			return null;
		}

		return $this->secrets[ $key ];
	}

	/**
	 * Produce a copy of this snapshot with `enabled` replaced.
	 *
	 * @param bool $enabled New provider-toggle flag.
	 */
	public function withEnabled( bool $enabled ): self {
		$clone          = clone $this;
		$clone->enabled = $enabled;
		return $clone;
	}

	/**
	 * Produce a copy of this snapshot with the supplied secret
	 * key set to the supplied value.
	 *
	 * Adds the entry when missing, overwrites it when present.
	 * The key is validated against the same non-empty-string
	 * rule as the constructor.
	 *
	 * @param string $key   Secret key.
	 * @param string $value Secret value (empty string permitted).
	 *
	 * @throws InvalidArgumentException When `$key` is the empty
	 *                                  string after trimming.
	 */
	public function withSecret( string $key, string $value ): self {
		$normalizedKey = trim( $key );
		if ( $normalizedKey === '' ) {
			$message = 'ProviderCredentials secret key must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$clone                            = clone $this;
		$clone->secrets[ $normalizedKey ] = $value;
		return $clone;
	}

	/**
	 * Report whether every secret value is non-empty after
	 * trimming.
	 *
	 * `enabled` is intentionally NOT consulted here: this probe
	 * answers "are the values complete enough to attempt an API
	 * call?", which the §13.4 Validate Connection button needs
	 * regardless of the toggle state.
	 */
	public function isConfigured(): bool {
		if ( $this->secrets === array() ) {
			return false;
		}

		foreach ( $this->secrets as $value ) {
			if ( trim( $value ) === '' ) {
				return false;
			}
		}

		return true;
	}

	/**
	 * Return the secrets map with every value replaced by its
	 * §6.3-masked form.
	 *
	 * Used by every REST / UI surface that needs to surface
	 * credential values to the admin. The keys are preserved so
	 * the masked map round-trips through the rotating-input
	 * editor unchanged.
	 *
	 * @return array<string, string>
	 */
	public function masked(): array {
		$masked = array();
		foreach ( $this->secrets as $key => $value ) {
			$masked[ $key ] = SettingsCredentialMask::mask( $value );
		}

		return $masked;
	}

	/**
	 * Report whether this snapshot equals the supplied one.
	 *
	 * Equality is structural: the provider key, enabled flag,
	 * and the full secrets map must match under strict
	 * comparison.
	 *
	 * @param self $other Other snapshot to compare against.
	 */
	public function equals( self $other ): bool {
		return $this->providerKey === $other->providerKey
			&& $this->enabled === $other->enabled
			&& $this->secrets === $other->secrets;
	}

	/**
	 * Serialize this snapshot for {@see \CommerceLinkBuilder\Infrastructure\Persistence\OptionStore}
	 * persistence.
	 *
	 * The shape is the inverse of {@see self::fromArray()}:
	 * `array('provider_key' => ..., 'enabled' => ..., 'secrets' => $secrets)`.
	 *
	 * @return array<string, mixed>
	 */
	public function toArray(): array {
		return array(
			'provider_key' => $this->providerKey,
			'enabled'      => $this->enabled,
			'secrets'      => $this->secrets,
		);
	}

	/**
	 * Construct a snapshot from a {@see self::toArray()} payload.
	 *
	 * Performs full structural validation: missing keys, wrong
	 * types, and the same per-field rules the constructor
	 * enforces all surface as {@see InvalidArgumentException}
	 * rather than silently coercing.
	 *
	 * @param array<string, mixed> $data Persisted payload.
	 *
	 * @throws InvalidArgumentException When `$data` is malformed.
	 */
	public static function fromArray( array $data ): self {
		if ( ! array_key_exists( 'provider_key', $data ) || ! is_string( $data['provider_key'] ) ) {
			$message = 'ProviderCredentials payload must carry a string `provider_key`.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! array_key_exists( 'enabled', $data ) || ! is_bool( $data['enabled'] ) ) {
			$message = 'ProviderCredentials payload must carry a bool `enabled`.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( ! array_key_exists( 'secrets', $data ) || ! is_array( $data['secrets'] ) ) {
			$message = 'ProviderCredentials payload must carry an array `secrets`.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return new self( $data['provider_key'], $data['enabled'], $data['secrets'] );
	}

	/**
	 * Validate and normalize the `providerKey` field at
	 * construction.
	 *
	 * @param string $providerKey Raw provider key.
	 *
	 * @throws InvalidArgumentException When the key violates the
	 *                                  `^[a-z][a-z0-9_]*$`
	 *                                  grammar.
	 */
	private static function normalizeProviderKey( string $providerKey ): string {
		if ( preg_match( '/^[a-z][a-z0-9_]*$/', $providerKey ) !== 1 ) {
			$message = 'ProviderCredentials providerKey must match ^[a-z][a-z0-9_]*$.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $providerKey;
	}

	/**
	 * Validate and normalize the `secrets` map at construction.
	 *
	 * Keys must be non-empty strings; values must be strings
	 * (empty string permitted). The map's iteration order is
	 * preserved verbatim because callers may rely on it for
	 * stable serialization.
	 *
	 * @param array<int|string, mixed> $secrets Raw secrets map.
	 *
	 * @return array<string, string>
	 *
	 * @throws InvalidArgumentException When `$secrets` violates
	 *                                  the per-key / per-value
	 *                                  rules above.
	 */
	private static function normalizeSecrets( array $secrets ): array {
		$result = array();
		foreach ( $secrets as $key => $value ) {
			if ( ! is_string( $key ) || trim( $key ) === '' ) {
				$message = 'ProviderCredentials secrets keys must be non-empty strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( ! is_string( $value ) ) {
				$message = sprintf(
					'ProviderCredentials secret `%s` must be a string.',
					$key
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$result[ $key ] = $value;
		}

		return $result;
	}
}
