<?php
/**
 * Amazon Creators API provider.
 *
 * Phase B (KI-019, ADR-0022) {@see ProviderInterface} +
 * {@see SearchProviderInterface} + {@see ItemLookupProviderInterface}
 * implementation that integrates the cross-provider domain
 * surface against Amazon's Creators API
 * (https://affiliate-program.amazon.com/creatorsapi/docs/).
 *
 * Replaces the legacy
 * {@see \CommerceLinkBuilder\Infrastructure\Provider\Amazon\AmazonProvider}
 * (PA-API 5.0 + AWS SigV4), which is being removed in the
 * Phase B closeout because Amazon is deprecating PA-API 5.0 on
 * 2026-05-15. This provider is the canonical Amazon integration
 * going forward.
 *
 * Collaborator graph (all injected at construction):
 *
 *   - {@see AmazonCreatorsApiCredentials} — long-lived OAuth 2.0
 *     credentials VO (Credential ID / Credential Secret /
 *     Version / Partner Tag / Marketplace).
 *
 *   - {@see HttpClient} — domain HTTP transport. Same client
 *     the token provider uses, so transport-level concerns
 *     (proxy, TLS, timeout policy) stay centralized.
 *
 *   - {@see AmazonCreatorsApiTokenProvider} — owns the OAuth 2.0
 *     `client_credentials` grant flow and the per-credential
 *     1-hour token cache. The provider asks the token provider
 *     for an access token implicitly through the request
 *     factory.
 *
 *   - {@see AmazonCreatorsApiRequestFactory} — translates
 *     domain {@see SearchQuery} / {@see ProviderProductId}
 *     values into Creators API HTTP requests with the
 *     Bearer-token + x-marketplace headers and the
 *     lowerCamelCase JSON body shape.
 *
 *   - {@see AmazonCreatorsApiResponseNormalizer} — translates
 *     2xx Creators API JSON responses into
 *     {@see SearchResult} / {@see ProductSnapshot} domain
 *     values.
 *
 *   - {@see AmazonCreatorsApiErrorMapper} — translates non-2xx
 *     Creators API responses into the §10.2 provider exception
 *     hierarchy.
 *
 *   - {@see AmazonCreatorsApiAffiliateLinkBuilder} — re-stamps
 *     the partner tag onto the snapshot's source URL and
 *     surfaces it as an {@see AffiliateLinkResult}.
 *
 *   - {@see Clock} — stamps `fetchedAt` / `observedAt` /
 *     `checkedAt` on every snapshot the response normalizer and
 *     health-check probe produce.
 *
 * Lifecycle:
 *
 *   - searchProducts() / getProduct():
 *
 *       1. Build the request via the request factory (which
 *          also resolves the access token via the token
 *          provider's cache).
 *
 *       2. Dispatch through the HTTP client.
 *
 *       3. On 2xx, hand the body to the response normalizer
 *          and return the resulting {@see SearchResult} /
 *          {@see ProductSnapshot}.
 *
 *       4. On non-2xx, hand the response to the error mapper
 *          and re-throw the produced §10.2 exception.
 *
 *       5. On transport failure, wrap the
 *          {@see HttpClientException} as
 *          {@see TemporaryUnavailableException} so the calling
 *          pipeline branches deterministically.
 *
 *       6. On a 401 from a successful token-fetch round-trip
 *          (i.e. Amazon rejected a token that was technically
 *          still within `expires_in`), invalidate the cached
 *          token via {@see AmazonCreatorsApiTokenProvider::invalidate()}
 *          and retry once. A second 401 propagates as
 *          {@see AuthenticationException} unchanged.
 *
 *   - healthCheck(): issues a minimal `searchItems` request
 *     (`keywords=amazon&itemCount=1`) and translates the
 *     outcome into a {@see ProviderHealthStatus} snapshot.
 *     Per the §6.1 contract this method MUST NOT throw on
 *     application-level failures — every observable outcome
 *     is encoded as a structured snapshot.
 *
 *   - buildAffiliateLink(): pure delegation to the affiliate
 *     link builder; see that class's docblock for the
 *     re-stamping algorithm.
 *
 * Capabilities (per {@see self::capabilities()}):
 *
 *   The MVP capabilities snapshot enables search / lookup /
 *   price / availability / sorts / affiliate-link build /
 *   health check, and disables multiple-images / rating /
 *   categories / rate-limit-inspection (the resource list
 *   {@see AmazonCreatorsApiRequestFactory::DEFAULT_SEARCH_RESOURCES}
 *   only requests `images.primary.medium`, no customer
 *   reviews, no browse nodes; future slices can extend the
 *   resource list and flip these flags).
 *
 * Failure semantics:
 *
 *   - Provider-mismatch ids in {@see self::getProduct()} raise
 *     {@see ProviderRequestException} before any network I/O,
 *     mirroring the legacy provider's behavior.
 *
 *   - Token-fetch exceptions
 *     ({@see AuthenticationException} on 401/403,
 *     {@see RateLimitExceededException} on 429,
 *     {@see TemporaryUnavailableException} on 5xx,
 *     {@see ProviderResponseException} on malformed JSON)
 *     bubble out of the request factory unchanged.
 *
 *   - Catalog-call exceptions are produced by the error
 *     mapper.
 *
 *   - Response normalization exceptions
 *     ({@see ProviderResponseException} on malformed JSON,
 *     {@see ProductNotFoundException} on empty `getItems`)
 *     bubble out of the normalizer unchanged.
 *
 * @package CommerceLinkBuilder\Infrastructure\Provider\Amazon\CreatorsApi
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Provider\Amazon\CreatorsApi;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped,Squiz.Commenting.FunctionComment.MissingParamTag,Squiz.Commenting.FunctionCommentThrowTag.Missing -- This provider constructs exception messages/context arrays and renders no HTML.

use CommerceLinkBuilder\Domain\Affiliate\AffiliateContext;
use CommerceLinkBuilder\Domain\Affiliate\AffiliateLinkResult;
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\Pricing\AvailabilityInfo;
use CommerceLinkBuilder\Domain\Product\ProductSnapshot;
use CommerceLinkBuilder\Domain\Provider\Exception\AuthenticationException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderRequestException;
use CommerceLinkBuilder\Domain\Provider\Exception\RateLimitExceededException;
use CommerceLinkBuilder\Domain\Provider\Exception\TemporaryUnavailableException;
use CommerceLinkBuilder\Domain\Provider\ItemLookupProviderInterface;
use CommerceLinkBuilder\Domain\Provider\ProviderCapabilities;
use CommerceLinkBuilder\Domain\Provider\ProviderHealthStatus;
use CommerceLinkBuilder\Domain\Provider\ProviderInterface;
use CommerceLinkBuilder\Domain\Provider\ProviderProductId;
use CommerceLinkBuilder\Domain\Provider\SearchProviderInterface;
use CommerceLinkBuilder\Domain\Search\SearchQuery;
use CommerceLinkBuilder\Domain\Search\SearchResult;
use DateTimeImmutable;
use Throwable;

/**
 * Amazon Creators API provider implementing the cross-provider
 * search / lookup / affiliate-link / health-check surface.
 *
 * See the file-level docblock for the collaborator graph,
 * the per-method lifecycle (with the single-shot 401 retry
 * after token invalidation), the MVP capabilities flags, and
 * the §10.2 exception propagation policy.
 */
final class AmazonCreatorsApiProvider implements
	ProviderInterface,
	SearchProviderInterface,
	ItemLookupProviderInterface {

	/**
	 * §4 stable provider key for Amazon per §4.1.
	 *
	 * Held as a class constant so every collaborator agrees on
	 * the key the provider tags responses, snapshots, and
	 * exceptions with. Mirrors the legacy AmazonProvider's
	 * key so the cross-provider {@see ProviderRegistry} and
	 * persistence layer keep working unchanged.
	 */
	public const PROVIDER_KEY = 'amazon';

	/**
	 * Human-readable display name for the admin UI per §6.1.
	 */
	public const LABEL = 'Amazon';

	/**
	 * Upstream catalog error codes that indicate Creators API
	 * catalog access is unavailable even though a link-only Amazon
	 * Associates URL can still be rendered from an ASIN.
	 *
	 * @var list<string>
	 */
	private const LINK_ONLY_FALLBACK_UPSTREAM_CODES = array(
		'AccessDenied',
		'AccessDeniedAwsUsers',
	);

	/**
	 * Long-lived credentials this provider authenticates with.
	 */
	private AmazonCreatorsApiCredentials $credentials;

	/**
	 * HTTP transport used to dispatch every Creators API
	 * catalog request.
	 */
	private HttpClient $httpClient;

	/**
	 * OAuth 2.0 token provider used to invalidate cached tokens
	 * after a 401 response. The provider does not call
	 * {@see AmazonCreatorsApiTokenProvider::accessToken()}
	 * directly — that happens inside the request factory.
	 */
	private AmazonCreatorsApiTokenProvider $tokenProvider;

	/**
	 * Request factory translating domain {@see SearchQuery} /
	 * {@see ProviderProductId} values into Creators API HTTP
	 * requests.
	 */
	private AmazonCreatorsApiRequestFactory $requestFactory;

	/**
	 * Response normalizer translating Creators API JSON
	 * responses into {@see SearchResult} / {@see ProductSnapshot}
	 * domain values.
	 */
	private AmazonCreatorsApiResponseNormalizer $responseNormalizer;

	/**
	 * Error mapper translating non-2xx Creators API responses
	 * into the §10.2 provider exception hierarchy.
	 */
	private AmazonCreatorsApiErrorMapper $errorMapper;

	/**
	 * Affiliate-link builder that mints partner-tag-stamped
	 * URLs from snapshots.
	 */
	private AmazonCreatorsApiAffiliateLinkBuilder $affiliateLinkBuilder;

	/**
	 * Clock used to stamp `fetchedAt` / `observedAt` /
	 * `checkedAt` on every snapshot the response normalizer
	 * and health-check probe produce.
	 */
	private Clock $clock;

	/**
	 * Construct the provider with its full collaborator graph.
	 *
	 * @param AmazonCreatorsApiCredentials          $credentials          Long-lived
	 *                                                                    Creators
	 *                                                                    API
	 *                                                                    credentials.
	 * @param HttpClient                            $httpClient           HTTP transport.
	 * @param AmazonCreatorsApiTokenProvider        $tokenProvider        OAuth 2.0
	 *                                                                    token
	 *                                                                    provider.
	 * @param AmazonCreatorsApiRequestFactory       $requestFactory       Request
	 *                                                                    builder.
	 * @param AmazonCreatorsApiResponseNormalizer   $responseNormalizer   Response
	 *                                                                    decoder.
	 * @param AmazonCreatorsApiErrorMapper          $errorMapper          Error
	 *                                                                    translator.
	 * @param AmazonCreatorsApiAffiliateLinkBuilder $affiliateLinkBuilder Affiliate
	 *                                                                    URL
	 *                                                                    minter.
	 * @param Clock                                 $clock                "Now"
	 *                                                                    source.
	 */
	public function __construct(
		AmazonCreatorsApiCredentials $credentials,
		HttpClient $httpClient,
		AmazonCreatorsApiTokenProvider $tokenProvider,
		AmazonCreatorsApiRequestFactory $requestFactory,
		AmazonCreatorsApiResponseNormalizer $responseNormalizer,
		AmazonCreatorsApiErrorMapper $errorMapper,
		AmazonCreatorsApiAffiliateLinkBuilder $affiliateLinkBuilder,
		Clock $clock
	) {
		$this->credentials          = $credentials;
		$this->httpClient           = $httpClient;
		$this->tokenProvider        = $tokenProvider;
		$this->requestFactory       = $requestFactory;
		$this->responseNormalizer   = $responseNormalizer;
		$this->errorMapper          = $errorMapper;
		$this->affiliateLinkBuilder = $affiliateLinkBuilder;
		$this->clock                = $clock;
	}

	/**
	 * Stable §4 provider key.
	 */
	public function key(): string {
		return self::PROVIDER_KEY;
	}

	/**
	 * Human-readable display name per §6.1.
	 */
	public function label(): string {
		return self::LABEL;
	}

	/**
	 * Capabilities snapshot per §7.9 / §9.1.
	 *
	 * The MVP enables search / lookup / price / availability /
	 * sorts / affiliate-link build / health check. Multiple
	 * images, ratings, categories, and rate-limit inspection
	 * remain disabled in this slice — they correspond to
	 * resources the request factory does not currently request
	 * ({@see AmazonCreatorsApiRequestFactory::DEFAULT_SEARCH_RESOURCES}
	 * only includes `images.primary.medium`, no
	 * `customerReviews`, no `browseNodeInfo`).
	 */
	public function capabilities(): ProviderCapabilities {
		return new ProviderCapabilities(
			true,  // supportsSearch.
			true,  // supportsLookup.
			false, // supportsMultipleImages — Phase B MVP only fetches Primary.Medium.
			true,  // supportsPrice.
			true,  // supportsAvailability.
			false, // supportsRating — customerReviews not requested in MVP.
			false, // supportsCategories — browseNodeInfo not requested in MVP.
			true,  // supportsSorts.
			true,  // supportsAffiliateLinkBuild.
			true,  // supportsHealthCheck.
			false  // supportsRateLimitInspection.
		);
	}

	/**
	 * Report whether the named §8 feature key is supported.
	 *
	 * Pure delegation to {@see ProviderCapabilities::supportsFeature()}
	 * so the §8 closed-set validation lives in one place.
	 *
	 * @param string $feature §8 feature key.
	 */
	public function supportsFeature( string $feature ): bool {
		return $this->capabilities()->supportsFeature( $feature );
	}

	/**
	 * Probe the upstream and return a {@see ProviderHealthStatus}
	 * snapshot per §7.10.
	 *
	 * Issues a minimal `searchItems` request
	 * (`keywords=amazon&itemCount=1`) and branches on the
	 * outcome:
	 *
	 *   - 2xx                                      → STATUS_HEALTHY
	 *   - {@see AuthenticationException}           → STATUS_INVALID_CREDENTIALS
	 *   - {@see RateLimitExceededException}        → STATUS_RATE_LIMITED
	 *   - {@see TemporaryUnavailableException}     → STATUS_DEGRADED
	 *   - any other §10.2 exception                → STATUS_DEGRADED
	 *   - transport failure ({@see HttpClientException})
	 *                                              → STATUS_UNREACHABLE
	 *
	 * Per the §6.1 contract this method MUST NOT throw on
	 * application-level failures; every outcome is encoded as a
	 * structured snapshot.
	 */
	public function healthCheck(): ProviderHealthStatus {
		$now = $this->clock->now();

		try {
			$query = new SearchQuery(
				'amazon',
				1,
				1,
				null,
				null,
				null,
				null,
				array(),
				null,
				null,
				'health_check'
			);

			$request  = $this->requestFactory->buildSearchRequest( $query );
			$response = $this->httpClient->send( $request );

			if ( $this->isSuccessful( $response ) ) {
				return ProviderHealthStatus::healthy( $now );
			}

			$exception = $this->errorMapper->mapResponse(
				$response,
				AmazonCreatorsApiErrorMapper::ENDPOINT_SEARCH,
				null
			);

			return $this->translateExceptionToHealth( $exception, true, $now );
		} catch ( HttpClientException $error ) {
			return new ProviderHealthStatus(
				ProviderHealthStatus::STATUS_UNREACHABLE,
				false,
				false,
				false,
				$error->getMessage() === '' ? null : $error->getMessage(),
				$now,
				array( 'reason' => 'transport_failure' )
			);
		} catch ( ProviderException $error ) {
			// Token-fetch / request-factory / response-normalizer
			// failures all derive from ProviderException; they
			// must NOT escape the health probe per §6.1, so they
			// are translated into a degraded / invalid-credentials
			// / rate-limited / unconfigured snapshot here.
			return $this->translateExceptionToHealth( $error, true, $now );
		} catch ( Throwable $error ) {
			// Defensive fallback: any other unexpected throwable
			// surfaces as STATUS_DEGRADED rather than crashing
			// the diagnostics page.
			return new ProviderHealthStatus(
				ProviderHealthStatus::STATUS_DEGRADED,
				true,
				true,
				false,
				$error->getMessage() === '' ? null : $error->getMessage(),
				$now,
				array( 'reason' => 'unexpected_throwable' )
			);
		}
	}

	/**
	 * Mint an affiliate-tracked URL per §6.1.
	 *
	 * Pure delegation to {@see AmazonCreatorsApiAffiliateLinkBuilder}.
	 *
	 * @param ProductSnapshot  $snapshot Snapshot whose URL should
	 *                                   be tracked.
	 * @param AffiliateContext $context  Per-request affiliate
	 *                                   context (currently unused
	 *                                   by the Amazon builder; see
	 *                                   the builder's docblock).
	 */
	public function buildAffiliateLink(
		ProductSnapshot $snapshot,
		AffiliateContext $context
	): AffiliateLinkResult {
		return $this->affiliateLinkBuilder->build( $snapshot, $context );
	}

	/**
	 * Execute a keyword search per §6.2.
	 *
	 * Lifecycle:
	 *
	 *   1. Build the request via the request factory (which also
	 *      resolves an access token via the token provider's
	 *      cache).
	 *
	 *   2. Dispatch through the HTTP client. Transport failures
	 *      surface as {@see TemporaryUnavailableException}.
	 *
	 *   3. On non-2xx, hand the response to the error mapper
	 *      and re-throw the produced §10.2 exception. A 401 in
	 *      particular triggers a single-shot retry: invalidate
	 *      the cached token, rebuild the request (which fetches
	 *      a fresh token), redispatch, and re-evaluate.
	 *
	 *   4. On 2xx, hand the body to the response normalizer.
	 *
	 * @param SearchQuery $query Normalized cross-provider query.
	 *
	 * @throws ProviderException When any §10.2 failure mode
	 *                           applies.
	 */
	public function searchProducts( SearchQuery $query ): SearchResult {
		$now = $this->clock->now();

		try {
			$response = $this->dispatchWithTokenRetry(
				fn(): HttpRequest => $this->requestFactory->buildSearchRequest( $query ),
				AmazonCreatorsApiErrorMapper::ENDPOINT_SEARCH,
				null
			);
		} catch ( ProviderException $error ) {
			$asin = self::extractAsinFromKeyword( $query->keyword() );
			if ( $asin !== null
				&& $this->shouldUseLinkOnlyFallback( $error, AmazonCreatorsApiErrorMapper::ENDPOINT_SEARCH )
			) {
				return $this->buildLinkOnlyFallbackSearchResult( $query, $asin, $now, $error );
			}

			throw $error;
		}

		return $this->responseNormalizer->normalizeSearchResult(
			(string) $response->body(),
			$query,
			$now
		);
	}

	/**
	 * Look up a single product by {@see ProviderProductId} per
	 * §6.3.
	 *
	 * Provider-mismatch ids are rejected before any network I/O
	 * occurs, surfacing as {@see ProviderRequestException}.
	 *
	 * @param ProviderProductId $id Identifier whose detail
	 *                              snapshot should be fetched.
	 *
	 * @throws ProviderRequestException When `$id` belongs to a different
	 *                                  provider per §6.3.
	 * @throws ProviderException        When any §10.2 failure mode applies.
	 */
	public function getProduct( ProviderProductId $id ): ProductSnapshot {
		if ( ! $id->belongsTo( self::PROVIDER_KEY ) ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				sprintf(
					'ProviderProductId belongs to provider "%s", not "%s".',
					$id->provider(),
					self::PROVIDER_KEY
				),
				array(
					'feature'           => 'lookup',
					'expected_provider' => self::PROVIDER_KEY,
					'actual_provider'   => $id->provider(),
				)
			);
		}

		$now = $this->clock->now();

		try {
			$response = $this->dispatchWithTokenRetry(
				fn(): HttpRequest => $this->requestFactory->buildLookupRequest( $id ),
				AmazonCreatorsApiErrorMapper::ENDPOINT_LOOKUP,
				$id
			);
		} catch ( ProviderException $error ) {
			if ( self::normalizeAsin( $id->value() ) !== null
				&& $this->shouldUseLinkOnlyFallback( $error, AmazonCreatorsApiErrorMapper::ENDPOINT_LOOKUP )
			) {
				return $this->buildLinkOnlyFallbackSnapshot( $id, $now, $error );
			}

			throw $error;
		}

		return $this->responseNormalizer->normalizeProduct(
			(string) $response->body(),
			$id,
			$now
		);
	}

	/**
	 * Build a synthetic single-item SearchResult for ASIN-based
	 * link-only fallback rendering.
	 *
	 * @param SearchQuery       $query Original search query.
	 * @param string            $asin  Normalized Amazon ASIN.
	 * @param DateTimeImmutable $now   Observation timestamp.
	 * @param ProviderException $cause Catalog failure that triggered the fallback.
	 */
	private function buildLinkOnlyFallbackSearchResult(
		SearchQuery $query,
		string $asin,
		DateTimeImmutable $now,
		ProviderException $cause
	): SearchResult {
		$items = array();
		if ( $query->page() === 1 ) {
			$items[] = $this->buildLinkOnlyFallbackSnapshot(
				new ProviderProductId( self::PROVIDER_KEY, $asin ),
				$now,
				$cause
			);
		}

		return new SearchResult(
			self::PROVIDER_KEY,
			$items,
			$query->page(),
			$query->perPage(),
			1,
			false,
			array(
				'amazon_link_only_fallback' => true,
				'fallback_reason'           => 'creators_api_catalog_access_denied',
			)
		);
	}

	/**
	 * Build a product snapshot that contains only operator-known
	 * ASIN identity plus an Amazon Associates detail-page link.
	 *
	 * The snapshot intentionally omits Amazon-controlled product
	 * content (image, price, availability, rating/reviews). The
	 * render layer will downgrade it to the fallback card because
	 * the availability status is `unavailable`, leaving only a
	 * title and a compliant CTA to the Amazon detail page.
	 *
	 * @param ProviderProductId $id    Amazon ASIN id.
	 * @param DateTimeImmutable $now   Observation timestamp.
	 * @param ProviderException $cause Catalog failure that triggered the fallback.
	 */
	private function buildLinkOnlyFallbackSnapshot(
		ProviderProductId $id,
		DateTimeImmutable $now,
		ProviderException $cause
	): ProductSnapshot {
		$normalizedId = new ProviderProductId( self::PROVIDER_KEY, strtoupper( $id->value() ) );
		$affiliate    = $this->affiliateLinkBuilder->buildForProductId( $normalizedId );
		$context      = $cause->context();

		return new ProductSnapshot(
			self::PROVIDER_KEY,
			$normalizedId,
			sprintf( 'Amazonの商品ページ（ASIN: %s）', $normalizedId->value() ),
			null,
			'価格・在庫・商品情報はリンク先のAmazonでご確認ください。',
			$this->affiliateLinkBuilder->detailUrlForProductId( $normalizedId ),
			$affiliate->url(),
			null,
			array(),
			null,
			new AvailabilityInfo( AvailabilityInfo::STATUS_UNAVAILABLE, null, false ),
			null,
			null,
			array(),
			array(),
			$now,
			array(
				'amazon_link_only_fallback' => true,
				'fallback_reason'           => 'creators_api_catalog_access_denied',
				'source_error_code'         => $cause->errorCode(),
				'source_http_status'        => $context['http_status'] ?? null,
				'source_upstream_code'      => $context['upstream_code'] ?? null,
			)
		);
	}

	/**
	 * Decide whether a catalog failure should degrade to the
	 * link-only ASIN fallback instead of bubbling to callers.
	 *
	 * Token-endpoint authentication failures never qualify; only
	 * catalog SearchItems / GetItems denials can enter this path.
	 *
	 * @param ProviderException $error    Provider failure.
	 * @param string            $endpoint Expected catalog endpoint.
	 */
	private function shouldUseLinkOnlyFallback( ProviderException $error, string $endpoint ): bool {
		if ( ! $error instanceof AuthenticationException ) {
			return false;
		}

		$context = $error->context();
		if ( ( $context['endpoint'] ?? null ) !== $endpoint ) {
			return false;
		}

		$status = $context['http_status'] ?? null;
		if ( $status !== 401 && $status !== 403 ) {
			return false;
		}

		$code = $context['upstream_code'] ?? null;
		if ( is_string( $code ) ) {
			return in_array( $code, self::LINK_ONLY_FALLBACK_UPSTREAM_CODES, true );
		}

		$message = $context['upstream_message'] ?? null;
		if (
			is_string( $message )
			&& preg_match( '/eligib|approv|authori[sz]ed|access denied|権限|承認/i', $message ) === 1
		) {
			return true;
		}

		return $status === 403;
	}

	/**
	 * Extract an ASIN from a search keyword, accepting either a raw
	 * ASIN or a pasted Amazon detail-page URL.
	 *
	 * @param string $keyword Search keyword / URL.
	 */
	private static function extractAsinFromKeyword( string $keyword ): ?string {
		$direct = self::normalizeAsin( $keyword );
		if ( $direct !== null ) {
			return $direct;
		}

		$normalized = strtoupper( rawurldecode( trim( $keyword ) ) );
		foreach (
			array(
				'~/(?:DP|GP/PRODUCT|EXEC/OBIDOS/ASIN)/([A-Z0-9]{10})(?:[/?#]|$)~',
				'~[?&]ASIN=([A-Z0-9]{10})(?:[&#]|$)~',
			)
			as $pattern
		) {
			if ( preg_match( $pattern, $normalized, $matches ) === 1 ) {
				return $matches[1];
			}
		}

		return null;
	}

	/**
	 * Normalize a raw ASIN candidate to uppercase, returning null
	 * when the shape is not a 10-character alphanumeric ASIN.
	 *
	 * @param string $value Raw candidate.
	 */
	private static function normalizeAsin( string $value ): ?string {
		$trimmed = strtoupper( trim( $value ) );
		if ( preg_match( '/^[A-Z0-9]{10}$/', $trimmed ) !== 1 ) {
			return null;
		}

		return $trimmed;
	}

	/**
	 * Dispatch a request through the HTTP client, with a
	 * single-shot retry after a 401 that invalidates the
	 * cached token.
	 *
	 * The single-shot retry handles the rare but real case
	 * where Amazon rejects a token that was technically still
	 * within `expires_in` (e.g. credential rotation server
	 * -side, or the cache holds a token issued just before a
	 * marketplace policy change). On a second 401, the
	 * exception propagates unchanged.
	 *
	 * Transport failures ({@see HttpClientException}) are
	 * wrapped as {@see TemporaryUnavailableException} so the
	 * calling pipeline branches on the §10.2 hierarchy
	 * uniformly.
	 *
	 * @param callable               $buildRequest Closure that
	 *                                             produces the
	 *                                             {@see HttpRequest}
	 *                                             to dispatch.
	 *                                             Invoked twice
	 *                                             on a 401 retry
	 *                                             (the second
	 *                                             call rebuilds
	 *                                             with a fresh
	 *                                             token).
	 * @param string                 $endpoint     Endpoint label
	 *                                             ({@see AmazonCreatorsApiErrorMapper::ENDPOINT_SEARCH}
	 *                                             /
	 *                                             {@see AmazonCreatorsApiErrorMapper::ENDPOINT_LOOKUP}).
	 * @param ProviderProductId|null $productId    Provider-scoped
	 *                                             product id when
	 *                                             the request is
	 *                                             a lookup; null
	 *                                             for searches.
	 *                                             Forwarded to
	 *                                             the error mapper
	 *                                             so a 404 lookup
	 *                                             surfaces as
	 *                                             {@see \CommerceLinkBuilder\Domain\Provider\Exception\ProductNotFoundException}.
	 *
	 * @throws ProviderException When a non-2xx response maps to
	 *                           a §10.2 exception, or when a
	 *                           transport failure is wrapped as
	 *                           {@see TemporaryUnavailableException}.
	 */
	private function dispatchWithTokenRetry(
		callable $buildRequest,
		string $endpoint,
		?ProviderProductId $productId
	): HttpResponse {
		$request  = $buildRequest();
		$response = $this->dispatch( $request );

		if ( $this->isSuccessful( $response ) ) {
			return $response;
		}

		// One-shot 401 retry: invalidate the cached token and
		// retry once. Other 4xx / 5xx codes go straight to the
		// error mapper.
		if ( $response->statusCode() === 401 ) {
			$this->tokenProvider->invalidate( $this->credentials );

			$request  = $buildRequest();
			$response = $this->dispatch( $request );

			if ( $this->isSuccessful( $response ) ) {
				return $response;
			}
		}

		throw $this->errorMapper->mapResponse( $response, $endpoint, $productId );
	}

	/**
	 * Send a single request through the HTTP client, wrapping
	 * transport failures as {@see TemporaryUnavailableException}.
	 *
	 * Centralizes the transport-failure translation so search /
	 * lookup / health-check share a single seam. The wrapped
	 * exception's context map carries only the originating
	 * request's HTTP method and URL host (never the full URL,
	 * which would embed the partner tag, and never the
	 * Authorization header, which embeds the access token).
	 *
	 * @throws TemporaryUnavailableException When the transport
	 *                                       layer reports a
	 *                                       failure.
	 */
	private function dispatch( HttpRequest $request ): HttpResponse {
		try {
			return $this->httpClient->send( $request );
		} catch ( HttpClientException $error ) {
			throw new TemporaryUnavailableException(
				self::PROVIDER_KEY,
				sprintf(
					'Amazon Creators API request failed at the transport layer: %s',
					$error->getMessage()
				),
				array(
					'http_method' => $request->method(),
					'reason'      => 'transport_failure',
				),
				$error
			);
		}
	}

	/**
	 * Whether the supplied HTTP response is in the 2xx success
	 * range.
	 *
	 * Centralized here because the {@see HttpResponse} VO does
	 * not expose an `isSuccessful()` helper directly across
	 * every release of the codebase; using the status code
	 * comparison keeps this provider compatible with any
	 * existing HttpResponse shape.
	 */
	private function isSuccessful( HttpResponse $response ): bool {
		$status = $response->statusCode();
		return $status >= 200 && $status < 300;
	}

	/**
	 * Translate a §10.2 exception into a
	 * {@see ProviderHealthStatus} snapshot for the
	 * {@see self::healthCheck()} path.
	 *
	 * @param ProviderException $exception Exception produced by
	 *                                     the error mapper or
	 *                                     by the request /
	 *                                     normalize path.
	 * @param bool              $reachable Whether the upstream
	 *                                     responded at all.
	 * @param DateTimeImmutable $now       Probe timestamp.
	 */
	private function translateExceptionToHealth(
		ProviderException $exception,
		bool $reachable,
		DateTimeImmutable $now
	): ProviderHealthStatus {
		$message = $exception->getMessage() === '' ? null : $exception->getMessage();
		$details = array(
			'error_code' => $exception->errorCode(),
		);

		if ( $exception instanceof AuthenticationException ) {
			return new ProviderHealthStatus(
				ProviderHealthStatus::STATUS_INVALID_CREDENTIALS,
				false,
				$reachable,
				false,
				$message,
				$now,
				$details
			);
		}

		if ( $exception instanceof RateLimitExceededException ) {
			return new ProviderHealthStatus(
				ProviderHealthStatus::STATUS_RATE_LIMITED,
				true,
				$reachable,
				true,
				$message,
				$now,
				$details
			);
		}

		if ( $exception instanceof TemporaryUnavailableException ) {
			return new ProviderHealthStatus(
				ProviderHealthStatus::STATUS_DEGRADED,
				true,
				$reachable,
				false,
				$message,
				$now,
				$details
			);
		}

		// Fall-through covers ProviderRequestException /
		// ProviderResponseException / ProductNotFoundException /
		// any other §10.2 subclass.
		return new ProviderHealthStatus(
			ProviderHealthStatus::STATUS_DEGRADED,
			true,
			$reachable,
			false,
			$message,
			$now,
			$details
		);
	}
}
