<?php
/**
 * Amazon Creators API credentials value object.
 *
 * Phase B (KI-019, ADR-0022) immutable value object that carries
 * the OAuth 2.0 credentials needed to authenticate against
 * Amazon's Creators API. The Creators API supersedes Product
 * Advertising API 5.0 (PA-API 5.0), which Amazon is deprecating
 * on 2026-05-15; this VO replaces the legacy
 * {@see \Affilun\Infrastructure\Provider\Amazon\AmazonCredentials}
 * (Access Key / Secret Key / Associate Tag / Region) with the new
 * shape Amazon publishes at
 * https://affiliate-program.amazon.com/creatorsapi/docs/.
 *
 * Field semantics:
 *
 *   - `credentialId` — Amazon Creators API Credential ID. Issued
 *     in Associates Central > Tools > Creators API by clicking
 *     "Create Credential" against an existing Application. Maps
 *     to the OAuth 2.0 `client_id` value forwarded to the token
 *     endpoint. Replaces the legacy AWS Access Key.
 *
 *   - `credentialSecret` — Amazon Creators API Credential Secret.
 *     Issued together with the Credential ID and shown only once
 *     in the Associates Central UI. Maps to the OAuth 2.0
 *     `client_secret`. Replaces the legacy AWS Secret Key.
 *
 *   - `version` — Credential Version. Closed enum drawn from the
 *     `provider-api-contracts.md` §9.1 "Token 取得エンドポイント"
 *     table:
 *
 *       - `2.1` (NA region, Cognito token endpoint)
 *       - `2.2` (EU region, Cognito token endpoint)
 *       - `2.3` (FE region, Cognito token endpoint)
 *       - `3.1` (NA region, LwA token endpoint)
 *       - `3.2` (EU region, LwA token endpoint)
 *       - `3.3` (FE region, LwA token endpoint)
 *
 *     Amazon assigns the version when credentials are created and
 *     prints it in the Associates Central UI alongside the
 *     Credential ID / Secret. Determines:
 *
 *       - {@see self::tokenEndpoint()} — the OAuth 2.0 token
 *         endpoint URL the {@see AmazonCreatorsApiTokenProvider}
 *         hits to fetch / refresh the Bearer token.
 *
 *       - {@see self::tokenScheme()} — `cognito` for v2.x or
 *         `lwa` for v3.x. The two flows differ in request body
 *         shape (`application/x-www-form-urlencoded` for Cognito,
 *         `application/json` for LwA) and in the scope token
 *         (`creatorsapi/default` vs `creatorsapi::default`).
 *
 *       - {@see self::requiresVersionInAuthorizationHeader()} —
 *         v2.x credentials require the `Authorization` header on
 *         API calls to carry the version suffix
 *         (`Authorization: Bearer <token>, Version <version>`),
 *         per the Creators API migration guide. v3.x credentials
 *         use the bare `Authorization: Bearer <token>` form.
 *
 *   - `partnerTag` — Amazon Associates Partner Tag (a.k.a.
 *     Associate Tag, e.g. `mytag-22`). Surfaces in every
 *     `SearchItems` / `GetItems` request payload as the
 *     `partnerTag` field, and is what stamps every affiliate URL
 *     Amazon returns with the Associate's tracking suffix.
 *
 *   - `marketplace` — Target Amazon marketplace host
 *     (e.g. `www.amazon.co.jp`, `www.amazon.com`). Surfaces in
 *     every API call both as the `x-marketplace` HTTP header and
 *     as the `marketplace` field in the JSON request body, per
 *     the Amazon Creators API "Common Request Headers and
 *     Parameters" reference. Credentials are global; the
 *     marketplace selection is per-request, so this field
 *     captures the operator-configured default marketplace for
 *     this credential pair.
 *
 * Validation rules:
 *
 *   - All five fields MUST be non-empty after trimming. Empty
 *     credentials cannot reach the API and the upstream UI is
 *     responsible for refusing to "configure" an Amazon provider
 *     until every field is filled in.
 *
 *   - `version` MUST be one of {@see self::VALID_VERSIONS}. Any
 *     other value (typo, future version Amazon hasn't shipped
 *     yet, accidental locale code) raises
 *     {@see InvalidArgumentException} so the misconfiguration
 *     surfaces at construction time rather than during the first
 *     token fetch.
 *
 *   - `marketplace` MUST start with `www.amazon.` and contain
 *     only printable ASCII so a typo (a stray Japanese full-width
 *     character, an unrelated host) does not silently land as the
 *     `x-marketplace` header value. The whitelist is permissive
 *     enough to admit every marketplace Amazon documents (`.com`
 *     / `.co.jp` / `.co.uk` / `.de` / `.fr` / `.it` / `.es` /
 *     `.ca` / `.com.mx` / `.com.br` / `.com.au` / `.in` / `.nl` /
 *     `.com.be` / `.eg` / `.ie` / `.pl` / `.sa` / `.se` / `.tr` /
 *     `.ae` / `.sg`) without locking the VO to a hard-coded
 *     enumeration that would need a code change every time
 *     Amazon adds a marketplace.
 *
 * Immutability:
 *
 *   - All fields are private and only exposed through accessor
 *     methods. There is no `with*()` mutator surface; rotating a
 *     credential is modeled as constructing a fresh instance from
 *     the new values, which mirrors the
 *     {@see \Affilun\Application\Settings\ProviderCredentials}
 *     "rotating-input editor" pattern the §13.4 Settings tab
 *     already uses.
 *
 * What this VO deliberately does NOT carry:
 *
 *   - The OAuth 2.0 access token. Token acquisition is the
 *     {@see AmazonCreatorsApiTokenProvider} responsibility; this
 *     VO only carries the long-lived credentials the token
 *     provider uses to fetch the short-lived (1 hour) Bearer
 *     token.
 *
 *   - The Creators API base URL. The base
 *     `https://creatorsapi.amazon` is global per ADR-0022 §1.3
 *     and is hard-coded at the request-factory layer, not
 *     parameterized through credentials.
 *
 *   - Region or marketplace enum information beyond what the
 *     `version` and `marketplace` fields carry. The `version` ->
 *     region mapping is encoded in {@see self::TOKEN_ENDPOINTS}
 *     (the same source of truth `provider-api-contracts.md` §9.1
 *     references); callers MUST NOT re-derive region tokens from
 *     `marketplace` because Amazon explicitly documents that
 *     credentials work globally and only the token endpoint is
 *     bound to the version's home region.
 *
 * @package Affilun\Infrastructure\Provider\Amazon\CreatorsApi
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Provider\Amazon\CreatorsApi;

// phpcs:disable Generic.Commenting.DocComment.LongNotCapital,Squiz.Commenting.FunctionComment.MissingParamTag -- Version tokens such as v2.x/v3.x intentionally start docblock prose and helpers are documented at file level.

use InvalidArgumentException;

/**
 * Immutable Amazon Creators API credentials snapshot.
 *
 * See the file-level docblock for field semantics, the closed
 * `version` enum, the marketplace whitelist rationale, and the
 * `version` -> token endpoint resolver contract.
 */
final class AmazonCreatorsApiCredentials {

	/**
	 * Closed set of accepted `version` tokens, in declaration
	 * order. Mirrors `provider-api-contracts.md` §9.1 verbatim.
	 *
	 * Held as a class constant so the constructor validator,
	 * {@see self::tokenEndpoint()}, {@see self::tokenScheme()},
	 * and the test suite share a single source of truth.
	 *
	 * @var list<string>
	 */
	public const VALID_VERSIONS = array( '2.1', '2.2', '2.3', '3.1', '3.2', '3.3' );

	/**
	 * Token-acquisition scheme token returned by
	 * {@see self::tokenScheme()} for v2.x credentials.
	 *
	 * v2.x credentials authenticate against Amazon Cognito with
	 * an `application/x-www-form-urlencoded` body and the
	 * `creatorsapi/default` scope, per the Creators API "Using
	 * cURL" reference (Step 1, Method 1 / Method 2).
	 */
	public const SCHEME_COGNITO = 'cognito';

	/**
	 * Token-acquisition scheme token returned by
	 * {@see self::tokenScheme()} for v3.x credentials.
	 *
	 * v3.x credentials authenticate against Login with Amazon
	 * (LwA) with an `application/json` body and the
	 * `creatorsapi::default` scope (note the double colon — LwA's
	 * scope token differs from the Cognito form by one character).
	 */
	public const SCHEME_LWA = 'lwa';

	/**
	 * `version` -> OAuth 2.0 token endpoint URL.
	 *
	 * Sourced verbatim from `provider-api-contracts.md` §9.1
	 * "Token 取得エンドポイント" table and from the Creators API
	 * "Using cURL" reference's Regional Endpoints section. Any
	 * change to either upstream document MUST be reflected here.
	 *
	 * The Cognito endpoints (v2.x) carry the region in the
	 * subdomain (`us-east-1` / `eu-south-2` / `us-west-2`); the
	 * LwA endpoints (v3.x) use Amazon's per-region Login domains
	 * (`amazon.com` / `amazon.co.uk` / `amazon.co.jp`). All six
	 * endpoints serve POST requests; the request body shape and
	 * `Content-Type` differ by scheme — see {@see self::tokenScheme()}.
	 *
	 * @var array<string, string>
	 */
	private const TOKEN_ENDPOINTS = array(
		// v2.x — Cognito.
		'2.1' => 'https://creatorsapi.auth.us-east-1.amazoncognito.com/oauth2/token',
		'2.2' => 'https://creatorsapi.auth.eu-south-2.amazoncognito.com/oauth2/token',
		'2.3' => 'https://creatorsapi.auth.us-west-2.amazoncognito.com/oauth2/token',

		// v3.x — Login with Amazon (LwA).
		'3.1' => 'https://api.amazon.com/auth/o2/token',
		'3.2' => 'https://api.amazon.co.uk/auth/o2/token',
		'3.3' => 'https://api.amazon.co.jp/auth/o2/token',
	);

	/**
	 * Marketplace host whitelist regex.
	 *
	 * Permissive enough to admit every Amazon marketplace
	 * (`www.amazon.com`, `www.amazon.co.jp`, `www.amazon.co.uk`,
	 * `www.amazon.com.br`, `www.amazon.com.au`, etc.) without
	 * locking the VO to a hard-coded enumeration that would need
	 * a code change every time Amazon adds a marketplace.
	 *
	 * The pattern requires:
	 *
	 *   - Exact `www.amazon.` prefix (case-sensitive — Amazon's
	 *     own documentation uses lowercase only).
	 *
	 *   - At least one ASCII alphanumeric character after the
	 *     `www.amazon.` prefix.
	 *
	 *   - Subsequent characters drawn from `[a-z0-9.]` so
	 *     compound TLDs like `co.jp` and `com.br` pass while
	 *     rejecting full-width / CJK characters, whitespace, and
	 *     control bytes.
	 *
	 * Intentionally does NOT verify that the host is one Amazon
	 * actually serves — the spec §9.1 explicitly says the
	 * marketplace list grows over time and this VO refuses to
	 * become a stale enumeration.
	 */
	private const MARKETPLACE_PATTERN = '/^www\.amazon\.[a-z0-9][a-z0-9.]*$/';

	/**
	 * Amazon Creators API Credential ID (OAuth 2.0 `client_id`).
	 */
	private string $credentialId;

	/**
	 * Amazon Creators API Credential Secret (OAuth 2.0 `client_secret`).
	 */
	private string $credentialSecret;

	/**
	 * Credential Version (one of {@see self::VALID_VERSIONS}).
	 */
	private string $version;

	/**
	 * Amazon Associates Partner Tag (e.g. `mytag-22`).
	 */
	private string $partnerTag;

	/**
	 * Target Amazon marketplace host (e.g. `www.amazon.co.jp`).
	 */
	private string $marketplace;

	/**
	 * Construct an immutable credentials snapshot.
	 *
	 * Every field is validated at construction time so a
	 * misconfiguration surfaces here rather than at the first
	 * token fetch / API call.
	 *
	 * @param string $credentialId     Creators API Credential ID
	 *                                 (non-empty after trim).
	 * @param string $credentialSecret Creators API Credential Secret
	 *                                 (non-empty after trim).
	 * @param string $version          Credential Version (one of
	 *                                 {@see self::VALID_VERSIONS}).
	 * @param string $partnerTag       Associates Partner Tag
	 *                                 (non-empty after trim).
	 * @param string $marketplace      Marketplace host
	 *                                 (matching {@see self::MARKETPLACE_PATTERN}).
	 *
	 * @throws InvalidArgumentException When any field is empty
	 *                                  after trimming, when
	 *                                  `version` is outside the
	 *                                  closed enum, or when
	 *                                  `marketplace` does not
	 *                                  match the host whitelist.
	 */
	public function __construct(
		string $credentialId,
		string $credentialSecret,
		string $version,
		string $partnerTag,
		string $marketplace
	) {
		$this->credentialId     = self::requireNonEmpty( 'credentialId', $credentialId );
		$this->credentialSecret = self::requireNonEmpty( 'credentialSecret', $credentialSecret );
		$this->version          = self::requireValidVersion( $version );
		$this->partnerTag       = self::requireNonEmpty( 'partnerTag', $partnerTag );
		$this->marketplace      = self::requireValidMarketplace( $marketplace );
	}

	/**
	 * Amazon Creators API Credential ID (OAuth 2.0 `client_id`).
	 */
	public function credentialId(): string {
		return $this->credentialId;
	}

	/**
	 * Amazon Creators API Credential Secret (OAuth 2.0
	 * `client_secret`).
	 *
	 * Callers that surface the value to UI / REST surfaces MUST
	 * route it through
	 * {@see \Affilun\Application\Settings\SettingsCredentialMask}
	 * first; this accessor exposes the plain value because the
	 * token provider needs it verbatim to authenticate.
	 */
	public function credentialSecret(): string {
		return $this->credentialSecret;
	}

	/**
	 * Credential Version (one of {@see self::VALID_VERSIONS}).
	 */
	public function version(): string {
		return $this->version;
	}

	/**
	 * Amazon Associates Partner Tag.
	 */
	public function partnerTag(): string {
		return $this->partnerTag;
	}

	/**
	 * Target Amazon marketplace host (e.g. `www.amazon.co.jp`).
	 */
	public function marketplace(): string {
		return $this->marketplace;
	}

	/**
	 * Resolve the OAuth 2.0 token endpoint URL the
	 * {@see AmazonCreatorsApiTokenProvider} should hit for this
	 * credential pair.
	 *
	 * The mapping is sourced from the constructor-validated
	 * `version` field and is therefore total: any value that
	 * passed the constructor's `version` validation has a
	 * corresponding endpoint, so this method never throws.
	 */
	public function tokenEndpoint(): string {
		return self::TOKEN_ENDPOINTS[ $this->version ];
	}

	/**
	 * Resolve the token-acquisition scheme for this credential
	 * pair.
	 *
	 * Returns one of {@see self::SCHEME_COGNITO} (v2.x) or
	 * {@see self::SCHEME_LWA} (v3.x). Used by
	 * {@see AmazonCreatorsApiTokenProvider} to branch between the
	 * Cognito form-encoded request shape and the LwA JSON
	 * request shape.
	 */
	public function tokenScheme(): string {
		return self::isV2( $this->version ) ? self::SCHEME_COGNITO : self::SCHEME_LWA;
	}

	/**
	 * Whether the `Authorization` header on Creators API calls
	 * (NOT on the token endpoint) must carry the version suffix.
	 *
	 * Per the Creators API migration guide, v2.x credentials
	 * require:
	 *
	 *   `Authorization: Bearer <access_token>, Version <version>`
	 *
	 * while v3.x credentials use the bare:
	 *
	 *   `Authorization: Bearer <access_token>`
	 *
	 * The {@see AmazonCreatorsApiRequestFactory} reads this flag
	 * to decide which form to stamp on the outgoing request's
	 * `Authorization` header.
	 */
	public function requiresVersionInAuthorizationHeader(): bool {
		return self::isV2( $this->version );
	}

	/**
	 * Return the closed set of accepted credential versions in
	 * declaration order.
	 *
	 * Exposed so test fixtures and diagnostics surfaces (the
	 * §13.4 Settings UI's "Credential Version" dropdown,
	 * candidate UI tooling, etc.) can iterate the valid tokens
	 * without duplicating the literal list.
	 *
	 * @return list<string>
	 */
	public static function validVersions(): array {
		return self::VALID_VERSIONS;
	}

	/**
	 * Validate a non-empty string field and return the trimmed
	 * value.
	 *
	 * @param string $field Field name for the error message.
	 * @param string $value Raw value supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$value` is empty
	 *                                  after trimming.
	 */
	private static function requireNonEmpty( string $field, string $value ): string {
		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'AmazonCreatorsApiCredentials %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 the `version` field against {@see self::VALID_VERSIONS}.
	 *
	 * @param string $version Raw version supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$version` is outside
	 *                                  the closed enum.
	 */
	private static function requireValidVersion( string $version ): string {
		$trimmed = trim( $version );
		if ( ! in_array( $trimmed, self::VALID_VERSIONS, true ) ) {
			$message = sprintf(
				'AmazonCreatorsApiCredentials version must be one of {%s}; got "%s".',
				implode( ', ', self::VALID_VERSIONS ),
				$version
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate the `marketplace` field against
	 * {@see self::MARKETPLACE_PATTERN}.
	 *
	 * @param string $marketplace Raw marketplace host supplied by
	 *                            the caller.
	 *
	 * @throws InvalidArgumentException When `$marketplace` does
	 *                                  not match the whitelist
	 *                                  pattern.
	 */
	private static function requireValidMarketplace( string $marketplace ): string {
		$trimmed = trim( $marketplace );
		if ( $trimmed === '' || preg_match( self::MARKETPLACE_PATTERN, $trimmed ) !== 1 ) {
			$message = sprintf(
				'AmazonCreatorsApiCredentials marketplace must match /^www\\.amazon\\.[a-z0-9][a-z0-9.]*$/; got "%s".',
				$marketplace
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Whether the supplied (already-validated) version is a v2.x
	 * (Cognito) credential.
	 *
	 * Centralizes the v2.x / v3.x branching used by
	 * {@see self::tokenScheme()} and
	 * {@see self::requiresVersionInAuthorizationHeader()} so a
	 * future v4.x addition only needs to update the constants
	 * and this single helper.
	 */
	private static function isV2( string $version ): bool {
		return $version === '2.1' || $version === '2.2' || $version === '2.3';
	}
}
