<?php
/**
 * Amazon Creators API response normalizer.
 *
 * Phase B (KI-019, ADR-0022) collaborator that translates raw
 * Amazon Creators API `POST /catalog/v1/searchItems` and
 * `POST /catalog/v1/getItems` JSON responses into the
 * cross-provider {@see SearchResult} and {@see ProductSnapshot}
 * domain values that the rest of the codebase consumes.
 *
 * Why this class exists:
 *
 *   - The Creators API response shape differs from the legacy
 *     PA-API 5.0 shape this codebase was originally built
 *     against in three orthogonal ways:
 *
 *       1. **Naming convention.** Every response field is
 *          lowerCamelCase (`itemsResult`, `searchResult`,
 *          `items`, `asin`, `detailPageURL`, `images`,
 *          `primary`, `medium`, `itemInfo`, `byLineInfo`,
 *          `brand`, `title`, `displayValue`, `parentASIN`,
 *          `offersV2`, `listings`, `price`, `money`, `amount`,
 *          `currency`, `displayAmount`, `availability`, `type`)
 *          rather than PA-API 5.0's PascalCase. The normalizer
 *          unwraps the lowerCamelCase paths once so the rest of
 *          the codebase never sees the wire shape.
 *
 *       2. **Response root.** SearchItems wraps results under
 *          `searchResult.items[]`; GetItems wraps results under
 *          `itemsResult.items[]`. Both arrays carry the same
 *          per-item shape, so this normalizer exposes one
 *          {@see self::normalizeSearchResult()} entry point that
 *          unwraps `searchResult` and one
 *          {@see self::normalizeProduct()} entry point that
 *          unwraps `itemsResult` and returns the first item.
 *
 *       3. **Offers shape.** PA-API 5.0's `Offers.Listings` /
 *          `Offers.Summaries` is gone; Creators API exposes
 *          `OffersV2` instead. The structure is documented at
 *          https://affiliate-program.amazon.com/creatorsapi/docs/en-us/api-reference/resources/offersV2
 *          and roughly maps to:
 *
 *            offersV2.listings[]
 *              .price
 *                .money.amount       (decimal number)
 *                .money.currency     (ISO 4217 string)
 *                .money.displayAmount (locale-formatted string)
 *              .availability.type    ('IN_STOCK' / 'OUT_OF_STOCK' / etc.)
 *              .availability.maxOrderQuantity
 *              .availability.minOrderQuantity
 *              .isBuyBoxWinner       (boolean)
 *              .merchantInfo.name
 *              .condition.value      ('New' / 'Used' / etc.)
 *
 *          We pick the buy-box-winning listing when present and
 *          fall back to the first listing otherwise. Currency
 *          falls back to {@see self::CURRENCY_FALLBACK} when
 *          Amazon omits it (defensive — every documented
 *          response carries a currency, but the normalizer
 *          should not crash on a sparse payload).
 *
 * Failure semantics:
 *
 *   - JSON-shape problems (missing root, missing required field,
 *     wrong type) raise {@see ProviderResponseException} with
 *     the offending JSON path in the context payload so log
 *     readers can correlate against the upstream response.
 *
 *   - Per-item normalization failures (a single item with a
 *     missing ASIN, an image URL that is not a valid URL, a
 *     price `amount` that is not numeric) raise
 *     {@see ProviderResponseException} for the bad item; the
 *     calling provider decides whether to fail the whole
 *     response or skip the bad item. This MVP keeps the strict
 *     "fail-fast on bad item" posture the legacy
 *     {@see \Affilun\Infrastructure\Provider\Amazon\AmazonResponseNormalizer}
 *     used.
 *
 *   - Pagination metadata: `searchResult.totalResultCount` is
 *     normalized into {@see SearchResult::total()} when present
 *     and an integer; otherwise null is passed through. The
 *     `hasMore` boolean is derived from the page+perPage+total
 *     triple at the {@see self::deriveHasMore()} helper because
 *     Amazon does not surface a `hasMore` field directly.
 *
 * Field mapping summary (Creators API -> ProductSnapshot):
 *
 *   item.asin                                   -> providerProductId.value
 *   item.itemInfo.title.displayValue            -> title
 *   item.itemInfo.byLineInfo.brand.displayValue -> brand (nullable)
 *   item.images.primary.medium.url              -> primaryImage (nullable)
 *   item.detailPageURL                          -> detailUrl
 *   item.parentASIN                             -> rawMeta['parent_asin'] (nullable)
 *   item.offersV2.listings[buyBox|first].price  -> price (PriceInfo, nullable)
 *   item.offersV2.listings[buyBox|first].availability.type -> availability (AvailabilityInfo)
 *
 * The full upstream item map is also stored verbatim under
 * {@see ProductSnapshot::rawMeta()} so a future enhancement can
 * surface fields the MVP normalizer ignores without re-fetching
 * the API response.
 *
 * @package Affilun\Infrastructure\Provider\Amazon\CreatorsApi
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Provider\Amazon\CreatorsApi;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped,Squiz.Commenting.FunctionCommentThrowTag.WrongNumber -- This normalizer constructs exceptions from upstream payloads and renders no HTML.

use Affilun\Domain\Pricing\AvailabilityInfo;
use Affilun\Domain\Pricing\PriceInfo;
use Affilun\Domain\Product\ProductSnapshot;
use Affilun\Domain\Provider\Exception\ProductNotFoundException;
use Affilun\Domain\Provider\Exception\ProviderResponseException;
use Affilun\Domain\Provider\ProviderProductId;
use Affilun\Domain\Search\SearchQuery;
use Affilun\Domain\Search\SearchResult;
use DateTimeImmutable;
use InvalidArgumentException;
use JsonException;
use Throwable;

/**
 * Normalize Amazon Creators API JSON responses into
 * {@see SearchResult} / {@see ProductSnapshot} cross-provider
 * domain values.
 *
 * See the file-level docblock for the lowerCamelCase wire-shape
 * rationale, the SearchItems-vs-GetItems root unwrapping rules,
 * the OffersV2 buy-box-winner selection policy, the per-item
 * fail-fast posture, and the rawMeta passthrough convention.
 */
final class AmazonCreatorsApiResponseNormalizer {

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

	/**
	 * Currency code used when {@see PriceInfo} is constructed
	 * from an OffersV2 listing whose `money.currency` field is
	 * missing or not a string.
	 *
	 * Defaults to JPY because the Phase 12 MVP marketplace is
	 * `www.amazon.co.jp`; a future slice that surfaces a
	 * marketplace-aware default can read from
	 * {@see AmazonCreatorsApiCredentials::marketplace()} via the
	 * provider's wiring.
	 */
	public const CURRENCY_FALLBACK = 'JPY';

	/**
	 * Normalize a SearchItems response body into a
	 * {@see SearchResult}.
	 *
	 * Decodes the JSON body, unwraps the `searchResult.items[]`
	 * array, and applies {@see self::buildSnapshot()} to each
	 * item entry. Pagination metadata (`page`, `perPage`,
	 * `total`, `hasMore`) is derived from the supplied
	 * {@see SearchQuery} plus the upstream
	 * `searchResult.totalResultCount` field; the response does
	 * not echo back the request's page/perPage so we trust the
	 * query.
	 *
	 * @param string            $body       Raw HTTP response body
	 *                                      returned by the Creators API
	 *                                      SearchItems endpoint.
	 * @param SearchQuery       $query      Original search query whose
	 *                                      page/perPage values are
	 *                                      stamped onto the result.
	 * @param DateTimeImmutable $observedAt UTC instant the
	 *                                      response was observed
	 *                                      (used as `fetchedAt`
	 *                                      and as the
	 *                                      {@see PriceInfo}
	 *                                      `observedAt`).
	 *
	 * @throws ProviderResponseException When the JSON cannot be
	 *                                   decoded, the root shape
	 *                                   is wrong, or any item
	 *                                   fails normalization.
	 */
	public function normalizeSearchResult(
		string $body,
		SearchQuery $query,
		DateTimeImmutable $observedAt
	): SearchResult {
		$payload = $this->decodeJson( $body, 'searchItems' );

		$searchResult = $payload['searchResult'] ?? $payload['itemsResult'] ?? null;
		if ( $searchResult === null && self::isNoResultsPayload( $payload ) ) {
			return SearchResult::empty( self::PROVIDER_KEY, $query->page(), $query->perPage() );
		}
		if ( ! is_array( $searchResult ) || array_is_list( $searchResult ) ) {
			throw $this->responseException(
				'Amazon Creators API searchItems response missing searchResult or itemsResult object.',
				array(
					'field'  => 'searchResult|itemsResult',
					'reason' => 'missing_required_field',
				)
			);
		}

		$items = $searchResult['items'] ?? array();
		if ( ! is_array( $items ) ) {
			throw $this->responseException(
				'Amazon Creators API searchItems response searchResult.items is not an array.',
				array(
					'field'  => 'searchResult.items',
					'reason' => 'wrong_type',
				)
			);
		}

		$total = $this->extractTotal( $searchResult );

		$snapshots = array();
		$index     = -1;
		foreach ( $items as $item ) {
			++$index;
			$snapshots[] = $this->buildSnapshot( $item, $observedAt, $index );
		}

		return new SearchResult(
			self::PROVIDER_KEY,
			$snapshots,
			$query->page(),
			$query->perPage(),
			$total,
			$this->deriveHasMore( $query, $total, count( $snapshots ) ),
			array(
				'amazon_creators_api' => array(
					'totalResultCount' => $total,
				),
			)
		);
	}

	/**
	 * Detect a successful error-envelope representation of zero results.
	 *
	 * @param array<string,mixed> $payload Decoded response.
	 */
	private static function isNoResultsPayload( array $payload ): bool {
		$errors = $payload['errors'] ?? null;
		if ( ! is_array( $errors ) || ! isset( $errors[0] ) || ! is_array( $errors[0] ) ) {
			return false;
		}
		$code = $errors[0]['code'] ?? null;
		return $code === 'NoResults' || $code === 'ResourceNotFoundException';
	}

	/**
	 * Normalize a GetItems response body into the single
	 * {@see ProductSnapshot} the cross-provider
	 * {@see \Affilun\Domain\Provider\ItemLookupProviderInterface::getProduct()}
	 * contract returns.
	 *
	 * Decodes the JSON body, unwraps the `itemsResult.items[]`
	 * array, and returns the snapshot built from the first
	 * (and only, in the MVP) item entry.
	 *
	 * Empty `itemsResult.items` raises
	 * {@see ProductNotFoundException} so the calling provider
	 * surfaces it through the standard §10.2 exception path.
	 *
	 * @param string            $body       Raw HTTP response body
	 *                                      returned by the Creators
	 *                                      API GetItems endpoint.
	 * @param ProviderProductId $productId  Provider-scoped product
	 *                                      identifier the lookup
	 *                                      was issued for (used to
	 *                                      construct the
	 *                                      {@see ProductNotFoundException}
	 *                                      and to validate the
	 *                                      returned ASIN matches).
	 * @param DateTimeImmutable $observedAt UTC instant the response
	 *                                      was observed.
	 *
	 * @throws ProductNotFoundException  When the response carries
	 *                                   no items.
	 * @throws ProviderResponseException When the JSON cannot be
	 *                                   decoded, the root shape
	 *                                   is wrong, or the single
	 *                                   item fails normalization.
	 */
	public function normalizeProduct(
		string $body,
		ProviderProductId $productId,
		DateTimeImmutable $observedAt
	): ProductSnapshot {
		$payload = $this->decodeJson( $body, 'getItems' );

		$itemsResult = $payload['itemsResult'] ?? null;
		if ( ! is_array( $itemsResult ) || array_is_list( $itemsResult ) ) {
			throw $this->responseException(
				'Amazon Creators API getItems response missing itemsResult object.',
				array(
					'field'  => 'itemsResult',
					'reason' => 'missing_required_field',
				)
			);
		}

		$items = $itemsResult['items'] ?? array();
		if ( ! is_array( $items ) ) {
			throw $this->responseException(
				'Amazon Creators API getItems response itemsResult.items is not an array.',
				array(
					'field'  => 'itemsResult.items',
					'reason' => 'wrong_type',
				)
			);
		}

		if ( $items === array() ) {
			throw new ProductNotFoundException(
				self::PROVIDER_KEY,
				sprintf(
					'Amazon Creators API getItems returned no item for ASIN %s.',
					$productId->value()
				),
				array(
					'provider_product_id' => $productId->value(),
					'reason'              => 'no_items_returned',
				)
			);
		}

		return $this->buildSnapshot( $items[0], $observedAt, 0 );
	}

	/**
	 * Build a {@see ProductSnapshot} from a single Creators API
	 * item entry.
	 *
	 * @param mixed             $item       Decoded item entry
	 *                                      (expected to be an
	 *                                      associative array).
	 * @param DateTimeImmutable $observedAt UTC instant the
	 *                                      response was observed.
	 * @param int               $index      Positional index for
	 *                                      diagnostic context.
	 *
	 * @throws ProviderResponseException When any required field
	 *                                   is missing or malformed.
	 */
	private function buildSnapshot(
		$item,
		DateTimeImmutable $observedAt,
		int $index
	): ProductSnapshot {
		$entry = $this->expectAssocArray( $item, 'items[]', $index );

		$asin   = $this->extractAsin( $entry, $index );
		$title  = $this->extractTitle( $entry, $index );
		$detail = $this->extractDetailUrl( $entry, $index );

		$brand        = $this->extractBrand( $entry );
		$primaryImage = $this->extractPrimaryImage( $entry );
		$price        = $this->extractPrice( $entry, $observedAt, $index );
		$availability = $this->extractAvailability( $entry );

		$rawMeta = $entry;
		$parent  = $this->extractParentAsin( $entry );
		if ( $parent !== null ) {
			$rawMeta['parent_asin'] = $parent;
		}

		try {
			return new ProductSnapshot(
				self::PROVIDER_KEY,
				new ProviderProductId( self::PROVIDER_KEY, $asin ),
				$title,
				$brand,
				null,
				$detail,
				null,
				$primaryImage,
				array(),
				$price,
				$availability,
				null,
				null,
				array(),
				array(),
				$observedAt,
				$rawMeta
			);
		} catch ( InvalidArgumentException $error ) {
			throw $this->responseException(
				sprintf(
					'Amazon Creators API items[%d] could not be normalized: %s',
					$index,
					$error->getMessage()
				),
				array(
					'field'  => 'items[]',
					'index'  => $index,
					'reason' => 'snapshot_normalization_failed',
				),
				$error
			);
		}
	}

	/**
	 * Decode the raw response body into an associative array.
	 *
	 * @param string $body     Raw HTTP response body.
	 * @param string $endpoint Endpoint label stamped into the
	 *                         exception context for log
	 *                         correlation (`searchItems` or
	 *                         `getItems`).
	 *
	 * @return array<string, mixed>
	 *
	 * @throws ProviderResponseException When the body is empty,
	 *                                   not valid JSON, or the
	 *                                   root is not a JSON
	 *                                   object.
	 */
	private function decodeJson( string $body, string $endpoint ): array {
		if ( $body === '' ) {
			throw $this->responseException(
				sprintf( 'Amazon Creators API %s response body is empty.', $endpoint ),
				array(
					'endpoint' => $endpoint,
					'reason'   => 'empty_body',
				)
			);
		}

		try {
			$decoded = json_decode( $body, true, 512, JSON_THROW_ON_ERROR );
		} catch ( JsonException $error ) {
			throw $this->responseException(
				sprintf(
					'Amazon Creators API %s response is not valid JSON: %s',
					$endpoint,
					$error->getMessage()
				),
				array(
					'endpoint' => $endpoint,
					'reason'   => 'invalid_json',
				),
				$error
			);
		}

		if ( ! is_array( $decoded ) || array_is_list( $decoded ) ) {
			throw $this->responseException(
				sprintf(
					'Amazon Creators API %s response root is not a JSON object.',
					$endpoint
				),
				array(
					'endpoint' => $endpoint,
					'reason'   => 'wrong_type',
				)
			);
		}

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

	/**
	 * Extract the `asin` field from an item entry.
	 *
	 * @param array<string, mixed> $item  Decoded item entry.
	 * @param int                  $index Positional index for
	 *                                    diagnostic context.
	 *
	 * @throws ProviderResponseException When the field is missing
	 *                                   / mistyped / empty.
	 */
	private function extractAsin( array $item, int $index ): string {
		$value = $item['asin'] ?? null;
		if ( ! is_string( $value ) ) {
			throw $this->responseException(
				sprintf( 'Amazon Creators API items[%d].asin is missing or not a string.', $index ),
				array(
					'field'  => 'items[].asin',
					'index'  => $index,
					'reason' => 'missing_required_field',
				)
			);
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			throw $this->responseException(
				sprintf( 'Amazon Creators API items[%d].asin is empty after trimming.', $index ),
				array(
					'field'  => 'items[].asin',
					'index'  => $index,
					'reason' => 'empty_required_field',
				)
			);
		}

		return $trimmed;
	}

	/**
	 * Extract the `itemInfo.title.displayValue` field from an
	 * item entry.
	 *
	 * @param array<string, mixed> $item  Decoded item entry.
	 * @param int                  $index Positional index for
	 *                                    diagnostic context.
	 *
	 * @throws ProviderResponseException When the field is missing
	 *                                   / mistyped / empty.
	 */
	private function extractTitle( array $item, int $index ): string {
		$itemInfo = $item['itemInfo'] ?? null;
		if ( ! is_array( $itemInfo ) || array_is_list( $itemInfo ) ) {
			throw $this->responseException(
				sprintf(
					'Amazon Creators API items[%d].itemInfo is missing or not an object.',
					$index
				),
				array(
					'field'  => 'items[].itemInfo',
					'index'  => $index,
					'reason' => 'missing_required_field',
				)
			);
		}

		$title = $itemInfo['title'] ?? null;
		if ( ! is_array( $title ) || array_is_list( $title ) ) {
			throw $this->responseException(
				sprintf(
					'Amazon Creators API items[%d].itemInfo.title is missing or not an object.',
					$index
				),
				array(
					'field'  => 'items[].itemInfo.title',
					'index'  => $index,
					'reason' => 'missing_required_field',
				)
			);
		}

		$display = $title['displayValue'] ?? null;
		if ( ! is_string( $display ) ) {
			throw $this->responseException(
				sprintf(
					'Amazon Creators API items[%d].itemInfo.title.displayValue is missing or not a string.',
					$index
				),
				array(
					'field'  => 'items[].itemInfo.title.displayValue',
					'index'  => $index,
					'reason' => 'missing_required_field',
				)
			);
		}

		$trimmed = trim( $display );
		if ( $trimmed === '' ) {
			throw $this->responseException(
				sprintf(
					'Amazon Creators API items[%d].itemInfo.title.displayValue is empty after trimming.',
					$index
				),
				array(
					'field'  => 'items[].itemInfo.title.displayValue',
					'index'  => $index,
					'reason' => 'empty_required_field',
				)
			);
		}

		return $trimmed;
	}

	/**
	 * Extract the `detailPageURL` field from an item entry.
	 *
	 * @param array<string, mixed> $item  Decoded item entry.
	 * @param int                  $index Positional index for
	 *                                    diagnostic context.
	 *
	 * @throws ProviderResponseException When the field is missing
	 *                                   / mistyped / empty.
	 */
	private function extractDetailUrl( array $item, int $index ): string {
		$value = $item['detailPageURL'] ?? null;
		if ( ! is_string( $value ) ) {
			throw $this->responseException(
				sprintf(
					'Amazon Creators API items[%d].detailPageURL is missing or not a string.',
					$index
				),
				array(
					'field'  => 'items[].detailPageURL',
					'index'  => $index,
					'reason' => 'missing_required_field',
				)
			);
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			throw $this->responseException(
				sprintf(
					'Amazon Creators API items[%d].detailPageURL is empty after trimming.',
					$index
				),
				array(
					'field'  => 'items[].detailPageURL',
					'index'  => $index,
					'reason' => 'empty_required_field',
				)
			);
		}

		return $trimmed;
	}

	/**
	 * Extract the `itemInfo.byLineInfo.brand.displayValue` field
	 * from an item entry, returning null when any link in the
	 * chain is missing or malformed.
	 *
	 * Brand is optional (some categories — gift cards, generic
	 * components — have no brand surface), so a missing field is
	 * NOT a normalization failure.
	 *
	 * @param array<string, mixed> $item Decoded item entry.
	 */
	private function extractBrand( array $item ): ?string {
		$itemInfo = $item['itemInfo'] ?? null;
		if ( ! is_array( $itemInfo ) ) {
			return null;
		}

		$byLine = $itemInfo['byLineInfo'] ?? null;
		if ( ! is_array( $byLine ) ) {
			return null;
		}

		$brand = $byLine['brand'] ?? null;
		if ( ! is_array( $brand ) ) {
			return null;
		}

		$display = $brand['displayValue'] ?? null;
		if ( ! is_string( $display ) ) {
			return null;
		}

		$trimmed = trim( $display );
		if ( $trimmed === '' ) {
			return null;
		}

		return $trimmed;
	}

	/**
	 * Extract the `images.primary.medium.url` field from an item
	 * entry, returning null when any link in the chain is missing
	 * or malformed.
	 *
	 * Primary image is optional in the Creators API responses
	 * (Amazon may withhold images for items pending review or
	 * for items in restricted categories), so a missing field is
	 * NOT a normalization failure.
	 *
	 * @param array<string, mixed> $item Decoded item entry.
	 */
	private function extractPrimaryImage( array $item ): ?string {
		$images = $item['images'] ?? null;
		if ( ! is_array( $images ) ) {
			return null;
		}

		$primary = $images['primary'] ?? null;
		if ( ! is_array( $primary ) ) {
			return null;
		}

		$medium = $primary['medium'] ?? null;
		if ( ! is_array( $medium ) ) {
			return null;
		}

		$url = $medium['url'] ?? null;
		if ( ! is_string( $url ) ) {
			return null;
		}

		$trimmed = trim( $url );
		if ( $trimmed === '' ) {
			return null;
		}

		return $trimmed;
	}

	/**
	 * Extract the `parentASIN` field from an item entry,
	 * returning null when missing.
	 *
	 * Parent ASIN is optional (only variation children carry
	 * one), so a missing field is NOT a normalization failure.
	 *
	 * @param array<string, mixed> $item Decoded item entry.
	 */
	private function extractParentAsin( array $item ): ?string {
		$value = $item['parentASIN'] ?? null;
		if ( ! is_string( $value ) ) {
			return null;
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			return null;
		}

		return $trimmed;
	}

	/**
	 * Extract a {@see PriceInfo} from the OffersV2 listings of
	 * an item entry.
	 *
	 * Picks the buy-box-winning listing when present (i.e. a
	 * listing with `isBuyBoxWinner === true`); falls back to the
	 * first listing otherwise. Returns null when no listings are
	 * present or none carry a usable price.
	 *
	 * @param array<string, mixed> $item       Decoded item entry.
	 * @param DateTimeImmutable    $observedAt UTC instant the
	 *                                         response was observed
	 *                                         (used as
	 *                                         {@see PriceInfo::observedAt()}).
	 * @param int                  $index      Positional index for
	 *                                         diagnostic context.
	 *
	 * @throws ProviderResponseException When a present
	 *                                   `money.amount` field is
	 *                                   not a number.
	 */
	private function extractPrice(
		array $item,
		DateTimeImmutable $observedAt,
		int $index
	): ?PriceInfo {
		$offers = $item['offersV2'] ?? null;
		if ( ! is_array( $offers ) ) {
			return null;
		}

		$listings = $offers['listings'] ?? null;
		if ( ! is_array( $listings ) || $listings === array() ) {
			return null;
		}

		$listing = self::pickListing( $listings );
		if ( $listing === null ) {
			return null;
		}

		$price = $listing['price'] ?? null;
		if ( ! is_array( $price ) ) {
			return null;
		}

		$money = $price['money'] ?? null;
		if ( ! is_array( $money ) ) {
			return null;
		}

		$amount = $money['amount'] ?? null;
		if ( ! is_int( $amount ) && ! is_float( $amount ) && ! is_string( $amount ) ) {
			return null;
		}

		$amountString = is_string( $amount ) ? trim( $amount ) : (string) $amount;
		if ( $amountString === '' || ! is_numeric( $amountString ) ) {
			throw $this->responseException(
				sprintf(
					'Amazon Creators API items[%d].offersV2.listings[].price.money.amount is not numeric (got "%s").',
					$index,
					is_scalar( $amount ) ? (string) $amount : gettype( $amount )
				),
				array(
					'field'  => 'items[].offersV2.listings[].price.money.amount',
					'index'  => $index,
					'reason' => 'invalid_numeric_value',
				)
			);
		}

		$currency = $money['currency'] ?? null;
		if ( ! is_string( $currency ) || trim( $currency ) === '' ) {
			$currency = self::CURRENCY_FALLBACK;
		} else {
			$currency = strtoupper( trim( $currency ) );
		}

		$displayAmount = $money['displayAmount'] ?? null;
		$label         = is_string( $displayAmount ) && trim( $displayAmount ) !== ''
			? trim( $displayAmount )
			: null;

		try {
			return new PriceInfo(
				$amountString,
				$currency,
				$label,
				$observedAt,
				false,
				false,
				null
			);
		} catch ( InvalidArgumentException $error ) {
			throw $this->responseException(
				sprintf(
					'Amazon Creators API items[%d] price could not be constructed: %s',
					$index,
					$error->getMessage()
				),
				array(
					'field'  => 'items[].offersV2.listings[].price',
					'index'  => $index,
					'reason' => 'price_construction_failed',
				),
				$error
			);
		}
	}

	/**
	 * Extract an {@see AvailabilityInfo} from the OffersV2
	 * listings of an item entry.
	 *
	 * Picks the buy-box-winning listing when present; falls back
	 * to the first listing otherwise. Returns
	 * {@see AvailabilityInfo::unknown()} when no listings are
	 * present or none carry an availability type.
	 *
	 * Amazon's OffersV2 `availability.type` values are
	 * uppercase tokens (`IN_STOCK`, `OUT_OF_STOCK`, `LIMITED`,
	 * `PREORDER`, etc.); we map them to the lowercase
	 * cross-provider {@see AvailabilityInfo} status tokens.
	 *
	 * @param array<string, mixed> $item Decoded item entry.
	 */
	private function extractAvailability( array $item ): AvailabilityInfo {
		$offers = $item['offersV2'] ?? null;
		if ( ! is_array( $offers ) ) {
			return AvailabilityInfo::unknown();
		}

		$listings = $offers['listings'] ?? null;
		if ( ! is_array( $listings ) || $listings === array() ) {
			return AvailabilityInfo::unknown();
		}

		$listing = self::pickListing( $listings );
		if ( $listing === null ) {
			return AvailabilityInfo::unknown();
		}

		$availability = $listing['availability'] ?? null;
		if ( ! is_array( $availability ) ) {
			return AvailabilityInfo::unknown();
		}

		$type = $availability['type'] ?? null;
		if ( ! is_string( $type ) ) {
			return AvailabilityInfo::unknown();
		}

		$status = self::mapAvailabilityType( $type );
		if ( $status === null ) {
			return AvailabilityInfo::unknown();
		}

		try {
			return new AvailabilityInfo( $status, null, true );
		} catch ( InvalidArgumentException $error ) {
			unset( $error );
			return AvailabilityInfo::unknown();
		}
	}

	/**
	 * Map Amazon's OffersV2 `availability.type` token to the
	 * cross-provider {@see AvailabilityInfo} status.
	 *
	 * Returns null for unknown / unmapped tokens so the caller
	 * can fall back to {@see AvailabilityInfo::unknown()}.
	 *
	 * @param string $type Upstream availability token.
	 */
	private static function mapAvailabilityType( string $type ): ?string {
		switch ( strtoupper( trim( $type ) ) ) {
			case 'IN_STOCK':
				return AvailabilityInfo::STATUS_IN_STOCK;
			case 'OUT_OF_STOCK':
				return AvailabilityInfo::STATUS_OUT_OF_STOCK;
			case 'LIMITED':
			case 'LIMITED_AVAILABILITY':
				return AvailabilityInfo::STATUS_LIMITED;
			case 'PREORDER':
			case 'PRE_ORDER':
				return AvailabilityInfo::STATUS_PREORDER;
			case 'UNAVAILABLE':
			case 'NOT_AVAILABLE':
				return AvailabilityInfo::STATUS_UNAVAILABLE;
			default:
				return null;
		}
	}

	/**
	 * Pick the buy-box-winning listing when present, else the
	 * first listing.
	 *
	 * @param list<mixed> $listings Decoded
	 *                              `offersV2.listings` array.
	 *
	 * @return array<string, mixed>|null
	 */
	private static function pickListing( array $listings ): ?array {
		foreach ( $listings as $candidate ) {
			if ( ! is_array( $candidate ) ) {
				continue;
			}
			if ( ! empty( $candidate['isBuyBoxWinner'] ) ) {
				/** @var array<string, mixed> $candidate */
				return $candidate;
			}
		}

		foreach ( $listings as $candidate ) {
			if ( is_array( $candidate ) ) {
				/** @var array<string, mixed> $candidate */
				return $candidate;
			}
		}

		return null;
	}

	/**
	 * Extract the `searchResult.totalResultCount` field as a
	 * non-negative integer, or null when the field is missing
	 * or not an integer.
	 *
	 * @param array<string, mixed> $searchResult Decoded
	 *                                           `searchResult`
	 *                                           subtree.
	 */
	private function extractTotal( array $searchResult ): ?int {
		$total = $searchResult['totalResultCount'] ?? null;
		if ( is_int( $total ) && $total >= 0 ) {
			return $total;
		}
		if ( is_string( $total ) && ctype_digit( $total ) ) {
			return (int) $total;
		}
		return null;
	}

	/**
	 * Derive the `hasMore` flag from the supplied page +
	 * perPage + total triple.
	 *
	 * The Creators API does not surface a `hasMore` field
	 * directly. We derive it from `totalResultCount` when
	 * available, falling back to "the current page returned a
	 * full perPage worth of items" when the total is unknown.
	 *
	 * @param SearchQuery $query        Original search query.
	 * @param int|null    $total        Upstream
	 *                                  `totalResultCount`.
	 * @param int         $returnedRows Number of snapshots that
	 *                                  ended up in this page.
	 */
	private function deriveHasMore( SearchQuery $query, ?int $total, int $returnedRows ): bool {
		if ( $total !== null ) {
			$consumed = ( $query->page() - 1 ) * $query->perPage() + $returnedRows;
			return $consumed < $total;
		}
		return $returnedRows >= $query->perPage();
	}

	/**
	 * Coerce the supplied value to a non-list associative array
	 * or raise a {@see ProviderResponseException}.
	 *
	 * @param mixed    $value Candidate value.
	 * @param string   $label Human-readable label for the error
	 *                        message.
	 * @param int|null $index Positional index for the error
	 *                        context (or null when the value is
	 *                        not part of an array iteration).
	 *
	 * @return array<string, mixed>
	 *
	 * @throws ProviderResponseException When the value is not an
	 *                                   associative array.
	 */
	private function expectAssocArray( $value, string $label, ?int $index ): array {
		if ( ! is_array( $value ) || array_is_list( $value ) ) {
			$context = array(
				'field'  => $label,
				'reason' => 'wrong_type',
			);
			if ( $index !== null ) {
				$context['index'] = $index;
			}
			throw $this->responseException(
				sprintf(
					'Amazon Creators API %s entry is not a JSON object.',
					$label
				),
				$context
			);
		}

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

	/**
	 * Compose a {@see ProviderResponseException} stamped with
	 * the Amazon provider key and the supplied diagnostic
	 * context.
	 *
	 * @param string               $message  Human-readable error
	 *                                       message.
	 * @param array<string, mixed> $context  Structured context
	 *                                       for log correlation.
	 * @param Throwable|null       $previous Underlying cause, or
	 *                                       null.
	 */
	private function responseException(
		string $message,
		array $context = array(),
		?Throwable $previous = null
	): ProviderResponseException {
		return new ProviderResponseException(
			self::PROVIDER_KEY,
			$message,
			$context,
			$previous
		);
	}
}
