<?php
/**
 * Yahoo!ショッピング response normalizer.
 *
 * Translates raw Yahoo!ショッピング商品検索 v3 response payloads
 * (decoded from the JSON envelope `wp_remote_*` returns) into
 * cross-provider {@see ProductSnapshot} / {@see SearchResult}
 * domain values per `specifications/provider-api-contracts.md` §9.3
 * and §7.4 / §7.5 / §7.6 / §7.2.
 *
 * Design notes:
 *
 *   - The normalizer accepts the already-decoded associative-array
 *     form of the response. Yahoo's商品検索 v3 envelope shape is:
 *
 *       {
 *         "totalResultsAvailable": int,
 *         "totalResultsReturned":  int,
 *         "firstResultPosition":   int,
 *         "hits": [ { "name": ..., "code": ..., ... }, ... ],
 *         "request": { ... }
 *       }
 *
 *   - Per-hit field mapping:
 *
 *       * `hits[].name`        → {@see ProductSnapshot::title()}
 *       * `hits[].description` → {@see ProductSnapshot::description()}
 *       * `hits[].url`         → {@see ProductSnapshot::detailUrl()}
 *       * `hits[].image.medium` (with fallback to `hits[].image.small`)
 *                              → {@see ProductSnapshot::primaryImage()}
 *       * `hits[].price`       → {@see PriceInfo::amount()} (JPY)
 *       * `hits[].inStock`     → {@see AvailabilityInfo}
 *       * `hits[].code`        → {@see ProviderProductId::value()}
 *       * `hits[].brand.name`  → {@see ProductSnapshot::brand()}
 *                                (with fallback to `hits[].seller.name`)
 *
 *   - Currency is always JPY for Yahoo!ショッピング's商品検索 v3
 *     results. The normalizer hard-codes the value rather than
 *     synthesizing it from a missing response field.
 *
 *   - Yahoo's商品検索 v3 response does not commit a server-side
 *     `taxIncluded` flag; the published prices are tax-included
 *     in the consumer-facing case. The normalizer encodes
 *     `isTaxIncluded = true` on the produced {@see PriceInfo}.
 *
 *   - {@see PriceInfo::observedAt()} and
 *     {@see ProductSnapshot::fetchedAt()} both come from a
 *     supplied "now" timestamp rather than from the upstream;
 *     synthesizing one from the local clock at the normalizer
 *     boundary keeps every snapshot from a single fetch carrying
 *     the same `fetchedAt` value.
 *
 *   - `inStock` semantics: the upstream emits a boolean. The
 *     normalizer maps:
 *
 *       * `true`  → {@see AvailabilityInfo::STATUS_IN_STOCK} with
 *                   `isPurchasable = true`.
 *       * `false` → {@see AvailabilityInfo::STATUS_OUT_OF_STOCK}
 *                   with `isPurchasable = false`.
 *       * Anything else (missing field) →
 *                   {@see AvailabilityInfo::unknown()}.
 *
 *   - {@see SearchResult::total()} ← `totalResultsAvailable`.
 *     Page / perPage are reflected from the original
 *     {@see SearchQuery} because Yahoo's response uses
 *     `firstResultPosition` (1-based start offset) rather than a
 *     1-based page number; the normalizer does not re-derive
 *     page from start because the `query.perPage()` axis is the
 *     authoritative size signal.
 *
 *   - {@see SearchResult::hasMore()} is derived from
 *     `firstResultPosition + totalResultsReturned - 1 <
 *     totalResultsAvailable`, falling back to
 *     `count($snapshots) === query.perPage()` when the position
 *     axes are missing.
 *
 *   - {@see SearchResult::rawMeta()} carries the upstream's
 *     pagination axes verbatim under
 *     `totalResultsAvailable` / `totalResultsReturned` /
 *     `firstResultPosition` 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 reach the
 *     {@see YahooShoppingErrorMapper} and never make it here.
 *
 *   - Response-shape failures 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 never carries credential material
 *     into exception messages or context maps.
 *
 * @package Affilun\Infrastructure\Provider\YahooShopping
 */

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.
//
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped

namespace Affilun\Infrastructure\Provider\YahooShopping;

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;

/**
 * Translates Yahoo!ショッピング商品検索 v3 response payloads into
 * cross-provider domain values.
 */
final class YahooShoppingResponseNormalizer {

	/**
	 * §4 stable provider key for Yahoo!ショッピング per §4.1.
	 */
	public const PROVIDER_KEY = 'yahoo_shopping';

	/**
	 * ISO 4217 currency code Yahoo!ショッピング's商品検索 v3
	 * always returns prices in. Hard-coded because the upstream
	 * does not echo the value in the response.
	 */
	public const CURRENCY = 'JPY';

	/**
	 * Decode a search-shaped response payload into a
	 * {@see SearchResult}.
	 *
	 * @param SearchQuery       $query      Original cross-provider query.
	 * @param mixed             $decoded    JSON-decoded response body.
	 * @param DateTimeImmutable $observedAt Observation timestamp.
	 *
	 * @throws ProviderResponseException When the payload is not
	 *                                   shaped like a商品検索 v3
	 *                                   response or when any
	 *                                   contained hit fails the
	 *                                   cross-provider snapshot
	 *                                   contract.
	 */
	public function normalizeSearchResult(
		SearchQuery $query,
		$decoded,
		DateTimeImmutable $observedAt
	): SearchResult {
		$payload = $this->expectAssocArray( $decoded, 'response', null );

		$hitsRaw = $payload['hits'] ?? null;
		if ( ! is_array( $hitsRaw ) ) {
			throw $this->responseException(
				'Yahoo!ショッピング search response is missing the "hits" array.',
				array(
					'field'  => 'hits',
					'reason' => 'missing_hits_array',
				)
			);
		}

		$snapshots = array();
		$index     = 0;
		foreach ( array_values( $hitsRaw ) as $hit ) {
			$snapshots[] = $this->buildSnapshot( $hit, $observedAt, $index );
			++$index;
		}

		$total = null;
		if (
			array_key_exists( 'totalResultsAvailable', $payload )
			&& is_int( $payload['totalResultsAvailable'] )
			&& $payload['totalResultsAvailable'] >= 0
		) {
			$total = $payload['totalResultsAvailable'];
		}

		$hasMore = $this->deriveHasMore( $payload, $snapshots, $query );
		$rawMeta = $this->buildRawMeta( $payload );

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

	/**
	 * Decode the exact matching hit from a lookup-via-search response into a
	 * {@see ProductSnapshot}, raising
	 * {@see ProductNotFoundException} when the payload's `hits`
	 * array is empty.
	 *
	 * Per §9.3 the lookup path issues a seller-scoped search and filters the
	 * result set by exact `hits[].code` match. An empty `hits` array, or a
	 * non-empty array without the expected code, means the upstream did not
	 * return the requested product.
	 *
	 * @param mixed             $decoded    JSON-decoded response body.
	 * @param ProviderProductId $expectedId Expected identifier.
	 * @param DateTimeImmutable $observedAt Observation timestamp.
	 *
	 * @throws ProviderResponseException|ProductNotFoundException When
	 *         the payload is not shaped like a商品検索 v3 response, when
	 *         the exact matching hit fails the cross-provider snapshot
	 *         contract, or when the response carries no exact matching hit.
	 */
	public function normalizeProduct(
		$decoded,
		ProviderProductId $expectedId,
		DateTimeImmutable $observedAt
	): ProductSnapshot {
		$payload = $this->expectAssocArray( $decoded, 'response', null );

		$hitsRaw = $payload['hits'] ?? null;
		if ( ! is_array( $hitsRaw ) ) {
			throw $this->responseException(
				'Yahoo!ショッピング lookup response is missing the "hits" array.',
				array(
					'field'  => 'hits',
					'reason' => 'missing_hits_array',
				)
			);
		}

		if ( $hitsRaw === array() ) {
			throw new ProductNotFoundException(
				self::PROVIDER_KEY,
				sprintf(
					'Yahoo!ショッピング lookup did not match any product for "%s".',
					$expectedId->value()
				),
				array(
					'provider_product_id' => $expectedId->value(),
					'feature'             => 'lookup',
				)
			);
		}

		foreach ( array_values( $hitsRaw ) as $index => $hit ) {
			if ( ! is_array( $hit ) ) {
				continue;
			}
			$code = $hit['code'] ?? null;
			if ( is_string( $code ) && $code === $expectedId->value() ) {
				return $this->buildSnapshot( $hit, $observedAt, $index );
			}
		}

		throw new ProductNotFoundException(
			self::PROVIDER_KEY,
			sprintf(
				'Yahoo!ショッピング lookup did not match any product for "%s".',
				$expectedId->value()
			),
			array(
				'provider_product_id' => $expectedId->value(),
				'feature'             => 'lookup',
			)
		);
	}

	/**
	 * Translate a single decoded hit map into a
	 * {@see ProductSnapshot}.
	 *
	 * @param mixed             $hit        Decoded hit entry.
	 * @param DateTimeImmutable $observedAt 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(
		$hit,
		DateTimeImmutable $observedAt,
		int $index
	): ProductSnapshot {
		$item = $this->expectAssocArray( $hit, 'hits[]', $index );

		$code   = $this->requireString( $item, 'code', $index );
		$name   = $this->requireString( $item, 'name', $index );
		$detail = $this->requireString( $item, 'url', $index );

		$priceField = $item['price'] ?? null;
		if ( ! is_int( $priceField ) && ! ( is_string( $priceField ) && $priceField !== '' ) ) {
			throw $this->responseException(
				sprintf(
					'Yahoo!ショッピング hits[%d].price must be an integer or numeric string.',
					$index
				),
				array(
					'field'  => 'hits[].price',
					'index'  => $index,
					'reason' => 'missing_or_invalid_price',
				)
			);
		}
		$priceAmount = is_int( $priceField ) ? (string) $priceField : $priceField;

		$description  = $this->optionalString( $item, 'description' );
		$primaryImage = $this->extractImageUrl( $item );
		$brand        = $this->extractBrandName( $item );
		$availability = $this->deriveAvailability( $item );

		try {
			$price = new PriceInfo(
				$priceAmount,
				self::CURRENCY,
				null,
				$observedAt,
				true,
				false,
				null
			);
		} catch ( InvalidArgumentException $error ) {
			throw $this->responseException(
				sprintf(
					'Yahoo!ショッピング hits[%d].price could not be normalized: %s',
					$index,
					$error->getMessage()
				),
				array(
					'field'  => 'hits[].price',
					'index'  => $index,
					'reason' => 'price_normalization_failed',
				),
				$error
			);
		}

		try {
			return new ProductSnapshot(
				self::PROVIDER_KEY,
				new ProviderProductId( self::PROVIDER_KEY, $code ),
				$name,
				$brand,
				$description,
				$detail,
				null,
				$primaryImage,
				array(),
				$price,
				$availability,
				null,
				null,
				array(),
				array(),
				$observedAt,
				$item
			);
		} catch ( InvalidArgumentException $error ) {
			throw $this->responseException(
				sprintf(
					'Yahoo!ショッピング hits[%d] could not be normalized: %s',
					$index,
					$error->getMessage()
				),
				array(
					'field'  => 'hits[]',
					'index'  => $index,
					'reason' => 'snapshot_normalization_failed',
				),
				$error
			);
		}
	}

	/**
	 * Translate the upstream's `inStock` boolean into an
	 * {@see AvailabilityInfo}. Missing / non-boolean values
	 * degrade to {@see AvailabilityInfo::unknown()}.
	 *
	 * @param array<string, mixed> $item Decoded hit map.
	 */
	private function deriveAvailability( array $item ): AvailabilityInfo {
		$inStock = $item['inStock'] ?? null;
		if ( $inStock === true ) {
			return new AvailabilityInfo(
				AvailabilityInfo::STATUS_IN_STOCK,
				null,
				true
			);
		}
		if ( $inStock === false ) {
			return new AvailabilityInfo(
				AvailabilityInfo::STATUS_OUT_OF_STOCK,
				null,
				false
			);
		}

		return AvailabilityInfo::unknown();
	}

	/**
	 * Synthesize {@see SearchResult::hasMore()} from the
	 * upstream's pagination axes.
	 *
	 * Preferred derivation: `firstResultPosition +
	 * totalResultsReturned - 1 < totalResultsAvailable` when all
	 * three axes are present integers.
	 *
	 * Fallback derivation: when the position axes are missing,
	 * the normalizer reports "has more" when the page produced
	 * exactly `query.perPage()` items.
	 *
	 * @param array<string, mixed>  $payload   Response envelope.
	 * @param list<ProductSnapshot> $snapshots Decoded snapshots.
	 * @param SearchQuery           $query     Original query.
	 */
	private function deriveHasMore(
		array $payload,
		array $snapshots,
		SearchQuery $query
	): bool {
		$first    = $payload['firstResultPosition'] ?? null;
		$returned = $payload['totalResultsReturned'] ?? null;
		$total    = $payload['totalResultsAvailable'] ?? null;

		if ( is_int( $first ) && is_int( $returned ) && is_int( $total ) ) {
			return ( $first + $returned - 1 ) < $total;
		}

		return $query->perPage() > 0 && count( $snapshots ) >= $query->perPage();
	}

	/**
	 * Build the {@see SearchResult::rawMeta()} payload from the
	 * upstream's pagination axes.
	 *
	 * @param array<string, mixed> $payload Response envelope.
	 *
	 * @return array<string, mixed>
	 */
	private function buildRawMeta( array $payload ): array {
		$rawMeta = array();
		foreach (
			array(
				'totalResultsAvailable',
				'totalResultsReturned',
				'firstResultPosition',
			) as $field
		) {
			if ( array_key_exists( $field, $payload ) ) {
				$rawMeta[ $field ] = $payload[ $field ];
			}
		}

		return $rawMeta;
	}

	/**
	 * Extract the primary image URL from a hit map, preferring
	 * `image.medium` and falling back to `image.small`.
	 *
	 * Returns null when neither axis is present as a non-empty
	 * string. The cross-provider snapshot constructor enforces
	 * the http(s) scheme guard, so a malformed URL surfaces as a
	 * snapshot-construction failure.
	 *
	 * @param array<string, mixed> $item Decoded hit map.
	 */
	private function extractImageUrl( array $item ): ?string {
		$image = $item['image'] ?? null;
		if ( ! is_array( $image ) ) {
			return null;
		}

		foreach ( array( 'medium', 'small' ) as $size ) {
			if ( ! array_key_exists( $size, $image ) ) {
				continue;
			}

			$value = $image[ $size ];
			if ( ! is_string( $value ) ) {
				continue;
			}

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

		return null;
	}

	/**
	 * Extract the brand name from a hit map, preferring
	 * `brand.name` and falling back to `seller.name`.
	 *
	 * @param array<string, mixed> $item Decoded hit map.
	 */
	private function extractBrandName( array $item ): ?string {
		foreach ( array( 'brand', 'seller' ) as $key ) {
			if ( ! array_key_exists( $key, $item ) ) {
				continue;
			}

			$entry = $item[ $key ];
			if ( ! is_array( $entry ) ) {
				continue;
			}

			$name = $entry['name'] ?? null;
			if ( ! is_string( $name ) ) {
				continue;
			}

			$trimmed = trim( $name );
			if ( $trimmed !== '' ) {
				return $trimmed;
			}
		}

		return null;
	}

	/**
	 * Extract a required string field from a hit map.
	 *
	 * @param array<string, mixed> $item  Decoded hit 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(
					'Yahoo!ショッピング hits[%d].%s is missing.',
					$index,
					$field
				),
				array(
					'field'  => sprintf( 'hits[].%s', $field ),
					'index'  => $index,
					'reason' => 'missing_required_field',
				)
			);
		}

		$value = $item[ $field ];
		if ( ! is_string( $value ) ) {
			throw $this->responseException(
				sprintf(
					'Yahoo!ショッピング hits[%d].%s must be a string.',
					$index,
					$field
				),
				array(
					'field'  => sprintf( 'hits[].%s', $field ),
					'index'  => $index,
					'reason' => 'invalid_field_type',
				)
			);
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			throw $this->responseException(
				sprintf(
					'Yahoo!ショッピング hits[%d].%s must be a non-empty string.',
					$index,
					$field
				),
				array(
					'field'  => sprintf( 'hits[].%s', $field ),
					'index'  => $index,
					'reason' => 'empty_required_field',
				)
			);
		}

		return $trimmed;
	}

	/**
	 * Extract an optional string field from a hit map.
	 *
	 * Returns null when the field is missing, null, the empty
	 * string after trimming, or not a string at all. Non-string
	 * values degrade to null because the cross-provider snapshot
	 * contract treats every optional field as nullable.
	 *
	 * @param array<string, mixed> $item  Decoded hit 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;
	}

	/**
	 * Cast a `mixed` value to an `array<string, mixed>`, raising
	 * {@see ProviderResponseException} when the cast does not
	 * apply.
	 *
	 * @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(
					'Yahoo!ショッピング %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
		);
	}
}
