<?php
/**
 * Yahoo!ショッピング request factory.
 *
 * Translates the cross-provider {@see SearchQuery} and
 * {@see ProviderProductId} domain values into concrete
 * {@see HttpRequest} instances targeting the Yahoo!ショッピング
 * 商品検索 v3 endpoint per `specifications/provider-api-contracts.md`
 * §9.3.
 *
 * Design notes:
 *
 *   - Both the keyword search and the single-item lookup paths
 *     hit the same upstream endpoint. Yahoo's商品検索 v3 does not
 *     expose a dedicated single-item detail surface; the canonical
 *     "lookup by id" pattern is to issue an item search filtered
 *     by `itemcode={value}` and consume the first hit. §9.3
 *     "任意対応 — 単品取得が弱い場合は検索結果ベース補完でもよい"
 *     allows exactly this. The response normalizer decodes both
 *     paths through the same hits[] mapping.
 *
 *   - Authentication is via the `appid` query parameter carrying
 *     the {@see YahooShoppingCredentials::clientId()} value. The
 *     factory stamps this on every request from the credentials
 *     held at construction time and never accepts ad-hoc
 *     overrides per call.
 *
 *   - Affiliate parameters: when
 *     {@see YahooShoppingCredentials::hasAffiliateSid()} is true,
 *     the factory sets `affiliate_type=yahoo` plus
 *     `affiliate_id={sid}` so the API echoes affiliate-aware
 *     fields on the response. When the sid is not configured
 *     these parameters are elided.
 *
 *     **Spec uncertainty.** §9.3 names `clientId`, `affiliateSid`,
 *     and `affiliatePid` as required AffiliateContext fields but
 *     does not commit a single canonical wire shape for the
 *     affiliate parameters on the商品検索 v3 request. Public Yahoo
 *     documentation describes `affiliate_type=yahoo` and
 *     `affiliate_id` as the search-side affiliate hint; the Value
 *     Commerce `pid` is consumed downstream by
 *     {@see YahooShoppingAffiliateLinkBuilder} when minting the
 *     redirect URL, not by this factory. The wire shape chosen
 *     here is "search request carries sid only; pid is used at
 *     link-build time." A future integration test may refine
 *     this; the structure is set up so a single change to
 *     {@see self::baseParameters()} can adjust the on-wire
 *     parameter set without touching the search / lookup paths.
 *
 *   - Per §9.3 the per-page upper bound is 50 (matching the
 *     upstream's own ceiling). The factory enforces this at the
 *     request-construction boundary so a higher value coming
 *     through the cross-provider {@see SearchQuery} surfaces as
 *     a {@see ProviderRequestException} from this layer rather
 *     than reaching the upstream and producing an opaque 400.
 *
 *   - Sort tokens map from CLB's vocabulary (`relevance` /
 *     `price_asc` / `price_desc`) to Yahoo's wire-level tokens
 *     (`-score` / `+price` / `-price`). `-score` is the
 *     upstream's own default ordering; the factory still emits
 *     it explicitly for `relevance` so the URL is reproducible
 *     across versions and so a future change to Yahoo's default
 *     does not silently shift CLB's "relevance" semantics.
 *
 *   - Pagination: §9.3's商品検索 v3 uses `start` (1-based
 *     positional offset of the first hit) plus `results` (page
 *     size). The factory translates the cross-provider `page` /
 *     `perPage` axes by `start = (page - 1) * perPage + 1`.
 *
 *   - Optional cross-provider {@see SearchQuery} fields the
 *     upstream does not have a stable equivalent for
 *     (`category`, `locale`, `currency`, `context`, `filters`)
 *     are deliberately ignored here, matching the
 *     {@see \CommerceLinkBuilder\Infrastructure\Provider\Rakuten\RakutenRequestFactory}
 *     convention.
 *
 * Failure semantics:
 *
 *   - Programming-level errors (provider mismatch on the supplied
 *     {@see ProviderProductId}, per-provider sort / page-size
 *     constraint violations) raise
 *     {@see ProviderRequestException} carrying a structured
 *     `context` map per `provider-api-contracts.md` §10. Per
 *     §10.1 the carried context never includes credential
 *     material — only `feature` / `requested` / `cap` /
 *     `supported` / `expected_provider` / `actual_provider`.
 *
 *   - Per §10.1 the factory MUST NOT expose its
 *     {@see YahooShoppingCredentials} through any public surface.
 *     It only forwards individual field values into
 *     {@see HttpRequest::get()}'s query parameter map.
 *
 * @package CommerceLinkBuilder\Infrastructure\Provider\YahooShopping
 */

declare(strict_types=1);

// Exception messages constructed in this factory are developer-
// facing diagnostic summaries surfaced through ProviderRequestException;
// 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 CommerceLinkBuilder\Infrastructure\Provider\YahooShopping;

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

/**
 * Builds Yahoo!ショッピング商品検索 v3 requests from cross-provider
 * domain values.
 *
 * See the file-level docblock for the §9.3 mapping, the lookup-via-
 * search rationale (`itemcode` filter), the per-provider page-size /
 * sort enforcement, and the §10.1 secret-material rule that binds
 * the carried {@see YahooShoppingCredentials}.
 */
final class YahooShoppingRequestFactory {

	/**
	 * §4 stable provider key for Yahoo!ショッピング. Exposed as a
	 * class constant so callers and tests reference the exact
	 * token without duplicating the literal. Per §4.1 the slug is
	 * `yahoo_shopping` (with underscore).
	 */
	public const PROVIDER_KEY = 'yahoo_shopping';

	/**
	 * Default商品検索 v3 endpoint per §9.3.
	 *
	 * A future versioned move replaces this constant in a single
	 * place. Tests and integration paths that need to point at
	 * an alternate endpoint construct the factory with an
	 * explicit URL.
	 */
	public const DEFAULT_SEARCH_ENDPOINT = 'https://shopping.yahooapis.jp/ShoppingWebService/V3/itemSearch';

	/**
	 * §9.3 per-page upper bound for商品検索 v3.
	 *
	 * Exposed as a class constant so consuming surfaces (admin UI,
	 * REST input validators, sync workers) can render or pre-clamp
	 * the value without re-hardcoding it.
	 */
	public const MAX_RESULTS_PER_PAGE = 50;

	/**
	 * Cross-provider sort token for the upstream's default
	 * relevance ordering.
	 */
	public const SORT_RELEVANCE = 'relevance';

	/**
	 * Cross-provider sort token for ascending price.
	 */
	public const SORT_PRICE_ASC = 'price_asc';

	/**
	 * Cross-provider sort token for descending price.
	 */
	public const SORT_PRICE_DESC = 'price_desc';

	/**
	 * Mapping from cross-provider sort tokens
	 * ({@see SearchQuery::sort()}) to Yahoo wire-level tokens.
	 *
	 * Unlike {@see \CommerceLinkBuilder\Infrastructure\Provider\Rakuten\RakutenRequestFactory},
	 * the relevance row is non-null: Yahoo's `-score` is the
	 * upstream's own default and the factory emits it explicitly
	 * so the URL stays reproducible across upstream version
	 * changes.
	 *
	 * @var array<string, string>
	 */
	private const SUPPORTED_SORTS = array(
		self::SORT_RELEVANCE  => '-score',
		self::SORT_PRICE_ASC  => '+price',
		self::SORT_PRICE_DESC => '-price',
	);

	/**
	 * Yahoo!ショッピング credentials forwarded to every constructed
	 * request.
	 *
	 * Held by reference rather than as field-by-field copies so a
	 * future credential rotation requires only swapping the DTO
	 * the factory was constructed with — but per §10.1 the
	 * factory itself MUST NOT expose the held DTO through any
	 * public surface.
	 */
	private YahooShoppingCredentials $credentials;

	/**
	 * Resolved Yahoo!ショッピング商品検索 v3 endpoint URL the
	 * factory targets. Held as a string rather than re-derived
	 * per call so the construction-time validation runs once.
	 */
	private string $endpoint;

	/**
	 * Construct a Yahoo!ショッピング request factory.
	 *
	 * @param YahooShoppingCredentials $credentials Yahoo credentials
	 *                                              to stamp onto
	 *                                              every constructed
	 *                                              request.
	 * @param string|null              $endpoint    Optional explicit
	 *                                              endpoint URL.
	 *                                              Defaults to
	 *                                              {@see self::DEFAULT_SEARCH_ENDPOINT}
	 *                                              per §9.3.
	 */
	public function __construct(
		YahooShoppingCredentials $credentials,
		?string $endpoint = null
	) {
		$this->credentials = $credentials;
		$this->endpoint    = $endpoint ?? self::DEFAULT_SEARCH_ENDPOINT;
	}

	/**
	 * Translate a cross-provider {@see SearchQuery} into a Yahoo
	 * 商品検索 v3 request.
	 *
	 * Validation summary:
	 *
	 *   - {@see SearchQuery::perPage()} MUST be `<= 50`
	 *     ({@see self::MAX_RESULTS_PER_PAGE}); a higher value
	 *     raises {@see ProviderRequestException}.
	 *
	 *   - {@see SearchQuery::sort()}, when non-null, MUST appear
	 *     in {@see self::SUPPORTED_SORTS}; any other token raises
	 *     {@see ProviderRequestException} carrying the offending
	 *     value plus the list of supported tokens for diagnosis.
	 *
	 *   - {@see SearchQuery::category()} /
	 *     {@see SearchQuery::locale()} /
	 *     {@see SearchQuery::currency()} /
	 *     {@see SearchQuery::context()} are accepted at the cross-
	 *     provider DTO level but deliberately NOT forwarded to
	 *     Yahoo because the upstream lacks a stable equivalent.
	 *
	 *   - The `appid` query parameter is stamped from
	 *     {@see self::$credentials} on every constructed request.
	 *     Affiliate parameters are forwarded only when the sid is
	 *     configured.
	 *
	 * @param SearchQuery $query Normalized cross-provider query.
	 *
	 * @throws ProviderRequestException When the supplied query
	 *                                  violates a Yahoo-native
	 *                                  input constraint
	 *                                  (per-provider page-size cap,
	 *                                  unsupported sort token).
	 */
	public function buildSearchRequest( SearchQuery $query ): HttpRequest {
		if ( $query->perPage() > self::MAX_RESULTS_PER_PAGE ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				sprintf(
					'Yahoo!ショッピング商品検索 v3 caps perPage at %d; got %d.',
					self::MAX_RESULTS_PER_PAGE,
					$query->perPage()
				),
				array(
					'feature'   => 'search',
					'parameter' => 'perPage',
					'requested' => $query->perPage(),
					'cap'       => self::MAX_RESULTS_PER_PAGE,
				)
			);
		}

		$wireSort = $this->resolveSort( $query->sort() );

		$parameters            = $this->baseParameters();
		$parameters['query']   = $query->keyword();
		$parameters['results'] = $query->perPage();
		$parameters['start']   = ( $query->page() - 1 ) * $query->perPage() + 1;
		$parameters['sort']    = $wireSort;

		$minPrice = $query->minPrice();
		if ( $minPrice !== null ) {
			$parameters['price_from'] = $minPrice;
		}

		$maxPrice = $query->maxPrice();
		if ( $maxPrice !== null ) {
			$parameters['price_to'] = $maxPrice;
		}

		return HttpRequest::get( $this->endpoint, $parameters );
	}

	/**
	 * Translate a cross-provider {@see ProviderProductId} into a
	 * Yahoo!ショッピング商品検索 v3 request scoped to a single
	 * item via the `itemcode` filter.
	 *
	 * Per §9.3 ("任意対応 — 単品取得が弱い場合は検索結果ベース
	 * 補完でもよい"), this factory implements the lookup path as
	 * a `?itemcode=<value>&results=1` search rather than against
	 * a dedicated single-item endpoint — the upstream does not
	 * expose one. The resulting upstream call returns a one-hit
	 * response the response normalizer can decode the same way
	 * as a normal search hit, and an empty `hits` array — which
	 * the upstream returns when the item code does not match —
	 * maps cleanly to a {@see \CommerceLinkBuilder\Domain\Provider\Exception\ProductNotFoundException}
	 * downstream.
	 *
	 * Validation:
	 *
	 *   - The supplied id MUST belong to this provider per
	 *     {@see self::PROVIDER_KEY}. A foreign-provider id raises
	 *     {@see ProviderRequestException} carrying the supplied
	 *     and expected provider keys for diagnosis.
	 *
	 *   - Beyond the provider-key cross-check, the factory does
	 *     NOT validate the value half against Yahoo's `itemcode`
	 *     grammar. A malformed value flows through to the
	 *     upstream, which rejects it with a well-defined error
	 *     envelope the error mapper can translate.
	 *
	 * @param ProviderProductId $id Identifier whose detail
	 *                              snapshot should be fetched.
	 *
	 * @throws ProviderRequestException When `$id` belongs to a
	 *                                  provider other than
	 *                                  {@see self::PROVIDER_KEY}.
	 */
	public function buildLookupRequest( ProviderProductId $id ): HttpRequest {
		if ( ! $id->belongsTo( self::PROVIDER_KEY ) ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				sprintf(
					'ProviderProductId belongs to provider "%s", not "%s".',
					$id->provider(),
					self::PROVIDER_KEY
				),
				array(
					'feature'           => 'lookup',
					'expected_provider' => self::PROVIDER_KEY,
					'actual_provider'   => $id->provider(),
				)
			);
		}

		$parameters             = $this->baseParameters();
		$parameters['itemcode'] = $id->value();
		$parameters['results']  = 1;
		$parameters['start']    = 1;

		return HttpRequest::get( $this->endpoint, $parameters );
	}

	/**
	 * Endpoint URL the factory currently targets.
	 *
	 * Exposed so test assertions can correlate a constructed
	 * {@see HttpRequest::url()} back to the configured endpoint
	 * without re-importing the constant. Production code does
	 * not need this accessor — the factory is the single seam
	 * where endpoint URLs live.
	 */
	public function endpoint(): string {
		return $this->endpoint;
	}

	/**
	 * Cross-provider sort tokens this factory accepts on
	 * {@see self::buildSearchRequest()}.
	 *
	 * Returned as a positional list in declaration order so admin
	 * UIs that want to render a sort dropdown can iterate without
	 * sorting the keys themselves.
	 *
	 * @return list<string>
	 */
	public static function supportedSortTokens(): array {
		return array_keys( self::SUPPORTED_SORTS );
	}

	/**
	 * Resolve the cross-provider sort token to its wire-level
	 * Yahoo equivalent, falling back to `-score` when the token
	 * is null.
	 *
	 * @param string|null $rawSort Cross-provider sort token, or null.
	 *
	 * @throws ProviderRequestException When `$rawSort` is non-null
	 *                                  and not in
	 *                                  {@see self::SUPPORTED_SORTS}.
	 */
	private function resolveSort( ?string $rawSort ): string {
		if ( $rawSort === null ) {
			return self::SUPPORTED_SORTS[ self::SORT_RELEVANCE ];
		}

		if ( ! array_key_exists( $rawSort, self::SUPPORTED_SORTS ) ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				sprintf(
					'Yahoo!ショッピング provider does not support sort token "%s".',
					$rawSort
				),
				array(
					'feature'   => 'search',
					'parameter' => 'sort',
					'requested' => $rawSort,
					'supported' => array_keys( self::SUPPORTED_SORTS ),
				)
			);
		}

		return self::SUPPORTED_SORTS[ $rawSort ];
	}

	/**
	 * Build the credential / affiliate query-parameter map shared
	 * by every constructed request.
	 *
	 * Keeping the map building behind a private helper keeps the
	 * §10.1 "credential material reads pass through a single
	 * audit point" guarantee at one location.
	 *
	 * @return array<string, scalar>
	 */
	private function baseParameters(): array {
		$parameters = array(
			'appid' => $this->credentials->clientId(),
		);

		// Search-side affiliate hint per §9.3. The Value Commerce
		// pid is consumed at link-build time by
		// YahooShoppingAffiliateLinkBuilder, not on the search wire.
		if ( $this->credentials->hasAffiliateSid() ) {
			$parameters['affiliate_type'] = 'yahoo';
			$parameters['affiliate_id']   = (string) $this->credentials->affiliateSid();
		}

		return $parameters;
	}
}
