<?php
/**
 * Amazon Creators API request factory.
 *
 * Phase B (KI-019, ADR-0022) collaborator that translates the
 * cross-provider {@see SearchQuery} / {@see ProviderProductId}
 * domain values into Amazon Creators API
 * `POST /catalog/v1/searchItems` / `POST /catalog/v1/getItems`
 * HTTP requests, complete with the OAuth 2.0 Bearer
 * authorization header, the per-credential `x-marketplace`
 * header, the lowerCamelCase JSON body shape Amazon documents
 * (https://affiliate-program.amazon.com/creatorsapi/docs/), and
 * the dot-notation `resources` list every catalog operation
 * accepts.
 *
 * Why this class exists:
 *
 *   - The Creators API request shape differs from the legacy
 *     PA-API 5.0 shape this codebase was originally built
 *     against in three orthogonal ways:
 *
 *       1. **Authentication.** Creators API uses an OAuth 2.0
 *          Bearer token in the `Authorization` header (with an
 *          optional `, Version <version>` suffix for v2.x
 *          credentials) instead of AWS SigV4 request signing.
 *          Token acquisition is delegated to
 *          {@see AmazonCreatorsApiTokenProvider}; this factory
 *          only stamps the resulting token onto the outgoing
 *          request.
 *
 *       2. **Naming convention.** Every request field is
 *          lowerCamelCase (`keywords`, `itemIds`, `partnerTag`,
 *          `marketplace`, `searchIndex`, `itemPage`, `itemCount`,
 *          `currencyOfPreference`, `languagesOfPreference`)
 *          rather than PA-API 5.0's PascalCase (`Keywords`,
 *          `ItemIds`, `PartnerTag`, ...). Resource names retain
 *          the same dot-notation form
 *          (`itemInfo.title`, `images.primary.medium`,
 *          `offersV2.listings.price`).
 *
 *       3. **Marketplace conveyance.** The marketplace must be
 *          carried both as the `x-marketplace` HTTP header AND
 *          as the `marketplace` field inside the JSON body. The
 *          two values must match; this factory writes both from
 *          the single
 *          {@see AmazonCreatorsApiCredentials::marketplace()}
 *          source so a divergence is structurally impossible.
 *
 *   - The factory is deliberately stateless across requests
 *     (the credentials and token provider are injected at
 *     construction, but each `buildSearchRequest()` /
 *     `buildLookupRequest()` call resolves a fresh access token
 *     so token rotation / cache expiry flows through
 *     transparently) and side-effect-free at the HTTP layer
 *     (it does not dispatch the request — that is the
 *     {@see AmazonCreatorsApiProvider}'s responsibility).
 *
 * Resource selection:
 *
 *   - The MVP resource list ({@see self::DEFAULT_SEARCH_RESOURCES}
 *     and {@see self::DEFAULT_LOOKUP_RESOURCES}) is the minimum
 *     set required to populate a {@see ProductSnapshot}: title,
 *     primary image (medium), brand (via byLineInfo), parent
 *     ASIN (for variation joins), and OffersV2 listings (price
 *     + availability). Any future resource expansion (additional
 *     image sizes, customer reviews, search refinements) lives
 *     as a separate constant set so this factory's defaults stay
 *     stable across slices.
 *
 *   - Resources are passed verbatim to the API in dot-notation,
 *     matching `provider-api-contracts.md` §9.1 and the Creators
 *     API SearchItems / GetItems references. The factory does
 *     NOT validate that every dot path is one Amazon documents;
 *     mistyped resources surface as either a 4xx from the API
 *     or as missing fields the response normalizer's defensive
 *     accessors handle.
 *
 * Pagination:
 *
 *   - Search: `itemCount` is bounded to 1..10 (the Creators API
 *     SearchItems hard limit per Amazon documentation), with
 *     `itemPage` mirroring the cross-provider
 *     {@see SearchQuery::page()}.
 *
 *   - Lookup (`getItems`): batched calls are NOT supported by
 *     this slice. The cross-provider
 *     {@see ItemLookupProviderInterface::getProduct()} contract
 *     resolves a single {@see ProviderProductId} at a time, so
 *     each `buildLookupRequest()` produces a single-element
 *     `itemIds` array. A future slice can add a
 *     `buildBatchLookupRequest()` entry point for the §13 sync
 *     worker.
 *
 * Failure semantics:
 *
 *   - Token-fetch failures bubble out unchanged from
 *     {@see AmazonCreatorsApiTokenProvider::accessToken()}.
 *     The provider exception hierarchy (§10.2) is the
 *     {@see AmazonCreatorsApiTokenProvider}'s responsibility;
 *     this factory simply lets the exceptions propagate so the
 *     calling provider's normal error path handles them.
 *
 *   - JSON encoding failures of the request body are wrapped in
 *     {@see ProviderRequestException}. They should be impossible
 *     in practice (the inputs are all simple scalars / lists of
 *     strings / lists of dot-paths), but the wrap keeps a future
 *     non-UTF-8 contamination from leaking as an uncaught
 *     {@see JsonException}.
 *
 * What this class does NOT do:
 *
 *   - It does not dispatch the HTTP request. The
 *     {@see HttpClient} is owned by {@see AmazonCreatorsApiProvider},
 *     which calls into this factory to build the request and
 *     then sends it itself.
 *
 *   - It does not normalize the response. That is
 *     {@see AmazonCreatorsApiResponseNormalizer}'s responsibility.
 *
 *   - It does not retry on token expiry. The token provider's
 *     own cache + invalidate flow handles that; if a catalog API
 *     call returns 401 the calling provider invokes
 *     {@see AmazonCreatorsApiTokenProvider::invalidate()} and
 *     retries via this factory once.
 *
 *   - It does not paginate batched lookups. See "Pagination"
 *     above.
 *
 * @package Affilun\Infrastructure\Provider\Amazon\CreatorsApi
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Provider\Amazon\CreatorsApi;

// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- This factory builds exception messages/context arrays and renders no HTML.

use Affilun\Domain\Http\HttpRequest;
use Affilun\Domain\Provider\Exception\ProviderRequestException;
use Affilun\Domain\Provider\ProviderProductId;
use Affilun\Domain\Search\SearchQuery;

/**
 * Build Amazon Creators API HTTP requests for SearchItems /
 * GetItems operations.
 *
 * See the file-level docblock for the lowerCamelCase JSON body
 * shape, the dual marketplace header + body field convention,
 * the v2.x version-suffix Authorization header rule, the MVP
 * resource selection rationale, and the failure-mapping policy
 * (token-fetch exceptions bubble out verbatim, JSON encoding
 * failures wrap as {@see ProviderRequestException}).
 */
final class AmazonCreatorsApiRequestFactory {

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

	/**
	 * Creators API base URL. Global across all regions; the
	 * region selection is encoded into the credential's `version`
	 * field and surfaces only at the
	 * {@see AmazonCreatorsApiTokenProvider}'s token-endpoint
	 * resolution.
	 *
	 * Hard-coded here per ADR-0022 §1.3 / §6.1: the base URL is
	 * not configurable through credentials.
	 */
	public const API_BASE_URL = 'https://creatorsapi.amazon';

	/**
	 * Path component for the SearchItems operation appended to
	 * {@see self::API_BASE_URL}.
	 */
	public const SEARCH_PATH = '/catalog/v1/searchItems';

	/**
	 * Path component for the GetItems operation appended to
	 * {@see self::API_BASE_URL}.
	 */
	public const LOOKUP_PATH = '/catalog/v1/getItems';

	/**
	 * Hard cap on `itemCount` Amazon enforces on SearchItems
	 * (per the Creators API SearchItems reference: 1..10).
	 *
	 * Held as a class constant so the test suite and any future
	 * diagnostics surface can reference the limit without
	 * duplicating the literal.
	 */
	public const SEARCH_MAX_ITEM_COUNT = 10;

	/**
	 * Hard cap on the number of items in a single GetItems
	 * request Amazon enforces (per the Creators API GetItems
	 * reference: 1..10). The MVP `buildLookupRequest()` only
	 * ever sends one item id per request, but the constant is
	 * exposed so a future batch-lookup factory can share the
	 * same source of truth.
	 */
	public const LOOKUP_MAX_ITEM_COUNT = 10;

	/**
	 * Per-call HTTP request timeout (seconds) for catalog API
	 * dispatches.
	 *
	 * The value is generous enough to absorb Amazon's documented
	 * p99 latency without leaving render paths hanging on a
	 * stalled upstream.
	 */
	public const CATALOG_REQUEST_TIMEOUT_SECONDS = 15;

	/**
	 * Default resource set for SearchItems requests.
	 *
	 * The list is the MVP minimum required to fill a
	 * {@see ProductSnapshot}:
	 *
	 *   - `itemInfo.title` — required for every snapshot.
	 *   - `itemInfo.byLineInfo` — brand / contributor name;
	 *     {@see AmazonCreatorsApiResponseNormalizer} extracts
	 *     the brand here.
	 *   - `images.primary.medium` — primary product image at the
	 *     medium size used by the §15 single-card template.
	 *   - `parentASIN` — populated when the item is a variation;
	 *     stored on the snapshot's `rawMeta` for later
	 *     `getVariations` follow-ups.
	 *   - `offersV2.listings.price` — buy-box price + currency
	 *     (replaces PA-API 5.0's deprecated `Offers.Listings.Price`).
	 *   - `offersV2.listings.availability` — in-stock /
	 *     out-of-stock status (Amazon returns `IN_STOCK` /
	 *     `OUT_OF_STOCK` / etc., normalized at the response
	 *     normalizer).
	 *
	 * @var list<string>
	 */
	public const DEFAULT_SEARCH_RESOURCES = array(
		'itemInfo.title',
		'itemInfo.byLineInfo',
		'images.primary.medium',
		'parentASIN',
		'offersV2.listings.price',
		'offersV2.listings.availability',
	);

	/**
	 * Default resource set for GetItems requests.
	 *
	 * Identical to {@see self::DEFAULT_SEARCH_RESOURCES} for the
	 * MVP — a successful sync of a single product needs the same
	 * fields the search list view needs. Held as a separate
	 * constant so a future slice can extend the lookup payload
	 * (additional image sizes, customer reviews, technical info)
	 * without disturbing the search defaults.
	 *
	 * @var list<string>
	 */
	public const DEFAULT_LOOKUP_RESOURCES = array(
		'itemInfo.title',
		'itemInfo.byLineInfo',
		'images.primary.medium',
		'parentASIN',
		'offersV2.listings.price',
		'offersV2.listings.availability',
	);

	/**
	 * Resource set used by the daily discounted-product discovery.
	 *
	 * @var list<string>
	 */
	public const DISCOUNT_SEARCH_RESOURCES = array(
		'itemInfo.title',
		'itemInfo.byLineInfo',
		'images.primary.medium',
		'offersV2.listings.price',
		'offersV2.listings.availability',
		'offersV2.listings.dealDetails',
		'offersV2.listings.isBuyBoxWinner',
	);

	/**
	 * Long-lived credentials this factory authenticates with.
	 *
	 * Held by reference so a credential rotation that produces a
	 * fresh {@see AmazonCreatorsApiCredentials} instance only
	 * affects requests built after the rotation; in-flight
	 * requests continue with the credential snapshot they were
	 * built against. The credentials VO is itself immutable, so
	 * holding it by reference is safe.
	 */
	private AmazonCreatorsApiCredentials $credentials;

	/**
	 * Token provider that resolves the OAuth 2.0 Bearer token
	 * for {@see self::$credentials}.
	 *
	 * Each `buildSearchRequest()` / `buildLookupRequest()` call
	 * fetches a token via this provider. The provider's own
	 * cache (in-memory + optional external cache, 1-hour TTL
	 * minus a 30-second safety margin) ensures that we do not
	 * hit the Amazon Cognito / LwA token endpoint on every
	 * catalog call.
	 */
	private AmazonCreatorsApiTokenProvider $tokenProvider;

	/**
	 * Construct the request factory with explicit collaborator
	 * injection.
	 *
	 * @param AmazonCreatorsApiCredentials   $credentials   Long-lived
	 *                                                      credentials
	 *                                                      this factory
	 *                                                      authenticates
	 *                                                      with.
	 * @param AmazonCreatorsApiTokenProvider $tokenProvider Token
	 *                                                      provider
	 *                                                      that
	 *                                                      resolves the
	 *                                                      Bearer
	 *                                                      token for
	 *                                                      `$credentials`.
	 */
	public function __construct(
		AmazonCreatorsApiCredentials $credentials,
		AmazonCreatorsApiTokenProvider $tokenProvider
	) {
		$this->credentials   = $credentials;
		$this->tokenProvider = $tokenProvider;
	}

	/**
	 * Build a `POST /catalog/v1/searchItems` HTTP request from
	 * the cross-provider {@see SearchQuery}.
	 *
	 * Resolves a fresh access token via the injected token
	 * provider, composes the lowerCamelCase JSON body (keywords,
	 * itemPage, itemCount, partnerTag, marketplace, resources,
	 * plus optional currencyOfPreference / languagesOfPreference
	 * from the query's locale / currency fields), and returns an
	 * {@see HttpRequest} ready for dispatch by the calling
	 * provider.
	 *
	 * `itemCount` is clamped to the Creators API hard limit
	 * ({@see self::SEARCH_MAX_ITEM_COUNT}) so a domain-level
	 * `perPage` greater than 10 silently degrades to 10 rather
	 * than producing a 4xx from the API. The
	 * {@see SearchQuery}'s own constructor already enforces
	 * `perPage >= 1`, so the lower bound needs no defensive code
	 * here.
	 *
	 * @param SearchQuery $query Cross-provider search query.
	 *
	 * @throws ProviderRequestException When the JSON body cannot
	 *                                  be encoded (should not
	 *                                  happen in practice; see
	 *                                  the file-level docblock).
	 */
	public function buildSearchRequest( SearchQuery $query ): HttpRequest {
		$body = array(
			'keywords'    => $query->keyword(),
			'itemPage'    => $query->page(),
			'itemCount'   => min( $query->perPage(), self::SEARCH_MAX_ITEM_COUNT ),
			'partnerTag'  => $this->credentials->partnerTag(),
			'marketplace' => $this->credentials->marketplace(),
			'resources'   => self::DEFAULT_SEARCH_RESOURCES,
		);

		$currency = $query->currency();
		if ( $currency !== null ) {
			$body['currencyOfPreference'] = $currency;
		}

		$locale = $query->locale();
		if ( $locale !== null ) {
			$body['languagesOfPreference'] = array( self::languagePreferenceForAmazon( $locale ) );
		}

		return $this->buildPostRequest(
			self::API_BASE_URL . self::SEARCH_PATH,
			$body
		);
	}

	/**
	 * Build a category or brand discount SearchItems request.
	 *
	 * @param string      $searchIndex      Amazon SearchIndex.
	 * @param int         $minSavingPercent Minimum saving percentage.
	 * @param int         $page             One-based result page.
	 * @param int         $itemCount        Items requested per page.
	 * @param string|null $brand       Optional Amazon brand filter.
	 */
	public function buildDiscountSearchRequest(
		string $searchIndex,
		int $minSavingPercent,
		int $page,
		int $itemCount,
		?string $brand = null
	): HttpRequest {
		$normalizedIndex = trim( $searchIndex );
		if ( preg_match( '/^[A-Za-z][A-Za-z0-9]{0,79}$/', $normalizedIndex ) !== 1 ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				'Amazon discount searchIndex is invalid.',
				array( 'feature' => 'discount_search' )
			);
		}
		if ( $minSavingPercent < 1 || $minSavingPercent > 99 ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				'Amazon discount minSavingPercent must be between 1 and 99.',
				array( 'feature' => 'discount_search' )
			);
		}
		if ( $page < 1 || $page > 10 ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				'Amazon discount itemPage must be between 1 and 10.',
				array( 'feature' => 'discount_search' )
			);
		}
		if ( $itemCount < 1 || $itemCount > self::SEARCH_MAX_ITEM_COUNT ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				'Amazon discount itemCount must be between 1 and 10.',
				array( 'feature' => 'discount_search' )
			);
		}
		$normalizedBrand = $brand === null ? null : trim( $brand );
		$brandLength     = $normalizedBrand !== null && function_exists( 'mb_strlen' )
			? mb_strlen( $normalizedBrand, 'UTF-8' )
			: strlen( $normalizedBrand ?? '' );
		if (
			$normalizedBrand !== null
			&& ( $normalizedBrand === '' || $brandLength > 191 )
		) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				'Amazon discount brand is invalid.',
				array( 'feature' => 'discount_search' )
			);
		}

		$body = array(
			'searchIndex'           => $normalizedIndex,
			'minSavingPercent'      => $minSavingPercent,
			'itemPage'              => $page,
			'itemCount'             => $itemCount,
			'partnerTag'            => $this->credentials->partnerTag(),
			'marketplace'           => $this->credentials->marketplace(),
			'languagesOfPreference' => array( 'ja_JP' ),
			'currencyOfPreference'  => 'JPY',
			'resources'             => self::DISCOUNT_SEARCH_RESOURCES,
		);
		if ( $normalizedBrand !== null ) {
			$body['brand'] = $normalizedBrand;
		}

		return $this->buildPostRequest( self::API_BASE_URL . self::SEARCH_PATH, $body );
	}

	/**
	 * Convert a cross-provider BCP-47 locale into Amazon's underscore form.
	 *
	 * @param string $locale Cross-provider locale, e.g. ja-JP.
	 */
	private static function languagePreferenceForAmazon( string $locale ): string {
		return str_replace( '-', '_', trim( $locale ) );
	}

	/**
	 * Build a `POST /catalog/v1/getItems` HTTP request for a
	 * single {@see ProviderProductId}.
	 *
	 * The Creators API GetItems operation accepts a list of
	 * `itemIds` (max {@see self::LOOKUP_MAX_ITEM_COUNT}); the
	 * MVP single-product lookup contract resolves one id per
	 * call, so the list always carries exactly one element.
	 *
	 * The `itemIdType` is hard-coded to `ASIN` because every
	 * Amazon item id this codebase persists is an ASIN per
	 * `provider-api-contracts.md` §9.1. A future slice that
	 * supports lookup by SKU / EAN / UPC can extend
	 * {@see ProviderProductId} with a discriminator field and
	 * branch here.
	 *
	 * @param ProviderProductId $productId Provider-scoped product
	 *                                     identifier (must be an
	 *                                     Amazon ASIN).
	 *
	 * @throws ProviderRequestException When the JSON body cannot
	 *                                  be encoded.
	 */
	public function buildLookupRequest( ProviderProductId $productId ): HttpRequest {
		$body = array(
			'itemIds'     => array( $productId->value() ),
			'itemIdType'  => 'ASIN',
			'partnerTag'  => $this->credentials->partnerTag(),
			'marketplace' => $this->credentials->marketplace(),
			'resources'   => self::DEFAULT_LOOKUP_RESOURCES,
		);

		return $this->buildPostRequest(
			self::API_BASE_URL . self::LOOKUP_PATH,
			$body
		);
	}

	/**
	 * Compose the canonical `Authorization` header value for the
	 * current credentials.
	 *
	 * For v2.x credentials, Amazon requires the version suffix:
	 *
	 *   `Bearer <access_token>, Version <version>`
	 *
	 * For v3.x credentials, the bare form is used:
	 *
	 *   `Bearer <access_token>`
	 *
	 * The branching is centralized here (driven by
	 * {@see AmazonCreatorsApiCredentials::requiresVersionInAuthorizationHeader()})
	 * so a future credential version that introduces a new
	 * suffix shape only needs to update the credentials VO + this
	 * helper.
	 *
	 * @param string $accessToken Freshly-resolved Bearer token
	 *                            from the token provider.
	 */
	private function buildAuthorizationHeader( string $accessToken ): string {
		if ( $this->credentials->requiresVersionInAuthorizationHeader() ) {
			return sprintf(
				'Bearer %s, Version %s',
				$accessToken,
				$this->credentials->version()
			);
		}

		return 'Bearer ' . $accessToken;
	}

	/**
	 * Compose the common HTTP request shape for both
	 * SearchItems and GetItems POST calls.
	 *
	 * Resolves the access token (via the injected token
	 * provider), JSON-encodes the supplied body (with
	 * {@see JSON_THROW_ON_ERROR} so encoding failures surface
	 * loudly rather than silently producing `false`), and
	 * stamps the canonical Creators API headers:
	 *
	 *   - `Authorization` with the version-aware Bearer suffix.
	 *   - `Content-Type: application/json`.
	 *   - `Accept: application/json`.
	 *   - `x-marketplace` matching the credentials' marketplace.
	 *
	 * @param string               $url  Absolute
	 *                                   target URL.
	 * @param array<string, mixed> $body JSON-encodable
	 *                                   body map (already
	 *                                   in lowerCamelCase
	 *                                   Creators API form).
	 *
	 * @throws ProviderRequestException When the JSON body cannot
	 *                                  be encoded.
	 */
	private function buildPostRequest( string $url, array $body ): HttpRequest {
		$accessToken = $this->tokenProvider->accessToken( $this->credentials );

		$encoded = wp_json_encode(
			$body,
			JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE
		);
		if ( ! is_string( $encoded ) ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				'Amazon Creators API request body could not be JSON-encoded.',
				array(
					'endpoint' => $url,
				)
			);
		}

		return new HttpRequest(
			HttpRequest::METHOD_POST,
			$url,
			array(
				'Authorization' => $this->buildAuthorizationHeader( $accessToken ),
				'Content-Type'  => 'application/json',
				'Accept'        => 'application/json',
				'x-marketplace' => $this->credentials->marketplace(),
			),
			array(),
			$encoded,
			self::CATALOG_REQUEST_TIMEOUT_SECONDS
		);
	}
}
