<?php
/**
 * Rakuten request factory.
 *
 * Translates the cross-provider {@see SearchQuery} and
 * {@see ProviderProductId} domain values into concrete
 * {@see HttpRequest} instances targeting the Rakuten Web Service
 * IchibaItem Search endpoint
 * (https://webservice.rakuten.co.jp/documentation, per
 * `specifications/provider-api-contracts.md` §9.2).
 *
 * Design notes:
 *
 *   - Both the keyword search and the single-item lookup paths
 *     hit the same upstream endpoint. The §9.2 "任意対応" line
 *     allows lookup to fall back to a one-item search by
 *     `itemCode`, and that is exactly what this factory does:
 *     the upstream's dedicated single-item endpoints are weak
 *     (no detail-only variant, inconsistent paging guarantees),
 *     so a `?itemCode=shop:item-id&hits=1` search produces a
 *     deterministic one-result response that the response
 *     normalizer can decode the same way as a normal search hit.
 *
 *   - Authentication parameters are stamped onto every request from
 *     the {@see RakutenCredentials} carried at construction time.
 *     `applicationId` and `affiliateId` are query parameters;
 *     `accessKey` is an HTTP header so it does not appear in URLs.
 *     `Origin` identifies the WordPress site registered in Rakuten's
 *     allowed-websites setting.
 *     The factory deliberately does NOT accept ad-hoc credential
 *     overrides per call; admins configure credentials once and
 *     callers pass query / lookup arguments only.
 *
 *   - The factory always sets `format=json` and `formatVersion=1`
 *     so the response normalizer receives the established nested
 *     `Items[].Item` shape.
 *
 *   - Per `provider-api-contracts.md` §9.2, the per-page upper
 *     bound is 30 (matching the API's own ceiling). The factory
 *     enforces this ceiling at the request-construction boundary
 *     so a higher value coming through the cross-provider
 *     {@see SearchQuery} (the cross-provider DTO does not bind a
 *     per-provider cap) surfaces as a
 *     {@see ProviderRequestException} from this layer rather than
 *     reaching the upstream and producing an opaque 400.
 *
 *   - Sort tokens are mapped from AFFILUN's vocabulary
 *     (`relevance` / `price_asc` / `price_desc`) to Rakuten's
 *     wire-level tokens (`standard` / `+itemPrice` / `-itemPrice`).
 *     `relevance` is the upstream default and elides the `sort`
 *     parameter so the URL stays minimal; the other two map 1:1.
 *     Tokens this provider does not support raise
 *     {@see ProviderRequestException}.
 *
 *   - Optional cross-provider {@see SearchQuery} fields that the
 *     upstream does not have a stable equivalent for (`category`,
 *     `locale`, `currency`, `context`) are deliberately ignored
 *     here. The factory documents the elision so a future
 *     contributor does not silently expect them to round-trip.
 *
 * Failure semantics:
 *
 *   - The factory does NOT speak HTTP itself; failures from the
 *     transport layer surface from
 *     {@see \Affilun\Domain\Http\HttpClient} on
 *     dispatch, not from this constructor.
 *
 *   - 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 MUST NOT include credential
 *     material — this factory only forwards `feature` /
 *     `requested` / `actual` / `supported` keys, never the
 *     applicationId / accessKey / affiliateId values.
 *
 *   - Per §10.1 the factory MUST NOT expose its
 *     {@see RakutenCredentials} through any public surface
 *     (`__toString()`, `toArray()`, etc.). It only forwards
 *     individual field values into
 *     {@see HttpRequest::withQueryParameter()} on dispatch.
 *
 * @package Affilun\Infrastructure\Provider\Rakuten
 */

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\Rakuten;

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

/**
 * Builds Rakuten Web Service IchibaItem Search requests from
 * cross-provider domain values.
 *
 * See the file-level docblock for the §9.2 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 RakutenCredentials}.
 */
final class RakutenRequestFactory {

	/**
	 * §4 stable provider key for Rakuten. Exposed as a class
	 * constant so callers and tests reference the exact token
	 * without duplicating the literal.
	 */
	public const PROVIDER_KEY = 'rakuten';

	/**
	 * Default versioned IchibaItem Search endpoint per §9.2.
	 *
	 * The path-versioned date suffix is the canonical Rakuten
	 * convention; a future spec-driven version bump replaces this
	 * constant in a single place. Tests and integration paths that
	 * need to point at an alternate endpoint (sandbox, mock
	 * server) construct the factory with an explicit URL.
	 */
	public const DEFAULT_SEARCH_ENDPOINT = 'https://openapi.rakuten.co.jp/ichibams/api/IchibaItem/Search/20260701';

	/**
	 * §9.2 per-page upper bound for IchibaItem Search.
	 *
	 * 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_HITS_PER_PAGE = 30;

	/**
	 * §9.2 wire-level sort token for "relevance" / default order.
	 *
	 * Rakuten's `standard` token is its idiomatic "no sort" choice,
	 * but the factory elides the `sort` parameter entirely for the
	 * default case so the resulting URL stays minimal and matches
	 * cached fixture URLs verbatim.
	 */
	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 Rakuten wire-level tokens.
	 *
	 * `null` means "elide the sort parameter on the wire" — used
	 * for {@see self::SORT_RELEVANCE} because Rakuten's default
	 * ordering is what the consumer wants in that case.
	 *
	 * @var array<string, string|null>
	 */
	private const SUPPORTED_SORTS = array(
		self::SORT_RELEVANCE  => null,
		self::SORT_PRICE_ASC  => '+itemPrice',
		self::SORT_PRICE_DESC => '-itemPrice',
	);

	/**
	 * Rakuten 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 RakutenCredentials $credentials;

	/**
	 * Resolved Rakuten Web Service 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;

	/**
	 * Site origin registered in Rakuten's allowed-websites setting.
	 */
	private string $origin;

	/**
	 * Construct a Rakuten request factory.
	 *
	 * @param RakutenCredentials $credentials Rakuten credentials
	 *                                        to stamp onto every
	 *                                        constructed request.
	 * @param string             $origin      Site origin registered
	 *                                        with Rakuten.
	 * @param string|null        $endpoint    Optional explicit
	 *                                        endpoint URL. Defaults
	 *                                        to {@see self::DEFAULT_SEARCH_ENDPOINT}
	 *                                        per §9.2. Tests and
	 *                                        integration paths that
	 *                                        need to point at an
	 *                                        alternate endpoint pass
	 *                                        a non-null value here.
	 */
	public function __construct(
		RakutenCredentials $credentials,
		string $origin,
		?string $endpoint = null
	) {
		$this->credentials = $credentials;
		$this->origin      = self::normalizeOrigin( $origin );
		$this->endpoint    = $endpoint ?? self::DEFAULT_SEARCH_ENDPOINT;
	}

	/**
	 * Translate a cross-provider {@see SearchQuery} into a Rakuten
	 * IchibaItem Search request.
	 *
	 * Validation summary:
	 *
	 *   - {@see SearchQuery::perPage()} MUST be `<= 30`
	 *     ({@see self::MAX_HITS_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
	 *     Rakuten because the upstream lacks a stable equivalent.
	 *     Filters (the structured map) are not forwarded for the
	 *     same reason; a future provider extension can pick up
	 *     specific filter keys.
	 *
	 *   - The `applicationId` and `affiliateId` query parameters
	 *     are stamped from {@see self::$credentials} on every
	 *     constructed request, regardless of cross-provider
	 *     content. The `accessKey` and `Origin` headers are also
	 *     always present.
	 *
	 *   - `format=json&formatVersion=1` is always set so the response
	 *     normalizer can decode the body as a single shape.
	 *
	 * @param SearchQuery $query Normalized cross-provider query.
	 *
	 * @throws ProviderRequestException When the supplied query
	 *                                  violates a Rakuten-native
	 *                                  input constraint
	 *                                  (per-provider page-size cap,
	 *                                  unsupported sort token).
	 */
	public function buildSearchRequest( SearchQuery $query ): HttpRequest {
		if ( $query->perPage() > self::MAX_HITS_PER_PAGE ) {
			throw new ProviderRequestException(
				self::PROVIDER_KEY,
				sprintf(
					'Rakuten IchibaItem Search caps perPage at %d; got %d.',
					self::MAX_HITS_PER_PAGE,
					$query->perPage()
				),
				array(
					'feature'   => 'search',
					'parameter' => 'perPage',
					'requested' => $query->perPage(),
					'cap'       => self::MAX_HITS_PER_PAGE,
				)
			);
		}

		$wireSort = null;
		$rawSort  = $query->sort();
		if ( $rawSort !== null ) {
			if ( ! array_key_exists( $rawSort, self::SUPPORTED_SORTS ) ) {
				throw new ProviderRequestException(
					self::PROVIDER_KEY,
					sprintf(
						'Rakuten provider does not support sort token "%s".',
						$rawSort
					),
					array(
						'feature'   => 'search',
						'parameter' => 'sort',
						'requested' => $rawSort,
						'supported' => array_keys( self::SUPPORTED_SORTS ),
					)
				);
			}

			$wireSort = self::SUPPORTED_SORTS[ $rawSort ];
		}

		$parameters            = $this->baseParameters();
		$parameters['keyword'] = $query->keyword();
		$parameters['page']    = $query->page();
		$parameters['hits']    = $query->perPage();

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

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

		if ( $wireSort !== null ) {
			$parameters['sort'] = $wireSort;
		}

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

	/**
	 * Translate a cross-provider {@see ProviderProductId} into a
	 * Rakuten IchibaItem Search request scoped to a single item.
	 *
	 * Per §9.2 ("任意対応 — 単品取得 API が弱い場合は検索結果
	 * ベース補完でもよい"), this factory implements the lookup
	 * path as a `?itemCode=shop:item-id&hits=1` search rather
	 * than against a dedicated single-item endpoint. The
	 * resulting upstream call returns a one-result `Items` array
	 * the response normalizer can decode the same way as a
	 * normal search hit, and an empty `Items` array — which the
	 * upstream returns when the item code does not match — maps
	 * cleanly to a {@see \Affilun\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 Rakuten's `itemCode`
	 *     grammar (`shop:item-id`). A malformed value flows
	 *     through to the upstream, which rejects it with a
	 *     well-defined error envelope the error mapper can
	 *     translate. Rejecting locally would force a duplicate
	 *     grammar definition in this factory.
	 *
	 * @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['hits']     = 1;
		$parameters['page']     = 1;

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

	/**
	 * 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;
	}

	/**
	 * Normalized site origin sent to Rakuten.
	 */
	public function origin(): string {
		return $this->origin;
	}

	/**
	 * 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 );
	}

	/**
	 * Build the credential / format 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 {
		return array(
			'format'        => 'json',
			'formatVersion' => 1,
			'applicationId' => $this->credentials->applicationId(),
			'affiliateId'   => $this->credentials->affiliateId(),
		);
	}

	/**
	 * Build headers required by Rakuten's current request context.
	 *
	 * @return array<string, string>
	 */
	private function requestHeaders(): array {
		return array(
			'accessKey' => $this->credentials->accessKey(),
			'Origin'    => $this->origin,
		);
	}

	/**
	 * Normalize a site URL to an RFC 6454-style origin.
	 *
	 * @param string $origin Site URL or origin to normalize.
	 *
	 * @throws InvalidArgumentException When the URL is not HTTP(S).
	 */
	private static function normalizeOrigin( string $origin ): string {
		// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Infrastructure class must remain usable outside WordPress.
		$parts = parse_url( trim( $origin ) );
		if ( ! is_array( $parts )
			|| ! isset( $parts['scheme'], $parts['host'] )
			|| ! in_array( strtolower( $parts['scheme'] ), array( 'http', 'https' ), true )
		) {
			throw new InvalidArgumentException( 'RakutenRequestFactory origin must be an HTTP(S) URL.' );
		}

		$normalized = strtolower( $parts['scheme'] ) . '://' . strtolower( $parts['host'] );
		if ( isset( $parts['port'] ) ) {
			$normalized .= ':' . $parts['port'];
		}

		return $normalized;
	}
}
