<?php
/**
 * Rakuten response normalizer.
 *
 * Translates raw Rakuten Web Service IchibaItem Search response
 * payloads (decoded from the JSON envelope `wp_remote_*` returns)
 * into cross-provider {@see ProductSnapshot} / {@see SearchResult}
 * domain values. The normalizer is the single seam where the
 * upstream's vocabulary
 * (`Items[].Item.{itemCode, itemName, itemPrice, itemUrl,
 * affiliateUrl, mediumImageUrls, ...}`) meets AFFILUN's cross-provider
 * model
 * (`provider-api-contracts.md` §7.4 / §7.5 / §7.6 / §7.2).
 *
 * Design notes:
 *
 *   - The normalizer accepts the already-decoded associative-array
 *     form of the Rakuten response. The HTTP layer
 *     ({@see \Affilun\Infrastructure\Http\WpRemoteHttpClient})
 *     produces an {@see HttpResponse} whose
 *     {@see HttpResponse::jsonBody()} returns the same shape; the
 *     provider layer hands that decoded value to this normalizer.
 *     Keeping the JSON-decoding seam outside this class lets tests
 *     drive the normalizer with hand-built fixtures without
 *     re-encoding through `json_encode()` / `json_decode()`.
 *
 *   - Every field's mapping rule lives next to the field accessor
 *     so a future contributor reading the file can answer "where
 *     does `ProductSnapshot::title()` come from on Rakuten?"
 *     without a separate translation table. The mapping is:
 *
 *       * `Item.itemName`     → {@see ProductSnapshot::title()}
 *       * `Item.shopName`     → {@see ProductSnapshot::brand()}
 *       * `Item.itemCaption`  → {@see ProductSnapshot::description()}
 *       * `Item.itemUrl`      → {@see ProductSnapshot::detailUrl()}
 *       * `Item.affiliateUrl` → {@see ProductSnapshot::affiliateUrl()}
 *       * `Item.mediumImageUrls[0].imageUrl`
 *                             → {@see ProductSnapshot::primaryImage()}
 *       * `Item.mediumImageUrls[1..].imageUrl`
 *                             → {@see ProductSnapshot::galleryImages()}
 *       * `Item.itemPrice`    → {@see PriceInfo::amount()}
 *       * `Item.availability` → {@see AvailabilityInfo}
 *       * `Item.reviewAverage`→ {@see ProductSnapshot::rating()}
 *       * `Item.reviewCount`  → {@see ProductSnapshot::reviewCount()}
 *       * `Item.genreId`      → {@see ProductSnapshot::categories()}
 *                               (single-element list when present)
 *       * `Item.itemCode`     → {@see ProviderProductId::value()}
 *
 *   - Currency on the Rakuten endpoint is always JPY; the
 *     normalizer hard-codes the value rather than synthesizing it
 *     from a missing response field. `taxFlag` (1 = tax-included,
 *     0 = tax-excluded) drives
 *     {@see PriceInfo::isTaxIncluded()}; the field is required by
 *     the IchibaItem schema so its absence indicates contract
 *     drift and surfaces as
 *     {@see ProviderResponseException}.
 *
 *   - {@see PriceInfo::observedAt()} and
 *     {@see ProductSnapshot::fetchedAt()} both come from a
 *     supplied "now" timestamp rather than from the upstream — the
 *     IchibaItem response does not carry a server-side observation
 *     timestamp, and synthesizing one from the local clock at the
 *     normalizer boundary keeps every snapshot from a single
 *     fetch carrying the same `fetchedAt` value.
 *
 *   - Per-item `availability` semantics on the Rakuten endpoint
 *     are documented as `1 = 在庫あり / 0 = 在庫なし`. The
 *     normalizer maps:
 *
 *       * `1` → {@see AvailabilityInfo::STATUS_IN_STOCK} with
 *               `isPurchasable = true`.
 *       * `0` → {@see AvailabilityInfo::STATUS_OUT_OF_STOCK} with
 *               `isPurchasable = false`.
 *       * Anything else (including missing field) →
 *               {@see AvailabilityInfo::unknown()}, matching the
 *               cross-provider §7.6 fallback.
 *
 *   - Image URLs in the Rakuten response carry per-image size
 *     suffixes (`?_ex=128x128`); the normalizer forwards them
 *     verbatim. The cross-provider {@see ProductSnapshot} URL
 *     fields enforce the http(s) scheme guard, so a malformed
 *     image entry naturally raises
 *     {@see ProviderResponseException} via the snapshot
 *     constructor — but to keep the response fault localized at
 *     the normalizer boundary we explicitly catch and re-wrap any
 *     {@see InvalidArgumentException} produced by the snapshot
 *     constructors so the §10.2 mapping is consistent.
 *
 *   - The `count` / `page` / `hits` / `last` axes on the response
 *     envelope feed {@see SearchResult::total()} /
 *     {@see SearchResult::page()} / {@see SearchResult::perPage()}
 *     / {@see SearchResult::hasMore()}. The upstream does not
 *     expose a dedicated "has more" flag, so the normalizer
 *     synthesizes it from `last < count` when both values are
 *     present, or from `count($items) === $hits` as a fallback
 *     when `count` is unknown — though the Rakuten endpoint
 *     always supplies `count`, the fallback exists so a future
 *     spec drift surfaces as a degraded but still-usable
 *     pagination signal rather than as a hard failure.
 *
 *   - {@see SearchResult::rawMeta()} carries the upstream's
 *     pagination axes verbatim under `count` / `page` /
 *     `pageCount` / `first` / `last` / `hits` keys. Per §7.2 the
 *     map is opaque to consuming surfaces; preserving the raw
 *     axes lets the diagnostics / observability layer probe them
 *     without re-decoding the response body.
 *
 * Failure semantics:
 *
 *   - The normalizer does NOT speak HTTP and does NOT branch on
 *     status codes. Application-level failures (4xx / 5xx, error
 *     envelopes) reach the {@see RakutenErrorMapper} (slice 4e)
 *     and never make it to this class.
 *
 *   - Response-shape failures (missing required field, value of
 *     the wrong type, `Items` entry that is not an associative
 *     array carrying an `Item` key) raise
 *     {@see ProviderResponseException} carrying a structured
 *     `context` map per §10. The context names the failing
 *     field via `field` / `index` keys and includes a short
 *     `reason` token so observability surfaces can branch on the
 *     specific drift mode without parsing the message body.
 *
 *   - Per §10.1 the normalizer MUST NOT carry credential material
 *     into exception messages or context maps. The Rakuten
 *     IchibaItem response payload is not credential-bearing, so
 *     the rule mostly applies to surrounding glue code; this
 *     class still avoids dumping decoded payload fragments
 *     (which can be large and may contain affiliate-link
 *     fragments) into messages.
 *
 * @package Affilun\Infrastructure\Provider\Rakuten
 */

declare(strict_types=1);

// Exception messages constructed in this normalizer are developer-
// facing diagnostic summaries surfaced through ProviderResponseException;
// they never reach an HTML rendering path. Suppress the EscapeOutput
// sniff at the file level rather than peppering every throw site
// with a per-line ignore.
//
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped

namespace Affilun\Infrastructure\Provider\Rakuten;

use Affilun\Domain\Pricing\AvailabilityInfo;
use Affilun\Domain\Pricing\PriceInfo;
use Affilun\Domain\Product\ProductSnapshot;
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;

/**
 * Translates Rakuten Web Service IchibaItem Search response
 * payloads into cross-provider {@see ProductSnapshot} /
 * {@see SearchResult} domain values.
 *
 * See the file-level docblock for the per-field mapping table,
 * the §7.6 availability translation, the synthetic `hasMore`
 * derivation, and the §10.2 mapping for response-shape failures.
 */
final class RakutenResponseNormalizer {

	/**
	 * §4 stable provider key for Rakuten. Held as a class
	 * constant so the normalizer never disagrees with the request
	 * factory on the key it tags snapshots with.
	 */
	public const PROVIDER_KEY = 'rakuten';

	/**
	 * ISO 4217 currency code Rakuten's IchibaItem endpoint always
	 * returns prices in. Hard-coded because the upstream does not
	 * vary by request and does not echo the value in the response.
	 */
	public const CURRENCY = 'JPY';

	/**
	 * Decode a search-shaped response payload into a
	 * {@see SearchResult}.
	 *
	 * The supplied `$decoded` payload is the JSON-decoded body of
	 * an IchibaItem Search response: an associative array with
	 * `Items` (a list of `{Item: {...}}` envelopes) plus the
	 * pagination axes `count` / `page` / `pageCount` / `hits` /
	 * `first` / `last`.
	 *
	 * The supplied `$query` carries the page / perPage values the
	 * caller requested. The response echoes these back on its own
	 * `page` / `hits` axes, but the normalizer prefers the
	 * upstream's reflected values when present and falls back to
	 * the request when the upstream omits them — both shapes have
	 * been observed in production.
	 *
	 * The supplied `$now` stamps {@see ProductSnapshot::fetchedAt()}
	 * and {@see PriceInfo::observedAt()} on every produced
	 * snapshot.
	 *
	 * @param mixed             $decoded JSON-decoded response body.
	 * @param SearchQuery       $query   Original cross-provider query.
	 * @param DateTimeImmutable $now     Observation timestamp.
	 *
	 * @throws ProviderResponseException When the payload is not
	 *                                   shaped like an IchibaItem
	 *                                   Search response or when
	 *                                   any contained item fails
	 *                                   the cross-provider snapshot
	 *                                   contract.
	 */
	public function normalizeSearchResult(
		$decoded,
		SearchQuery $query,
		DateTimeImmutable $now
	): SearchResult {
		$payload = $this->expectAssocArray( $decoded, 'response', null );

		$itemsRaw = $payload['Items'] ?? $payload['items'] ?? null;
		if ( ! is_array( $itemsRaw ) ) {
			throw $this->responseException(
				'Rakuten search response is missing the "Items" array.',
				array(
					'field'  => 'Items',
					'reason' => 'missing_items_array',
				)
			);
		}

		$snapshots = array();
		$index     = 0;
		foreach ( $itemsRaw as $envelope ) {
			$snapshots[] = $this->normalizeItemEnvelope( $envelope, $now, $index );
			++$index;
		}

		$page    = $this->extractInt( $payload, 'page', $query->page() );
		$perPage = $this->extractInt( $payload, 'hits', $query->perPage() );
		$total   = array_key_exists( 'count', $payload ) && is_int( $payload['count'] )
			? $payload['count']
			: null;
		$hasMore = $this->deriveHasMore( $payload, $snapshots );
		$rawMeta = $this->buildRawMeta( $payload );

		return new SearchResult(
			self::PROVIDER_KEY,
			$snapshots,
			$page,
			$perPage,
			$total,
			$hasMore,
			$rawMeta
		);
	}

	/**
	 * Decode the first item from a single-result lookup-via-search
	 * response into a {@see ProductSnapshot}, or return null when
	 * the payload's `Items` array is empty.
	 *
	 * Per §9.2 the lookup path issues a `?itemCode=...&hits=1`
	 * search; an empty `Items` array means the upstream confirmed
	 * no product matches the supplied id, which the provider
	 * layer translates into a {@see \Affilun\Domain\Provider\Exception\ProductNotFoundException}.
	 * Returning null from the normalizer rather than raising
	 * lets the provider layer compose its own exception with the
	 * looked-up id in context.
	 *
	 * @param mixed             $decoded JSON-decoded response body.
	 * @param DateTimeImmutable $now     Observation timestamp.
	 *
	 * @throws ProviderResponseException When the payload is not
	 *                                   shaped like an IchibaItem
	 *                                   Search response or when
	 *                                   the first item fails the
	 *                                   cross-provider snapshot
	 *                                   contract.
	 */
	public function normalizeFirstItem( $decoded, DateTimeImmutable $now ): ?ProductSnapshot {
		$payload  = $this->expectAssocArray( $decoded, 'response', null );
		$itemsRaw = $payload['Items'] ?? $payload['items'] ?? null;

		if ( ! is_array( $itemsRaw ) ) {
			throw $this->responseException(
				'Rakuten lookup response is missing the "Items" array.',
				array(
					'field'  => 'Items',
					'reason' => 'missing_items_array',
				)
			);
		}

		if ( $itemsRaw === array() ) {
			return null;
		}

		// Re-key to a positional list and take the first entry; the
		// upstream returns positional arrays already, but defensive
		// re-indexing keeps the normalizer robust against a future
		// drift to associative shapes.
		$ordered = array_values( $itemsRaw );

		return $this->normalizeItemEnvelope( $ordered[0], $now, 0 );
	}

	/**
	 * Decode a single `{Item: {...}}` envelope into a
	 * {@see ProductSnapshot}.
	 *
	 * Exposed primarily for callers that already have an envelope
	 * in hand (test fixtures, future bulk paths). Production
	 * provider code goes through {@see self::normalizeSearchResult()}
	 * or {@see self::normalizeFirstItem()}.
	 *
	 * @param mixed             $envelope JSON-decoded `{Item: {...}}` envelope.
	 * @param DateTimeImmutable $now      Observation timestamp.
	 * @param int               $index    Positional index for diagnostic context.
	 *
	 * @throws ProviderResponseException When the envelope is not
	 *                                   shaped correctly or when
	 *                                   the contained item fails
	 *                                   the cross-provider snapshot
	 *                                   contract.
	 */
	public function normalizeItemEnvelope(
		$envelope,
		DateTimeImmutable $now,
		int $index = 0
	): ProductSnapshot {
		$envelopeAssoc = $this->expectAssocArray( $envelope, 'Items entry', $index );

		$itemKey = array_key_exists( 'Item', $envelopeAssoc )
			? 'Item'
			: ( array_key_exists( 'item', $envelopeAssoc ) ? 'item' : null );

		if ( $itemKey === null ) {
			throw $this->responseException(
				sprintf(
					'Rakuten Items[%d] is missing the "Item" envelope.',
					$index
				),
				array(
					'field'  => 'Items[].Item',
					'index'  => $index,
					'reason' => 'missing_item_envelope',
				)
			);
		}

		$item = $this->expectAssocArray( $envelopeAssoc[ $itemKey ], 'Items[].Item', $index );

		return $this->buildSnapshot( $item, $now, $index );
	}

	/**
	 * Translate a single decoded `Item` map into a
	 * {@see ProductSnapshot}.
	 *
	 * @param array<string, mixed> $item  Decoded item map.
	 * @param DateTimeImmutable    $now   Observation timestamp.
	 * @param int                  $index Positional index for diagnostic context.
	 *
	 * @throws ProviderResponseException When any required field is
	 *                                   missing / mistyped, or
	 *                                   when the resulting snapshot
	 *                                   constructor rejects the
	 *                                   value.
	 */
	private function buildSnapshot(
		array $item,
		DateTimeImmutable $now,
		int $index
	): ProductSnapshot {
		$itemCode = $this->requireString( $item, 'itemCode', $index );
		$itemName = $this->requireString( $item, 'itemName', $index );
		$itemUrl  = $this->requireString( $item, 'itemUrl', $index );

		$itemPriceField = $item['itemPrice'] ?? null;
		if ( ! is_int( $itemPriceField ) && ! ( is_string( $itemPriceField ) && $itemPriceField !== '' ) ) {
			throw $this->responseException(
				sprintf(
					'Rakuten Items[%d].Item.itemPrice must be an integer or numeric string.',
					$index
				),
				array(
					'field'  => 'Items[].Item.itemPrice',
					'index'  => $index,
					'reason' => 'missing_or_invalid_price',
				)
			);
		}
		$itemPrice = is_int( $itemPriceField ) ? (string) $itemPriceField : $itemPriceField;

		$taxFlagField = $item['taxFlag'] ?? null;
		if ( ! is_int( $taxFlagField ) ) {
			throw $this->responseException(
				sprintf(
					'Rakuten Items[%d].Item.taxFlag must be an integer (0 or 1).',
					$index
				),
				array(
					'field'  => 'Items[].Item.taxFlag',
					'index'  => $index,
					'reason' => 'missing_or_invalid_tax_flag',
				)
			);
		}
		$isTaxIncluded = $taxFlagField === 1;

		$shopName     = $this->optionalString( $item, 'shopName' );
		$itemCaption  = $this->optionalString( $item, 'itemCaption' );
		$affiliateUrl = $this->optionalString( $item, 'affiliateUrl' );
		$rating       = $this->optionalRating( $item, 'reviewAverage', $index );
		$reviewCount  = $this->optionalReviewCount( $item, 'reviewCount', $index );

		$primaryImage  = null;
		$galleryImages = array();
		if ( array_key_exists( 'mediumImageUrls', $item ) && is_array( $item['mediumImageUrls'] ) ) {
			$mediumImageUrls = array_values( $item['mediumImageUrls'] );
			foreach ( $mediumImageUrls as $imageIndex => $imageEntry ) {
				$imageUrl = $this->extractImageUrl( $imageEntry, $index, $imageIndex );
				if ( $imageUrl === null ) {
					continue;
				}

				if ( $primaryImage === null ) {
					$primaryImage = $imageUrl;
					continue;
				}

				$galleryImages[] = $imageUrl;
			}
		}

		$availability = $this->deriveAvailability( $item );

		$categories = array();
		if ( array_key_exists( 'genreId', $item ) ) {
			$genreId = $item['genreId'];
			if ( is_int( $genreId ) ) {
				$categories[] = (string) $genreId;
			} elseif ( is_string( $genreId ) && $genreId !== '' ) {
				$categories[] = $genreId;
			}
		}

		try {
			$price = new PriceInfo(
				$itemPrice,
				self::CURRENCY,
				null,
				$now,
				$isTaxIncluded,
				false,
				null
			);
		} catch ( InvalidArgumentException $error ) {
			throw $this->responseException(
				sprintf(
					'Rakuten Items[%d].Item.itemPrice could not be normalized: %s',
					$index,
					$error->getMessage()
				),
				array(
					'field'  => 'Items[].Item.itemPrice',
					'index'  => $index,
					'reason' => 'price_normalization_failed',
				),
				$error
			);
		}

		try {
			return new ProductSnapshot(
				self::PROVIDER_KEY,
				new ProviderProductId( self::PROVIDER_KEY, $itemCode ),
				$itemName,
				$shopName,
				$itemCaption,
				$itemUrl,
				$affiliateUrl,
				$primaryImage,
				$galleryImages,
				$price,
				$availability,
				$rating,
				$reviewCount,
				$categories,
				array(),
				$now,
				$item
			);
		} catch ( InvalidArgumentException $error ) {
			throw $this->responseException(
				sprintf(
					'Rakuten Items[%d].Item could not be normalized: %s',
					$index,
					$error->getMessage()
				),
				array(
					'field'  => 'Items[].Item',
					'index'  => $index,
					'reason' => 'snapshot_normalization_failed',
				),
				$error
			);
		}
	}

	/**
	 * Translate the upstream's `availability` integer into an
	 * {@see AvailabilityInfo}.
	 *
	 * Per the Rakuten IchibaItem schema, `1` means "in stock" and
	 * `0` means "out of stock". Anything else (including a missing
	 * field) maps to {@see AvailabilityInfo::unknown()} per the
	 * §7.6 fallback.
	 *
	 * @param array<string, mixed> $item Decoded item map.
	 */
	private function deriveAvailability( array $item ): AvailabilityInfo {
		$availability = $item['availability'] ?? null;
		if ( $availability === 1 ) {
			return new AvailabilityInfo(
				AvailabilityInfo::STATUS_IN_STOCK,
				null,
				true
			);
		}
		if ( $availability === 0 ) {
			return new AvailabilityInfo(
				AvailabilityInfo::STATUS_OUT_OF_STOCK,
				null,
				false
			);
		}

		return AvailabilityInfo::unknown();
	}

	/**
	 * Synthesize {@see SearchResult::hasMore()} from the response
	 * envelope's pagination axes.
	 *
	 * Preferred derivation: `last < count` when both are integers
	 * — the upstream's `last` is the 1-based index of the last
	 * item on this page, and `count` is the cross-page total.
	 *
	 * Fallback derivation: when `count` is unknown, the
	 * normalizer reports "has more" when the page produced exactly
	 * `hits` items (matching what the request asked for). This
	 * mirrors the cross-provider §7.2 "cursor-style provider"
	 * convention.
	 *
	 * @param array<string, mixed>  $payload   Response envelope.
	 * @param list<ProductSnapshot> $snapshots Decoded snapshots.
	 */
	private function deriveHasMore( array $payload, array $snapshots ): bool {
		if (
			array_key_exists( 'last', $payload )
			&& is_int( $payload['last'] )
			&& array_key_exists( 'count', $payload )
			&& is_int( $payload['count'] )
		) {
			return $payload['last'] < $payload['count'];
		}

		if ( array_key_exists( 'hits', $payload ) && is_int( $payload['hits'] ) ) {
			return $payload['hits'] > 0 && count( $snapshots ) >= $payload['hits'];
		}

		return false;
	}

	/**
	 * Build the {@see SearchResult::rawMeta()} payload from the
	 * upstream's pagination axes.
	 *
	 * Carries `count` / `page` / `pageCount` / `first` / `last` /
	 * `hits` keys verbatim when present. Per §7.2 the rendering
	 * layer treats this map as opaque, so missing keys are
	 * deliberately not synthesized here.
	 *
	 * @param array<string, mixed> $payload Response envelope.
	 *
	 * @return array<string, mixed>
	 */
	private function buildRawMeta( array $payload ): array {
		$rawMeta = array();
		foreach ( array( 'count', 'page', 'pageCount', 'first', 'last', 'hits' ) as $field ) {
			if ( array_key_exists( $field, $payload ) ) {
				$rawMeta[ $field ] = $payload[ $field ];
			}
		}

		return $rawMeta;
	}

	/**
	 * Extract a URL from a `mediumImageUrls[i]` entry.
	 *
	 * The Rakuten endpoint shape varies between major versions:
	 * older responses wrap the URL in a `{ "imageUrl": "https://..." }`
	 * object, while newer responses expose the URL string
	 * directly. This helper accepts either shape and returns
	 * null when the entry is malformed beyond either form.
	 *
	 * @param mixed $entry      Raw image entry.
	 * @param int   $itemIndex  Positional index of the parent item.
	 * @param int   $imageIndex Positional index of this image entry.
	 *
	 * @throws ProviderResponseException When the entry's `imageUrl`
	 *                                   is present but not a string,
	 *                                   indicating contract drift
	 *                                   beyond the tolerated dual
	 *                                   shape.
	 */
	private function extractImageUrl( $entry, int $itemIndex, int $imageIndex ): ?string {
		if ( is_string( $entry ) ) {
			$trimmed = trim( $entry );
			return $trimmed === '' ? null : $trimmed;
		}

		if ( is_array( $entry ) && array_key_exists( 'imageUrl', $entry ) ) {
			$url = $entry['imageUrl'];
			if ( is_string( $url ) ) {
				$trimmed = trim( $url );
				return $trimmed === '' ? null : $trimmed;
			}

			throw $this->responseException(
				sprintf(
					'Rakuten Items[%d].Item.mediumImageUrls[%d].imageUrl must be a string.',
					$itemIndex,
					$imageIndex
				),
				array(
					'field'  => 'Items[].Item.mediumImageUrls[].imageUrl',
					'index'  => $itemIndex,
					'reason' => 'invalid_image_url',
				)
			);
		}

		return null;
	}

	/**
	 * Extract a required string field from an item map.
	 *
	 * Trims surrounding whitespace and rejects the result when the
	 * field is missing, not a string, or empty after trimming.
	 *
	 * @param array<string, mixed> $item  Decoded item map.
	 * @param string               $field Field name.
	 * @param int                  $index Positional index for diagnostic context.
	 *
	 * @throws ProviderResponseException When the field violates
	 *                                   the per-field rule.
	 */
	private function requireString( array $item, string $field, int $index ): string {
		if ( ! array_key_exists( $field, $item ) ) {
			throw $this->responseException(
				sprintf(
					'Rakuten Items[%d].Item.%s is missing.',
					$index,
					$field
				),
				array(
					'field'  => sprintf( 'Items[].Item.%s', $field ),
					'index'  => $index,
					'reason' => 'missing_required_field',
				)
			);
		}

		$value = $item[ $field ];
		if ( ! is_string( $value ) ) {
			throw $this->responseException(
				sprintf(
					'Rakuten Items[%d].Item.%s must be a string.',
					$index,
					$field
				),
				array(
					'field'  => sprintf( 'Items[].Item.%s', $field ),
					'index'  => $index,
					'reason' => 'invalid_field_type',
				)
			);
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			throw $this->responseException(
				sprintf(
					'Rakuten Items[%d].Item.%s must be a non-empty string.',
					$index,
					$field
				),
				array(
					'field'  => sprintf( 'Items[].Item.%s', $field ),
					'index'  => $index,
					'reason' => 'empty_required_field',
				)
			);
		}

		return $trimmed;
	}

	/**
	 * Extract an optional string field from an item map.
	 *
	 * Returns null when the field is missing, null, the empty
	 * string after trimming, or not a string at all. The
	 * "not a string" case deliberately does NOT raise here
	 * because the cross-provider snapshot contract treats every
	 * optional field as nullable; non-string values arrive only
	 * via contract drift on optional axes and degrading to null
	 * is preferable to failing the whole snapshot.
	 *
	 * @param array<string, mixed> $item  Decoded item map.
	 * @param string               $field Field name.
	 */
	private function optionalString( array $item, string $field ): ?string {
		if ( ! array_key_exists( $field, $item ) ) {
			return null;
		}

		$value = $item[ $field ];
		if ( ! is_string( $value ) ) {
			return null;
		}

		$trimmed = trim( $value );
		return $trimmed === '' ? null : $trimmed;
	}

	/**
	 * Extract the optional `reviewAverage` field as a decimal
	 * string suitable for {@see ProductSnapshot::rating()}.
	 *
	 * The upstream emits `reviewAverage` as a float (`4.5`); the
	 * cross-provider snapshot expects a decimal string, so this
	 * helper formats with `%g` to preserve the precision the
	 * upstream supplied without introducing trailing zeros.
	 *
	 * @param array<string, mixed> $item  Decoded item map.
	 * @param string               $field Field name.
	 * @param int                  $index Positional index for diagnostic context.
	 *
	 * @throws ProviderResponseException When the field is present
	 *                                   but not a numeric value.
	 */
	private function optionalRating( array $item, string $field, int $index ): ?string {
		if ( ! array_key_exists( $field, $item ) ) {
			return null;
		}

		$value = $item[ $field ];
		if ( $value === null ) {
			return null;
		}

		if ( is_int( $value ) ) {
			return (string) $value;
		}

		if ( is_float( $value ) ) {
			$formatted = sprintf( '%g', $value );
			return $formatted === '' ? null : $formatted;
		}

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

		throw $this->responseException(
			sprintf(
				'Rakuten Items[%d].Item.%s must be a number, numeric string, or null.',
				$index,
				$field
			),
			array(
				'field'  => sprintf( 'Items[].Item.%s', $field ),
				'index'  => $index,
				'reason' => 'invalid_rating',
			)
		);
	}

	/**
	 * Extract the optional `reviewCount` field as an integer
	 * suitable for {@see ProductSnapshot::reviewCount()}.
	 *
	 * @param array<string, mixed> $item  Decoded item map.
	 * @param string               $field Field name.
	 * @param int                  $index Positional index for diagnostic context.
	 *
	 * @throws ProviderResponseException When the field is present
	 *                                   but not an integer or
	 *                                   numeric string.
	 */
	private function optionalReviewCount( array $item, string $field, int $index ): ?int {
		if ( ! array_key_exists( $field, $item ) ) {
			return null;
		}

		$value = $item[ $field ];
		if ( $value === null ) {
			return null;
		}

		if ( is_int( $value ) ) {
			return $value < 0 ? null : $value;
		}

		if ( is_string( $value ) && ctype_digit( $value ) ) {
			return (int) $value;
		}

		throw $this->responseException(
			sprintf(
				'Rakuten Items[%d].Item.%s must be a non-negative integer.',
				$index,
				$field
			),
			array(
				'field'  => sprintf( 'Items[].Item.%s', $field ),
				'index'  => $index,
				'reason' => 'invalid_review_count',
			)
		);
	}

	/**
	 * Extract a positive integer pagination axis from the response
	 * envelope, falling back to the supplied default when the
	 * field is missing or invalid.
	 *
	 * @param array<string, mixed> $payload Response envelope.
	 * @param string               $field   Field name.
	 * @param int                  $fallback Fallback value.
	 */
	private function extractInt( array $payload, string $field, int $fallback ): int {
		if ( ! array_key_exists( $field, $payload ) ) {
			return $fallback;
		}

		$value = $payload[ $field ];
		if ( is_int( $value ) && $value >= 1 ) {
			return $value;
		}

		if ( is_string( $value ) && ctype_digit( $value ) && $value !== '' ) {
			$cast = (int) $value;
			if ( $cast >= 1 ) {
				return $cast;
			}
		}

		return $fallback;
	}

	/**
	 * Cast a `mixed` value to an `array<string, mixed>`, raising
	 * {@see ProviderResponseException} when the cast does not
	 * apply.
	 *
	 * Used at every shape-checking boundary so the response-shape
	 * failure mode lives at one location and carries consistent
	 * diagnostic context.
	 *
	 * @param mixed    $value Value under test.
	 * @param string   $label Diagnostic label for the failing field.
	 * @param int|null $index Positional index, or null for the
	 *                        top-level response.
	 *
	 * @return array<string, mixed>
	 *
	 * @throws ProviderResponseException When `$value` is not an
	 *                                   associative array.
	 */
	private function expectAssocArray( $value, string $label, ?int $index ): array {
		if ( ! is_array( $value ) ) {
			throw $this->responseException(
				sprintf(
					'Rakuten %s must be an associative array; got %s.',
					$label,
					gettype( $value )
				),
				array(
					'field'  => $label,
					'index'  => $index,
					'reason' => 'invalid_envelope_type',
				)
			);
		}

		return $value;
	}

	/**
	 * Construct a {@see ProviderResponseException} with this
	 * normalizer's provider key and the supplied diagnostic
	 * context.
	 *
	 * @param string               $message  Human-readable summary.
	 * @param array<string, mixed> $context  Structured context map.
	 * @param \Throwable|null      $previous Underlying cause, or null.
	 */
	private function responseException(
		string $message,
		array $context,
		?\Throwable $previous = null
	): ProviderResponseException {
		return new ProviderResponseException(
			self::PROVIDER_KEY,
			$message,
			$context,
			$previous
		);
	}
}
