<?php
/**
 * Amazon Creators API OAuth 2.0 access-token provider.
 *
 * Phase B (KI-019, ADR-0022) collaborator that owns the OAuth 2.0
 * `client_credentials` grant flow against the Amazon Creators API
 * token endpoints. Surfaces a single
 * {@see self::accessToken()} entry point that returns a usable
 * Bearer token to the caller, hiding the differences between the
 * v2.x (Cognito) and v3.x (LwA = Login with Amazon) token-fetch
 * shapes and the per-credential 1-hour cache lifetime.
 *
 * Why this class exists:
 *
 *   - Amazon Creators API requires every catalog request to carry
 *     an `Authorization: Bearer <access_token>` header (with an
 *     optional `, Version <version>` suffix for v2.x credentials).
 *     Tokens are valid for 3600 seconds and Amazon's "Best
 *     Practices" reference (see `Using cURL`) explicitly tells
 *     integrators to cache and reuse them rather than re-fetching
 *     on every call -- token endpoints are themselves rate-limited
 *     and counted toward the application's quota.
 *
 *   - The token-fetch flow differs by credential version (see
 *     {@see AmazonCreatorsApiCredentials::tokenScheme()}):
 *
 *       - v2.x (Cognito): `application/x-www-form-urlencoded`
 *         body with `grant_type=client_credentials&client_id=...
 *         &client_secret=...&scope=creatorsapi/default`. Hostname
 *         is one of the regional Cognito endpoints
 *         (`creatorsapi.auth.us-east-1.amazoncognito.com` etc.).
 *
 *       - v3.x (LwA): `application/json` body with
 *         `{"grant_type": "client_credentials",
 *           "client_id": "...", "client_secret": "...",
 *           "scope": "creatorsapi::default"}`
 *         (note the double colon in the scope; LwA's scope token
 *         differs from the Cognito form by one character).
 *         Hostname is one of the regional LwA endpoints
 *         (`api.amazon.com` / `api.amazon.co.uk` /
 *         `api.amazon.co.jp`).
 *
 *     The token endpoint URL itself is resolved by the credentials
 *     VO via {@see AmazonCreatorsApiCredentials::tokenEndpoint()};
 *     this class only branches on
 *     {@see AmazonCreatorsApiCredentials::tokenScheme()} for the
 *     request body shape and `Content-Type` header.
 *
 *   - Both flavors return a JSON response of the shape
 *     `{"access_token": "...", "token_type": "Bearer" | "bearer",
 *       "expires_in": 3600, ...}`. We extract `access_token` and
 *     `expires_in`, ignoring the rest, and stamp a UTC expiry
 *     instant computed from the injected {@see Clock}'s "now" plus
 *     the response's `expires_in` minus a 30-second safety margin
 *     (so we never hand out a token that is about to expire mid
 *     -flight).
 *
 * Cache strategy:
 *
 *   - Tokens are cached in-memory on this provider instance,
 *     keyed by a deterministic cache key derived from the
 *     credentials' `version` + `credentialId` (see
 *     {@see self::cacheKey()}). The Credential Secret is NOT
 *     included in the key because it can rotate within the same
 *     Application/Credential ID without invalidating the cache;
 *     the version + Credential ID pair is enough to scope cached
 *     tokens to one logical credential.
 *
 *   - An optional external cache (`?CacheInterface $externalCache`,
 *     duck-typed below) lets callers persist tokens across
 *     requests via WordPress's object cache or transients. The
 *     duck-type contract requires only `get(string): ?string` and
 *     `set(string, string, int): void` so this class stays
 *     decoupled from any specific WP cache adapter; the bootstrap
 *     wiring is responsible for injecting (or omitting) a real
 *     adapter.
 *
 *   - When the in-memory cache is empty but the external cache
 *     yields a value, the value is parsed back into the
 *     `[token, expiresAt]` tuple via {@see self::decodeCached()}
 *     and rehydrated into the in-memory slot so subsequent calls
 *     within the same request short-circuit.
 *
 *   - Expiry is computed against the injected {@see Clock}'s
 *     "now" so the entire flow is testable without sleeping. A
 *     token returned by Amazon with `expires_in: 3600` is cached
 *     for `(now + 3600 - SAFETY_MARGIN_SECONDS)`, and any
 *     {@see self::accessToken()} call after that instant treats
 *     the cache as a miss and refetches.
 *
 * Failure semantics:
 *
 *   - Network / transport errors raise
 *     {@see ProviderRequestException} with the credential's
 *     {@see AmazonCreatorsApiProvider::PROVIDER_KEY} stamp and
 *     `endpoint=token`. The caller (typically the request
 *     factory) is expected to surface this through the standard
 *     §10.2 exception hierarchy.
 *
 *   - HTTP 4xx responses on the token endpoint signal credential
 *     problems: 400 / 401 / 403 raise
 *     {@see AuthenticationException} so the upstream
 *     {@see CapabilityPolicy} / diagnostics surface can mark the
 *     provider as `invalid_credentials`. Other 4xx codes raise
 *     {@see ProviderRequestException}.
 *
 *   - HTTP 429 raises {@see RateLimitExceededException}; the
 *     `expires_in` field is irrelevant on a rate-limited
 *     response. Retry hints are not parsed from the response body
 *     in this slice (Amazon's documentation does not promise a
 *     stable `Retry-After` shape for the token endpoint); a
 *     follow-up slice can extend
 *     {@see RateLimitExceededException}'s context if needed.
 *
 *   - HTTP 5xx raises {@see TemporaryUnavailableException}.
 *
 *   - Malformed JSON / missing `access_token` field raises
 *     {@see ProviderResponseException} so the bug surfaces with
 *     the offending payload preserved in the exception's context.
 *
 * What this class does NOT do:
 *
 *   - It does not call the catalog API itself. It only fetches
 *     the access token; the {@see AmazonCreatorsApiRequestFactory}
 *     stamps the resulting Bearer header onto outgoing
 *     `searchItems` / `getItems` requests.
 *
 *   - It does not implement automatic retry on token-endpoint
 *     failure beyond the inherent re-fetch on cache miss / cache
 *     expiry. If Amazon's token endpoint is temporarily down, the
 *     calling provider sees a `TemporaryUnavailableException` and
 *     surfaces it through the standard sync / search error path.
 *
 *   - It does not log token values. Bearer tokens never appear in
 *     log lines, audit rows, or thrown-exception messages — only
 *     a redacted "[redacted bearer token]" placeholder when the
 *     surrounding code needs to acknowledge a token's existence.
 *
 * @package CommerceLinkBuilder\Infrastructure\Provider\Amazon\CreatorsApi
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Provider\Amazon\CreatorsApi;

use CommerceLinkBuilder\Domain\Clock;
use CommerceLinkBuilder\Domain\Http\HttpClient;
use CommerceLinkBuilder\Domain\Http\HttpClientException;
use CommerceLinkBuilder\Domain\Http\HttpRequest;
use CommerceLinkBuilder\Domain\Http\HttpResponse;
use CommerceLinkBuilder\Domain\Provider\Exception\AuthenticationException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderRequestException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderResponseException;
use CommerceLinkBuilder\Domain\Provider\Exception\RateLimitExceededException;
use CommerceLinkBuilder\Domain\Provider\Exception\TemporaryUnavailableException;
use DateInterval;
use DateTimeImmutable;
use DateTimeZone;
use JsonException;
use Throwable;

/**
 * Acquires and caches OAuth 2.0 access tokens for one or more
 * {@see AmazonCreatorsApiCredentials} pairs.
 *
 * See the file-level docblock for the v2.x / v3.x request-shape
 * branching, the in-memory + optional external cache strategy,
 * the clock-based expiry computation, and the failure mapping
 * onto the §10.2 provider exception hierarchy.
 */
final class AmazonCreatorsApiTokenProvider {

	/**
	 * §4 stable provider key stamped on every exception this
	 * class raises.
	 *
	 * Mirrors {@see AmazonCreatorsApiProvider::PROVIDER_KEY} but
	 * is duplicated here as a class constant so this provider
	 * can be loaded and exercised in tests without forcing the
	 * full {@see AmazonCreatorsApiProvider} class graph to be
	 * autoloaded first.
	 */
	public const PROVIDER_KEY = 'amazon';

	/**
	 * Endpoint label stamped into exception context so log /
	 * audit consumers can distinguish a token-fetch failure from
	 * a catalog-call failure.
	 */
	public const ENDPOINT_LABEL = 'token';

	/**
	 * OAuth 2.0 grant-type token used by both v2.x and v3.x
	 * flows.
	 */
	private const GRANT_TYPE = 'client_credentials';

	/**
	 * OAuth 2.0 scope token Amazon requires for the v2.x
	 * (Cognito) flow. Single colon.
	 */
	private const SCOPE_COGNITO = 'creatorsapi/default';

	/**
	 * OAuth 2.0 scope token Amazon requires for the v3.x (LwA)
	 * flow. Double colon.
	 */
	private const SCOPE_LWA = 'creatorsapi::default';

	/**
	 * Safety margin (seconds) subtracted from the response's
	 * `expires_in` when computing the cache entry's expiry
	 * instant, so we never hand out a token that is about to
	 * expire while it is in flight to Amazon's catalog API.
	 *
	 * 30 seconds is comfortably larger than any realistic
	 * round-trip latency to Amazon while still leaving 99% of
	 * the token's nominal 1-hour lifetime usable.
	 */
	private const SAFETY_MARGIN_SECONDS = 30;

	/**
	 * Per-call request timeout (seconds) for token-endpoint
	 * fetches.
	 *
	 * Token endpoints typically respond in < 1 second; 10
	 * seconds is generous enough to absorb network jitter
	 * without letting a hung token endpoint stall a
	 * search-results render indefinitely.
	 */
	private const TOKEN_REQUEST_TIMEOUT_SECONDS = 10;

	/**
	 * HTTP transport used to dispatch token-endpoint requests.
	 *
	 * The same {@see HttpClient} the
	 * {@see AmazonCreatorsApiProvider} uses for catalog calls is
	 * injected here so transport-level concerns (proxy, TLS,
	 * timeout policy) stay centralized.
	 */
	private HttpClient $httpClient;

	/**
	 * Clock used to stamp / compare token-expiry instants.
	 *
	 * All timestamps the cache reasons about are normalized to
	 * UTC via {@see DateTimeZone}('UTC'); see {@see self::utcNow()}.
	 */
	private Clock $clock;

	/**
	 * Optional cross-request token cache.
	 *
	 * Duck-typed against the minimal `get(string $key): ?string`
	 * + `set(string $key, string $value, int $ttlSeconds): void`
	 * surface so this provider stays decoupled from any specific
	 * WordPress object-cache adapter. When null, tokens are
	 * cached only in-memory on this instance and a fresh PHP
	 * request will refetch them.
	 *
	 * @var object|null
	 */
	private ?object $externalCache;

	/**
	 * In-memory cache of `[token, expiresAtUtc]` tuples, keyed by
	 * {@see self::cacheKey()} output.
	 *
	 * @var array<string, array{0: string, 1: DateTimeImmutable}>
	 */
	private array $memoryCache = array();

	/**
	 * Construct the token provider with explicit collaborator
	 * injection.
	 *
	 * @param HttpClient   $httpClient    HTTP transport used to
	 *                                    POST to token endpoints.
	 * @param Clock        $clock         Clock used to stamp /
	 *                                    compare token expiry
	 *                                    instants.
	 * @param object|null  $externalCache Optional duck-typed
	 *                                    cross-request token
	 *                                    cache (must expose
	 *                                    `get(string): ?string`
	 *                                    and
	 *                                    `set(string, string, int): void`).
	 */
	public function __construct(
		HttpClient $httpClient,
		Clock $clock,
		?object $externalCache = null
	) {
		$this->httpClient    = $httpClient;
		$this->clock         = $clock;
		$this->externalCache = $externalCache;
	}

	/**
	 * Return a usable Bearer access token for the supplied
	 * credentials.
	 *
	 * Cache hit (in-memory or external) returns immediately.
	 * Cache miss / cache expiry triggers a fresh fetch against
	 * the credentials' token endpoint, populates both caches
	 * with the new `[token, expiresAt]` tuple, and returns the
	 * token.
	 *
	 * @param AmazonCreatorsApiCredentials $credentials Long-lived
	 *                                                  credential
	 *                                                  pair the
	 *                                                  token is
	 *                                                  scoped to.
	 *
	 * @throws AuthenticationException        When the token endpoint
	 *                                        rejects the credentials
	 *                                        (HTTP 400 / 401 / 403).
	 * @throws RateLimitExceededException     When the token endpoint
	 *                                        returns HTTP 429.
	 * @throws TemporaryUnavailableException  When the token endpoint
	 *                                        returns HTTP 5xx.
	 * @throws ProviderRequestException       For other transport
	 *                                        errors and unexpected
	 *                                        4xx codes.
	 * @throws ProviderResponseException      When the token endpoint
	 *                                        returns malformed JSON
	 *                                        or omits `access_token`.
	 */
	public function accessToken( AmazonCreatorsApiCredentials $credentials ): string {
		$cacheKey = $this->cacheKey( $credentials );
		$now      = $this->utcNow();

		$cached = $this->lookupCache( $cacheKey, $now );
		if ( $cached !== null ) {
			return $cached;
		}

		[ $token, $expiresAt ] = $this->fetchToken( $credentials, $now );

		$this->storeCache( $cacheKey, $token, $expiresAt, $now );

		return $token;
	}

	/**
	 * Drop the cached token for the supplied credentials.
	 *
	 * Useful when the catalog API returns a 401 with a token
	 * that was technically still within its `expires_in` window
	 * (e.g. credential rotation server-side), and the calling
	 * code wants to force a re-fetch on the next
	 * {@see self::accessToken()} call without waiting for the
	 * natural expiry.
	 *
	 * Idempotent: invoking against credentials that have no
	 * cached token is a no-op.
	 *
	 * @param AmazonCreatorsApiCredentials $credentials Credential
	 *                                                  pair whose
	 *                                                  cached
	 *                                                  token should
	 *                                                  be dropped.
	 */
	public function invalidate( AmazonCreatorsApiCredentials $credentials ): void {
		$cacheKey = $this->cacheKey( $credentials );

		unset( $this->memoryCache[ $cacheKey ] );

		if ( $this->externalCache !== null ) {
			// Duck-typed `delete()` is optional on the external
			// cache; if it does not exist (e.g. transient-only
			// adapter), overwrite the slot with a stale value so
			// the next `lookupCache()` treats it as expired.
			if ( method_exists( $this->externalCache, 'delete' ) ) {
				$this->externalCache->delete( $cacheKey );
			} else {
				$this->externalCache->set( $cacheKey, '', 1 );
			}
		}
	}

	/**
	 * Return the current "now" in UTC.
	 *
	 * Centralizes the {@see DateTimeZone}('UTC') normalization
	 * so the rest of the provider can reason about expiry
	 * arithmetic without re-asserting the timezone every time.
	 */
	private function utcNow(): DateTimeImmutable {
		$now = $this->clock->now();

		return $now->setTimezone( new DateTimeZone( 'UTC' ) );
	}

	/**
	 * Look up a cached token for the supplied cache key,
	 * returning the token string when present and unexpired,
	 * else null.
	 *
	 * Checks the in-memory cache first. On miss, attempts the
	 * external cache (if injected). A successful external-cache
	 * hit rehydrates the in-memory slot so subsequent calls
	 * within the same request short-circuit.
	 *
	 * @param string            $cacheKey Cache key produced by
	 *                                    {@see self::cacheKey()}.
	 * @param DateTimeImmutable $now      Current UTC instant.
	 */
	private function lookupCache( string $cacheKey, DateTimeImmutable $now ): ?string {
		if ( isset( $this->memoryCache[ $cacheKey ] ) ) {
			[ $token, $expiresAt ] = $this->memoryCache[ $cacheKey ];
			if ( $expiresAt > $now ) {
				return $token;
			}
			unset( $this->memoryCache[ $cacheKey ] );
		}

		if ( $this->externalCache === null ) {
			return null;
		}

		$encoded = $this->externalCache->get( $cacheKey );
		if ( ! is_string( $encoded ) || $encoded === '' ) {
			return null;
		}

		$decoded = self::decodeCached( $encoded );
		if ( $decoded === null ) {
			return null;
		}

		[ $token, $expiresAt ] = $decoded;
		if ( $expiresAt <= $now ) {
			return null;
		}

		$this->memoryCache[ $cacheKey ] = array( $token, $expiresAt );

		return $token;
	}

	/**
	 * Persist the supplied token / expiry pair into both the
	 * in-memory and (optional) external caches.
	 *
	 * The TTL handed to the external cache is the remaining
	 * lifetime of the token in seconds (post-safety-margin), so
	 * the external cache will drop the entry close to (but not
	 * exactly at) the same instant the in-memory cache treats
	 * it as expired.
	 *
	 * @param string            $cacheKey  Cache key produced by
	 *                                     {@see self::cacheKey()}.
	 * @param string            $token     Token string returned
	 *                                     by the token endpoint.
	 * @param DateTimeImmutable $expiresAt UTC expiry instant for
	 *                                     the cached entry.
	 * @param DateTimeImmutable $now       Current UTC instant.
	 */
	private function storeCache(
		string $cacheKey,
		string $token,
		DateTimeImmutable $expiresAt,
		DateTimeImmutable $now
	): void {
		$this->memoryCache[ $cacheKey ] = array( $token, $expiresAt );

		if ( $this->externalCache === null ) {
			return;
		}

		$ttl = $expiresAt->getTimestamp() - $now->getTimestamp();
		if ( $ttl <= 0 ) {
			// Defensive: caching a zero / negative TTL on most
			// WordPress object caches is a no-op or treated as
			// "cache forever", neither of which is what we want
			// here. Skip the write so the next call refetches.
			return;
		}

		$this->externalCache->set( $cacheKey, self::encodeCached( $token, $expiresAt ), $ttl );
	}

	/**
	 * Build the cache key for a credential pair.
	 *
	 * Includes the credential version and Credential ID but
	 * deliberately omits the Credential Secret (rotating the
	 * secret without rotating the Credential ID is a supported
	 * Amazon flow; we treat the Credential ID as the stable
	 * identity for cache scoping purposes).
	 *
	 * The class name prefix and double-colon delimiters are
	 * conservative: they keep the key recognizable in
	 * `wp_cache` / transient option dumps and prevent collision
	 * with any unrelated cache entries that happen to share the
	 * Credential ID string.
	 */
	private function cacheKey( AmazonCreatorsApiCredentials $credentials ): string {
		return sprintf(
			'clb:amazon:creatorsapi:token:v%s:%s',
			$credentials->version(),
			$credentials->credentialId()
		);
	}

	/**
	 * Fetch a fresh access token from the credentials' token
	 * endpoint and return it together with the computed expiry
	 * instant.
	 *
	 * @param AmazonCreatorsApiCredentials $credentials Credential
	 *                                                  pair to
	 *                                                  authenticate.
	 * @param DateTimeImmutable            $now         Current UTC
	 *                                                  instant the
	 *                                                  expiry is
	 *                                                  computed
	 *                                                  against.
	 *
	 * @return array{0: string, 1: DateTimeImmutable}
	 */
	private function fetchToken(
		AmazonCreatorsApiCredentials $credentials,
		DateTimeImmutable $now
	): array {
		$request = $this->buildTokenRequest( $credentials );

		try {
			$response = $this->httpClient->send( $request );
		} catch ( HttpClientException $error ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				sprintf(
					'Amazon Creators API token endpoint transport failure: %s',
					$error->getMessage()
				),
				array(
					'endpoint' => self::ENDPOINT_LABEL,
					'version'  => $credentials->version(),
				),
				$error
			);
		}

		$this->assertSuccessful( $response, $credentials );

		$payload = $this->decodeJson( $response, $credentials );

		$token = $payload['access_token'] ?? null;
		if ( ! is_string( $token ) || trim( $token ) === '' ) {
			throw new ProviderResponseException(
				self::PROVIDER_KEY,
				'Amazon Creators API token response is missing the access_token field.',
				array(
					'endpoint'   => self::ENDPOINT_LABEL,
					'version'    => $credentials->version(),
					'http_status' => $response->statusCode(),
				)
			);
		}

		$expiresIn = $payload['expires_in'] ?? null;
		if ( ! is_int( $expiresIn ) ) {
			// LwA's documented response uses an integer; defend
			// against numeric-string or float values too.
			if ( is_float( $expiresIn ) ) {
				$expiresIn = (int) $expiresIn;
			} elseif ( is_string( $expiresIn ) && ctype_digit( $expiresIn ) ) {
				$expiresIn = (int) $expiresIn;
			} else {
				throw new ProviderResponseException(
					self::PROVIDER_KEY,
					'Amazon Creators API token response expires_in is missing or not an integer.',
					array(
						'endpoint'    => self::ENDPOINT_LABEL,
						'version'     => $credentials->version(),
						'http_status' => $response->statusCode(),
					)
				);
			}
		}

		if ( $expiresIn <= self::SAFETY_MARGIN_SECONDS ) {
			// Defensive: if Amazon ever ships a sub-30-second
			// token, refuse to cache it (we'd hand it out and
			// the catalog call would still time out). Treat as
			// a transient upstream issue.
			throw new TemporaryUnavailableException(
				self::PROVIDER_KEY,
				sprintf(
					'Amazon Creators API token endpoint returned an expires_in of %d seconds (<= safety margin %d).',
					$expiresIn,
					self::SAFETY_MARGIN_SECONDS
				),
				array(
					'endpoint'    => self::ENDPOINT_LABEL,
					'version'     => $credentials->version(),
					'expires_in'  => $expiresIn,
				)
			);
		}

		$lifetime  = $expiresIn - self::SAFETY_MARGIN_SECONDS;
		$expiresAt = $now->add( new DateInterval( 'PT' . $lifetime . 'S' ) );

		return array( trim( $token ), $expiresAt );
	}

	/**
	 * Construct the {@see HttpRequest} for the token-endpoint
	 * call corresponding to the credentials' scheme.
	 */
	private function buildTokenRequest( AmazonCreatorsApiCredentials $credentials ): HttpRequest {
		if ( $credentials->tokenScheme() === AmazonCreatorsApiCredentials::SCHEME_COGNITO ) {
			return $this->buildCognitoTokenRequest( $credentials );
		}

		return $this->buildLwaTokenRequest( $credentials );
	}

	/**
	 * Build the v2.x (Cognito) token request.
	 *
	 * `application/x-www-form-urlencoded` body with explicit
	 * `client_id` / `client_secret` fields per the Creators API
	 * "Using cURL" Method 1 reference. (Method 2 — Basic-Auth in
	 * the Authorization header — is functionally identical; we
	 * pick Method 1 because it keeps secrets out of the header
	 * shape, simplifying log redaction.)
	 */
	private function buildCognitoTokenRequest( AmazonCreatorsApiCredentials $credentials ): HttpRequest {
		$body = http_build_query(
			array(
				'grant_type'    => self::GRANT_TYPE,
				'client_id'     => $credentials->credentialId(),
				'client_secret' => $credentials->credentialSecret(),
				'scope'         => self::SCOPE_COGNITO,
			),
			'',
			'&'
		);

		return new HttpRequest(
			HttpRequest::METHOD_POST,
			$credentials->tokenEndpoint(),
			array(
				'Content-Type' => 'application/x-www-form-urlencoded',
				'Accept'       => 'application/json',
			),
			array(),
			$body,
			self::TOKEN_REQUEST_TIMEOUT_SECONDS
		);
	}

	/**
	 * Build the v3.x (LwA) token request.
	 *
	 * `application/json` body with the same field set as the
	 * Cognito form, except the scope token has the LwA-specific
	 * double-colon shape.
	 */
	private function buildLwaTokenRequest( AmazonCreatorsApiCredentials $credentials ): HttpRequest {
		$body = wp_json_encode(
			array(
				'grant_type'    => self::GRANT_TYPE,
				'client_id'     => $credentials->credentialId(),
				'client_secret' => $credentials->credentialSecret(),
				'scope'         => self::SCOPE_LWA,
			),
			JSON_UNESCAPED_SLASHES
		);
		if ( ! is_string( $body ) ) {
			// Should be impossible — the inputs are all simple
			// strings — but encode-failure is wrapped just in
			// case a future field carries non-UTF8 bytes.
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				'Amazon Creators API LwA token request body could not be JSON-encoded.',
				array(
					'endpoint' => self::ENDPOINT_LABEL,
					'version'  => $credentials->version(),
				)
			);
		}

		return new HttpRequest(
			HttpRequest::METHOD_POST,
			$credentials->tokenEndpoint(),
			array(
				'Content-Type' => 'application/json',
				'Accept'       => 'application/json',
			),
			array(),
			$body,
			self::TOKEN_REQUEST_TIMEOUT_SECONDS
		);
	}

	/**
	 * Branch on the response status code and raise the
	 * appropriate §10.2 exception for non-2xx responses.
	 *
	 * On 2xx, returns silently so the caller can proceed to
	 * decode the JSON body.
	 */
	private function assertSuccessful(
		HttpResponse $response,
		AmazonCreatorsApiCredentials $credentials
	): void {
		$status = $response->statusCode();
		if ( $status >= 200 && $status < 300 ) {
			return;
		}

		$context = array(
			'endpoint'    => self::ENDPOINT_LABEL,
			'version'     => $credentials->version(),
			'http_status' => $status,
		);

		if ( $status === 400 || $status === 401 || $status === 403 ) {
			throw new AuthenticationException(
				self::PROVIDER_KEY,
				sprintf(
					'Amazon Creators API token endpoint rejected the credentials (HTTP %d).',
					$status
				),
				$context
			);
		}

		if ( $status === 429 ) {
			throw new RateLimitExceededException(
				self::PROVIDER_KEY,
				'Amazon Creators API token endpoint reported rate limit exceeded (HTTP 429).',
				$context
			);
		}

		if ( $status >= 500 ) {
			throw new TemporaryUnavailableException(
				self::PROVIDER_KEY,
				sprintf(
					'Amazon Creators API token endpoint returned HTTP %d.',
					$status
				),
				$context
			);
		}

		throw new ProviderRequestException(
			self::PROVIDER_KEY,
			sprintf(
				'Amazon Creators API token endpoint returned unexpected HTTP %d.',
				$status
			),
			$context
		);
	}

	/**
	 * Decode the token endpoint's JSON response body into an
	 * associative array, raising
	 * {@see ProviderResponseException} on invalid JSON or
	 * non-object root.
	 *
	 * @return array<string, mixed>
	 */
	private function decodeJson(
		HttpResponse $response,
		AmazonCreatorsApiCredentials $credentials
	): array {
		$body = $response->body();
		if ( ! is_string( $body ) || $body === '' ) {
			throw new ProviderResponseException(
				self::PROVIDER_KEY,
				'Amazon Creators API token response body is empty.',
				array(
					'endpoint'    => self::ENDPOINT_LABEL,
					'version'     => $credentials->version(),
					'http_status' => $response->statusCode(),
				)
			);
		}

		try {
			$decoded = json_decode( $body, true, 512, JSON_THROW_ON_ERROR );
		} catch ( JsonException $error ) {
			throw new ProviderResponseException(
				self::PROVIDER_KEY,
				sprintf(
					'Amazon Creators API token response is not valid JSON: %s',
					$error->getMessage()
				),
				array(
					'endpoint'    => self::ENDPOINT_LABEL,
					'version'     => $credentials->version(),
					'http_status' => $response->statusCode(),
				),
				$error
			);
		}

		if ( ! is_array( $decoded ) || array_is_list( $decoded ) ) {
			throw new ProviderResponseException(
				self::PROVIDER_KEY,
				'Amazon Creators API token response root is not a JSON object.',
				array(
					'endpoint'    => self::ENDPOINT_LABEL,
					'version'     => $credentials->version(),
					'http_status' => $response->statusCode(),
				)
			);
		}

		/** @var array<string, mixed> $decoded */
		return $decoded;
	}

	/**
	 * Encode a `[token, expiresAt]` tuple for storage in the
	 * external cache.
	 *
	 * The format is intentionally trivial — a JSON object with
	 * two string fields — so that any external cache that
	 * preserves strings round-trips it unchanged. The encoding
	 * does not include the credential id / version because the
	 * cache key already scopes the entry.
	 */
	private static function encodeCached( string $token, DateTimeImmutable $expiresAt ): string {
		$encoded = wp_json_encode(
			array(
				'token'      => $token,
				'expires_at' => $expiresAt->format( DateTimeImmutable::ATOM ),
			),
			JSON_UNESCAPED_SLASHES
		);
		if ( ! is_string( $encoded ) ) {
			// Should be impossible: $token is the trimmed upstream
			// string and $expiresAt formats to an ASCII ISO-8601
			// string. Fall back to an empty string so the external
			// cache write becomes a no-op rather than crashing the
			// surrounding accessToken() call.
			return '';
		}

		return $encoded;
	}

	/**
	 * Decode a previously-encoded cache entry back into a
	 * `[token, expiresAt]` tuple. Returns null on any decoding
	 * failure (treated as a cache miss by the caller).
	 *
	 * @return array{0: string, 1: DateTimeImmutable}|null
	 */
	private static function decodeCached( string $encoded ): ?array {
		try {
			$decoded = json_decode( $encoded, true, 8, JSON_THROW_ON_ERROR );
		} catch ( JsonException $error ) {
			unset( $error );
			return null;
		}

		if ( ! is_array( $decoded ) || array_is_list( $decoded ) ) {
			return null;
		}

		$token        = $decoded['token'] ?? null;
		$expiresAtRaw = $decoded['expires_at'] ?? null;

		if ( ! is_string( $token ) || $token === '' ) {
			return null;
		}
		if ( ! is_string( $expiresAtRaw ) || $expiresAtRaw === '' ) {
			return null;
		}

		try {
			$expiresAt = new DateTimeImmutable( $expiresAtRaw );
		} catch ( Throwable $error ) {
			unset( $error );
			return null;
		}

		return array( $token, $expiresAt->setTimezone( new DateTimeZone( 'UTC' ) ) );
	}
}
