<?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
 * `credential_id` / `credential_secret` / `version` /
 * `partner_tag` plus the fixed Japan marketplace, Rakuten
 * uses `application_id` / `access_key` / `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 \Affilun\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 Affilun\Application\Settings
 */

declare(strict_types=1);

namespace Affilun\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 {

	/**
	 * Default Amazon Creators API credential version for the
	 * Japan-focused affilun distribution.
	 */
	public const AMAZON_DEFAULT_VERSION = '3.3';

	/**
	 * Fixed Amazon marketplace for affilun's Japan-focused setup.
	 */
	public const AMAZON_MARKETPLACE = 'www.amazon.co.jp';

	public const BUTTON_TEXT_COLOR_AUTO  = 'auto';
	public const BUTTON_TEXT_COLOR_BLACK = 'black';
	public const BUTTON_TEXT_COLOR_WHITE = 'white';

	/**
	 * Default affiliate-button background colors, keyed by provider.
	 *
	 * @var array<string, string>
	 */
	public const DEFAULT_BUTTON_COLORS = array(
		'amazon'         => '#f6a306',
		'rakuten'        => '#cf4944',
		'yahoo_shopping' => '#51a7e8',
		'mercari'        => '#f2a1a1',
	);

	/**
	 * 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 );
	}

	/**
	 * Return the default affiliate-button color for a provider.
	 *
	 * @param string $providerKey Provider key.
	 */
	public static function defaultButtonColor( string $providerKey ): string {
		return self::DEFAULT_BUTTON_COLORS[ $providerKey ] ?? '#111827';
	}

	/**
	 * Normalize a user-supplied affiliate-button color.
	 *
	 * @param string $providerKey Provider key used for fallback.
	 * @param string $value       Candidate hex color.
	 */
	public static function normalizeButtonColor( string $providerKey, string $value ): string {
		$trimmed = trim( $value );
		if ( preg_match( '/\A#[0-9a-fA-F]{6}\z/', $trimmed ) === 1 ) {
			return strtolower( $trimmed );
		}

		return self::defaultButtonColor( $providerKey );
	}

	/**
	 * Normalize the provider-specific button text color preference.
	 *
	 * @param string $value Raw preference token.
	 */
	public static function normalizeButtonTextColor( string $value ): string {
		$normalized = strtolower( trim( $value ) );
		return in_array(
			$normalized,
			array( self::BUTTON_TEXT_COLOR_AUTO, self::BUTTON_TEXT_COLOR_BLACK, self::BUTTON_TEXT_COLOR_WHITE ),
			true
		)
			? $normalized
			: self::BUTTON_TEXT_COLOR_AUTO;
	}

	/**
	 * Resolve the actual text color for a button background and preference.
	 *
	 * @param string $backgroundColor Candidate hex background color.
	 * @param string $preference      One of auto/black/white.
	 */
	public static function resolveButtonTextColor( string $backgroundColor, string $preference ): string {
		return match ( self::normalizeButtonTextColor( $preference ) ) {
			self::BUTTON_TEXT_COLOR_BLACK => '#000000',
			self::BUTTON_TEXT_COLOR_WHITE => '#ffffff',
			default => self::automaticButtonTextColor( $backgroundColor ),
		};
	}

	/**
	 * Return an accessible black/white text color for a button background.
	 *
	 * Uses an APCA-style perceptual contrast comparison between black and white.
	 * This better matches UI text readability than a simple luminance threshold,
	 * while still keeping the output intentionally constrained to black/white.
	 *
	 * @param string $backgroundColor Candidate hex background color.
	 */
	public static function automaticButtonTextColor( string $backgroundColor ): string {
		$background = self::normalizeButtonColor( 'custom', $backgroundColor );
		$blackScore = abs( self::apcaContrast( '#000000', $background ) );
		$whiteScore = abs( self::apcaContrast( '#ffffff', $background ) );

		return $whiteScore > $blackScore ? '#ffffff' : '#000000';
	}

	/**
	 * Calculate APCA-style contrast for text on a background.
	 *
	 * Positive values mean dark text on a light background; negative values mean
	 * light text on a dark background. Callers compare absolute values when they
	 * only need to choose the stronger black/white polarity.
	 *
	 * @param string $textColor       Normalized `#rrggbb` text color.
	 * @param string $backgroundColor Normalized `#rrggbb` background color.
	 */
	private static function apcaContrast( string $textColor, string $backgroundColor ): float {
		$textY       = self::softClampLuminance( self::relativeLuminanceFromHex( $textColor ) );
		$backgroundY = self::softClampLuminance( self::relativeLuminanceFromHex( $backgroundColor ) );

		if ( abs( $backgroundY - $textY ) < 0.0005 ) {
			return 0.0;
		}

		if ( $backgroundY > $textY ) {
			$contrast = ( ( $backgroundY ** 0.56 ) - ( $textY ** 0.57 ) ) * 1.14;
			return $contrast < 0.1 ? 0.0 : ( $contrast - 0.027 ) * 100;
		}

		$contrast = ( ( $backgroundY ** 0.65 ) - ( $textY ** 0.62 ) ) * 1.14;
		return $contrast > -0.1 ? 0.0 : ( $contrast + 0.027 ) * 100;
	}

	/**
	 * Soft-clamp near-black luminance per APCA's black threshold behavior.
	 *
	 * @param float $luminance WCAG relative luminance.
	 */
	private static function softClampLuminance( float $luminance ): float {
		$threshold = 0.022;
		return $luminance >= $threshold ? $luminance : $luminance + ( ( $threshold - $luminance ) ** 1.414 );
	}

	/**
	 * Calculate WCAG relative luminance for a hex color.
	 *
	 * @param string $color Normalized `#rrggbb` color.
	 */
	private static function relativeLuminanceFromHex( string $color ): float {
		$red   = hexdec( substr( $color, 1, 2 ) );
		$green = hexdec( substr( $color, 3, 2 ) );
		$blue  = hexdec( substr( $color, 5, 2 ) );

		return self::relativeLuminance( $red, $green, $blue );
	}


	/**
	 * Calculate WCAG relative luminance for an RGB color.
	 *
	 * @param int $red   Red channel, 0-255.
	 * @param int $green Green channel, 0-255.
	 * @param int $blue  Blue channel, 0-255.
	 */
	private static function relativeLuminance( int $red, int $green, int $blue ): float {
		$channels = array_map(
			static function ( int $channel ): float {
				$value = $channel / 255;
				return $value <= 0.04045 ? $value / 12.92 : ( ( $value + 0.055 ) / 1.055 ) ** 2.4;
			},
			array( $red, $green, $blue )
		);

		return 0.2126 * $channels[0] + 0.7152 * $channels[1] + 0.0722 * $channels[2];
	}

	/**
	 * 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. affilun is
	 *     Japan-focused and fixes this to `www.amazon.co.jp`; the
	 *     value is stamped into both the `x-marketplace` header and
	 *     request body `marketplace` field.
	 *
	 * 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 the current Rakuten Web Service
	 * authentication shape:
	 *
	 *   - `application_id` — application identifier.
	 *   - `access_key` — required API access key.
	 *   - `affiliate_id` — affiliate identifier used to request
	 *     affiliate URLs.
	 *
	 * @param bool   $enabled       Provider-toggle flag.
	 * @param string $applicationId Rakuten Application ID.
	 * @param string $affiliateId   Rakuten Affiliate ID.
	 * @param string $accessKey     Rakuten Access Key.
	 */
	public static function rakuten(
		bool $enabled,
		string $applicationId,
		string $affiliateId,
		string $accessKey = ''
	): self {
		return new self(
			'rakuten',
			$enabled,
			array(
				'application_id' => $applicationId,
				'access_key'     => $accessKey,
				'affiliate_id'   => $affiliateId,
			)
		);
	}

	/**
	 * Construct a Yahoo Shopping credentials snapshot.
	 *
	 * The secrets map carries `app_id` (Yahoo Developer Network
	 * Client ID) plus optional ValueCommerce `affiliate_sid` /
	 * `affiliate_pid` fields used for Yahoo!ショッピング reward
	 * attribution. The legacy `affiliate_parameters` slot is still
	 * accepted for backward compatibility with already-persisted
	 * settings rows but is treated as opaque.
	 *
	 * @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.
	 * @param string|null $affiliateSid         ValueCommerce sid.
	 * @param string|null $affiliatePid         ValueCommerce pid.
	 */
	public static function yahoo(
		bool $enabled,
		string $appId,
		?string $affiliateParameters = null,
		?string $affiliateSid = null,
		?string $affiliatePid = null
	): self {
		$secrets = array(
			'app_id' => $appId,
		);
		if ( $affiliateParameters !== null ) {
			$secrets['affiliate_parameters'] = $affiliateParameters;
		}
		if ( $affiliateSid !== null ) {
			$secrets['affiliate_sid'] = $affiliateSid;
		}
		if ( $affiliatePid !== null ) {
			$secrets['affiliate_pid'] = $affiliatePid;
		}

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

	/**
	 * Construct a Mercari provider toggle snapshot.
	 *
	 * 0.4.0 uses Mercari for public search-button links. There is no API / SDK,
	 * but the Ambassador `afid` is stored so links can be stamped as affiliate
	 * URLs.
	 *
	 * @param bool   $enabled Provider-toggle flag.
	 * @param string $afid    Mercari Ambassador affiliate id.
	 */
	public static function mercari( bool $enabled, string $afid = '' ): self {
		return new self(
			'mercari',
			$enabled,
			array(
				'afid' => $afid,
			)
		);
	}

	/**
	 * 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 \Affilun\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 );
		}

		$secrets = $data['secrets'];
		if ( $data['provider_key'] === 'rakuten' && ! array_key_exists( 'access_key', $secrets ) ) {
			// Pre-0.8.0 settings remain intact but must be treated as
			// incomplete until the new Rakuten Access Key is entered.
			$secrets['access_key'] = '';
		}

		return new self( $data['provider_key'], $data['enabled'], $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;
	}
}
