<?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 constrained
 *     to the seller plus the seller-local item code and consume the
 *     exact matching 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 both ValueCommerce sid and pid
 *     are configured, the factory sets `affiliate_type=vc` plus
 *     `affiliate_id` containing the ValueCommerce referral base
 *     URL ending in `vc_url=`. The HTTP adapter performs the
 *     outer query-string encoding. When either value is missing,
 *     these parameters are elided.
 *
 *   - 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 AFFILUN'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 AFFILUN'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 \Affilun\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 Affilun\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 Affilun\Infrastructure\Provider\YahooShopping;

use Affilun\Domain\Http\HttpRequest;
use Affilun\Domain\Provider\Exception\ProviderRequestException;
use Affilun\Domain\Provider\ProviderProductId;
use Affilun\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, 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 \Affilun\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 seller-scoped search.
	 *
	 * Per §9.3 ("任意対応 — 単品取得が弱い場合は検索結果ベース
	 * 補完でもよい"), this factory implements the lookup path as a
	 * seller-scoped keyword search rather than against a dedicated
	 * single-item endpoint — the upstream does not expose one. Yahoo's
	 * persisted `code` value is shaped as `seller_id_item_code`, so the
	 * factory splits on the first underscore and sends
	 * `seller_id=<seller>&query=<item_code>`. The response normalizer then
	 * selects the hit whose `code` exactly matches the expected provider id.
	 *
	 * 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 only performs a
	 *     best-effort split of Yahoo's `seller_item` code. Values that do
	 *     not contain an underscore fall back to a normal keyword search for
	 *     the full id and are validated by the normalizer's exact-code match.
	 *
	 * @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();
		$parts      = explode( '_', $id->value(), 2 );
		if ( count( $parts ) === 2 && trim( $parts[0] ) !== '' && trim( $parts[1] ) !== '' ) {
			$parameters['seller_id'] = $parts[0];
			$parameters['query']     = $parts[1];
		} else {
			$parameters['query'] = $id->value();
		}
		$parameters['results'] = 10;
		$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(),
		);

		// Yahoo!ショッピング rewards are attributed through ValueCommerce.
		// Pass the referral base URL as the affiliate_id value; the HTTP
		// adapter performs the outer query-string encoding.
		if ( $this->credentials->hasAffiliateCredentials() ) {
			$parameters['affiliate_type'] = 'vc';
			$parameters['affiliate_id']   = YahooShoppingAffiliateLinkBuilder::buildReferralBaseUrl(
				(string) $this->credentials->affiliateSid(),
				(string) $this->credentials->affiliatePid()
			);
		}

		return $parameters;
	}
}
