<?php
/**
 * Rakuten provider implementation.
 *
 * Aggregates the Phase 4 Rakuten infrastructure pieces
 * ({@see RakutenCredentials}, {@see RakutenRequestFactory},
 * {@see RakutenResponseNormalizer}, {@see RakutenErrorMapper},
 * {@see RakutenAffiliateLinkBuilder}) behind the Phase 3 cross-
 * provider interfaces ({@see ProviderInterface},
 * {@see SearchProviderInterface},
 * {@see ItemLookupProviderInterface}) so consuming surfaces (REST
 * controllers, admin pickers, sync workers) can drive the Rakuten
 * Web Service IchibaItem Search endpoint without binding to any
 * Rakuten-specific type.
 *
 * Design notes:
 *
 *   - The class is the single seam where the cross-provider
 *     contract meets the Rakuten-specific collaborator graph. Every
 *     Rakuten-only type is held by reference and never returned;
 *     the public surface speaks only in Phase 3 cross-provider
 *     values ({@see SearchResult}, {@see ProductSnapshot},
 *     {@see AffiliateLinkResult}, {@see ProviderHealthStatus}).
 *
 *   - Per `specifications/provider-api-contracts.md` §9.2 the
 *     provider declares the following capability surface:
 *
 *       * `supportsSearch` = true (canonical IchibaItem Search use)
 *       * `supportsLookup` = true (lookup-via-search per §9.2)
 *       * `supportsMultipleImages` = true (mediumImageUrls list)
 *       * `supportsPrice` = true
 *       * `supportsAvailability` = true
 *       * `supportsRating` = true (reviewAverage / reviewCount)
 *       * `supportsCategories` = true (genreId)
 *       * `supportsSorts` = true (relevance / price_asc / price_desc)
 *       * `supportsAffiliateLinkBuild` = true
 *       * `supportsHealthCheck` = true
 *       * `supportsRateLimitInspection` = false (the upstream does
 *         not expose a structured rate-limit window)
 *
 *   - {@see self::healthCheck()} dispatches a minimal "probe"
 *     search (one-character keyword, single hit) and translates
 *     the outcome into a {@see ProviderHealthStatus} snapshot per
 *     the §6.1 / §7.10 contract:
 *
 *       * 2xx response → `STATUS_HEALTHY`.
 *       * 401 / 403 / authentication-class Rakuten error →
 *         `STATUS_INVALID_CREDENTIALS`.
 *       * 429 / rate-limit-class error → `STATUS_RATE_LIMITED`.
 *       * 5xx / transient-class error → `STATUS_DEGRADED`.
 *       * Transport failure ({@see HttpClientException}) →
 *         `STATUS_UNREACHABLE`.
 *       * Anything else → `STATUS_DEGRADED` with the produced
 *         exception's message preserved on the status's
 *         human-readable summary.
 *
 *     The probe deliberately uses a non-empty keyword (`a`)
 *     rather than relying on `itemCode`-style lookups so the call
 *     does not depend on a known item being available — the
 *     upstream returns a non-empty `Items` array for any search
 *     keyword and a 400 with `wrong_parameter` for an empty one.
 *
 *   - {@see self::buildAffiliateLink()} delegates to
 *     {@see RakutenAffiliateLinkBuilder} verbatim. The §6.1
 *     "passthrough on no-tracking" rule is honored by the builder
 *     itself; the provider does not introduce additional logic.
 *
 *   - {@see self::searchProducts()} and {@see self::getProduct()}
 *     follow the same dispatch pipeline:
 *
 *       1. Build the request via the request factory. Provider-
 *          native input violations surface as
 *          {@see ProviderRequestException} from the factory.
 *
 *       2. Send the request via the {@see HttpClient}. Transport
 *          failures surface as
 *          {@see HttpClientException}, which the provider
 *          translates to
 *          {@see TemporaryUnavailableException} per §6.2 / §6.3.
 *
 *       3. On a non-2xx response, hand it to the
 *          {@see RakutenErrorMapper} and re-throw the resulting
 *          §10.2 exception.
 *
 *       4. On a 2xx response, decode the body and hand it to the
 *          {@see RakutenResponseNormalizer}. Body-decode failures
 *          and shape failures both surface as
 *          {@see ProviderResponseException}.
 *
 *       5. For lookup, an empty `Items` array surfaces as
 *          {@see ProductNotFoundException} carrying the looked-up
 *          id in context per §6.3.
 *
 *   - The `now` axis used by the response normalizer is sourced
 *     from the injected {@see Clock} so tests can drive
 *     timestamp-dependent code paths deterministically.
 *
 *   - The {@see HttpClientException} carried request is preserved
 *     verbatim. The provider does NOT redact it before re-raising
 *     because the credentials it carries on the URL query string
 *     are the same ones already configured at the
 *     {@see RakutenCredentials} layer; per §10.1 the rule binds
 *     log-line code rather than this re-throw.
 *
 *   - Per §10.1 every produced exception carries the `provider`
 *     axis equal to {@see self::PROVIDER_KEY}; never any
 *     credential field.
 *
 * @package CommerceLinkBuilder\Infrastructure\Provider\Rakuten
 */

declare(strict_types=1);

// Exception messages constructed in this provider are developer-
// facing diagnostic summaries surfaced through ProviderException
// subclasses; 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\Rakuten;

use CommerceLinkBuilder\Domain\Affiliate\AffiliateContext;
use CommerceLinkBuilder\Domain\Affiliate\AffiliateLinkResult;
use CommerceLinkBuilder\Domain\Clock;
use CommerceLinkBuilder\Domain\Http\HttpClient;
use CommerceLinkBuilder\Domain\Http\HttpClientException;
use CommerceLinkBuilder\Domain\Http\HttpResponse;
use CommerceLinkBuilder\Domain\Product\ProductSnapshot;
use CommerceLinkBuilder\Domain\Provider\Exception\AuthenticationException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProductNotFoundException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderResponseException;
use CommerceLinkBuilder\Domain\Provider\Exception\RateLimitExceededException;
use CommerceLinkBuilder\Domain\Provider\Exception\TemporaryUnavailableException;
use CommerceLinkBuilder\Domain\Provider\ItemLookupProviderInterface;
use CommerceLinkBuilder\Domain\Provider\ProviderCapabilities;
use CommerceLinkBuilder\Domain\Provider\ProviderHealthStatus;
use CommerceLinkBuilder\Domain\Provider\ProviderInterface;
use CommerceLinkBuilder\Domain\Provider\ProviderProductId;
use CommerceLinkBuilder\Domain\Provider\SearchProviderInterface;
use CommerceLinkBuilder\Domain\Search\SearchQuery;
use CommerceLinkBuilder\Domain\Search\SearchResult;
use InvalidArgumentException;

/**
 * Rakuten Web Service provider implementation.
 *
 * See the file-level docblock for the cross-provider contract
 * mapping (§9.2 → Phase 3 interfaces), the dispatch pipeline that
 * binds search / lookup / health-check, the §10.2 exception
 * translation rules, and the §10.1 secret-material rule that binds
 * surrounding log-line code rather than this class itself.
 */
final class RakutenProvider implements
	ProviderInterface,
	SearchProviderInterface,
	ItemLookupProviderInterface {

	/**
	 * §4 stable provider key for Rakuten. Held as a class
	 * constant so every collaborator agrees on the key the
	 * provider tags responses, snapshots, and exceptions with.
	 */
	public const PROVIDER_KEY = 'rakuten';

	/**
	 * Human-readable display name for the admin UI per §6.1
	 * `label()`. Held as a class constant so the value is stable
	 * across instances and so a future rename has a single
	 * location to update.
	 */
	public const LABEL = '楽天市場';

	/**
	 * HTTP client used to dispatch every Rakuten request.
	 *
	 * Held by reference rather than constructed per call so the
	 * service-provider boot path can wire a single
	 * {@see \CommerceLinkBuilder\Infrastructure\Http\WpRemoteHttpClient}
	 * across every provider in the rotation.
	 */
	private HttpClient $httpClient;

	/**
	 * Request factory that translates cross-provider
	 * {@see SearchQuery} / {@see ProviderProductId} values into
	 * Rakuten-specific {@see \CommerceLinkBuilder\Domain\Http\HttpRequest}
	 * instances.
	 */
	private RakutenRequestFactory $requestFactory;

	/**
	 * Response normalizer that translates raw Rakuten JSON
	 * payloads into cross-provider {@see SearchResult} /
	 * {@see ProductSnapshot} values.
	 */
	private RakutenResponseNormalizer $responseNormalizer;

	/**
	 * Error mapper that translates non-2xx Rakuten responses into
	 * §10.2 exception subclasses.
	 */
	private RakutenErrorMapper $errorMapper;

	/**
	 * Affiliate-link builder that mints tracked URLs for Rakuten
	 * snapshots.
	 */
	private RakutenAffiliateLinkBuilder $affiliateLinkBuilder;

	/**
	 * Clock used to stamp `fetchedAt` / `observedAt` on every
	 * snapshot the response normalizer produces.
	 */
	private Clock $clock;

	/**
	 * Construct the provider with its full collaborator graph.
	 *
	 * The constructor accepts every collaborator explicitly so the
	 * service-provider boot path can wire stub doubles for tests
	 * and so a future configuration override (alternate endpoint,
	 * custom error mapper) requires no changes to this class.
	 *
	 * @param HttpClient                  $httpClient           HTTP transport.
	 * @param RakutenRequestFactory       $requestFactory       Request builder.
	 * @param RakutenResponseNormalizer   $responseNormalizer   Response decoder.
	 * @param RakutenErrorMapper          $errorMapper          Error translator.
	 * @param RakutenAffiliateLinkBuilder $affiliateLinkBuilder Affiliate URL minter.
	 * @param Clock                       $clock                "Now" source.
	 */
	public function __construct(
		HttpClient $httpClient,
		RakutenRequestFactory $requestFactory,
		RakutenResponseNormalizer $responseNormalizer,
		RakutenErrorMapper $errorMapper,
		RakutenAffiliateLinkBuilder $affiliateLinkBuilder,
		Clock $clock
	) {
		$this->httpClient           = $httpClient;
		$this->requestFactory       = $requestFactory;
		$this->responseNormalizer   = $responseNormalizer;
		$this->errorMapper          = $errorMapper;
		$this->affiliateLinkBuilder = $affiliateLinkBuilder;
		$this->clock                = $clock;
	}

	/**
	 * Stable §4 provider key.
	 */
	public function key(): string {
		return self::PROVIDER_KEY;
	}

	/**
	 * Human-readable display name per §6.1.
	 */
	public function label(): string {
		return self::LABEL;
	}

	/**
	 * Capabilities snapshot per §7.9 / §9.2.
	 *
	 * Constructed fresh on each call rather than cached so a
	 * future per-deployment capability variant (some Rakuten
	 * tiers disable affiliate links) can branch on
	 * {@see RakutenCredentials} state without restructuring the
	 * provider.
	 */
	public function capabilities(): ProviderCapabilities {
		return new ProviderCapabilities(
			true,  // supportsSearch.
			true,  // supportsLookup.
			true,  // supportsMultipleImages.
			true,  // supportsPrice.
			true,  // supportsAvailability.
			true,  // supportsRating.
			true,  // supportsCategories.
			true,  // supportsSorts.
			true,  // supportsAffiliateLinkBuild.
			true,  // supportsHealthCheck.
			false  // supportsRateLimitInspection.
		);
	}

	/**
	 * Report whether the named §8 feature key is supported.
	 *
	 * Delegates to {@see ProviderCapabilities::supportsFeature()}
	 * on the result of {@see self::capabilities()} so the two
	 * surfaces stay consistent without duplication, per the §6.1
	 * binding rule.
	 *
	 * @param string $feature §8 feature key.
	 *
	 * @throws InvalidArgumentException When `$feature` is not in
	 *                                  the §8 closed set.
	 */
	public function supportsFeature( string $feature ): bool {
		return $this->capabilities()->supportsFeature( $feature );
	}

	/**
	 * Probe the upstream and return a {@see ProviderHealthStatus}
	 * snapshot per §7.10.
	 *
	 * The probe issues a minimal `keyword=a&hits=1` search and
	 * branches on the outcome:
	 *
	 *   - 2xx → {@see ProviderHealthStatus::healthy()}.
	 *   - {@see HttpClientException} (transport failure) →
	 *     `STATUS_UNREACHABLE` with `reachable = false`.
	 *   - {@see AuthenticationException} →
	 *     `STATUS_INVALID_CREDENTIALS`.
	 *   - {@see RateLimitExceededException} →
	 *     `STATUS_RATE_LIMITED`.
	 *   - {@see TemporaryUnavailableException} →
	 *     `STATUS_DEGRADED` with `reachable = true`.
	 *   - Anything else → `STATUS_DEGRADED` carrying the
	 *     exception message on the snapshot's summary axis.
	 *
	 * Per the §6.1 contract this method MUST NOT throw on
	 * application-level failures — every observable outcome is
	 * encoded as a structured snapshot so the diagnostics surface
	 * can render it without `try`/`catch`.
	 */
	public function healthCheck(): ProviderHealthStatus {
		$now = $this->clock->now();

		try {
			$query = new SearchQuery(
				'a',
				1,
				1,
				null,
				null,
				null,
				null,
				array(),
				null,
				null,
				'health_check'
			);

			$request  = $this->requestFactory->buildSearchRequest( $query );
			$response = $this->httpClient->send( $request );

			if ( $response->isSuccessful() ) {
				return ProviderHealthStatus::healthy( $now );
			}

			$exception = $this->errorMapper->map( $response );

			return $this->translateExceptionToHealth( $exception, true, $now );
		} catch ( HttpClientException $error ) {
			return new ProviderHealthStatus(
				ProviderHealthStatus::STATUS_UNREACHABLE,
				false,
				false,
				false,
				$error->getMessage() === '' ? null : $error->getMessage(),
				$now,
				array( 'reason' => 'transport_failure' )
			);
		}
	}

	/**
	 * Mint an affiliate-tracked URL per §6.1.
	 *
	 * Delegates to {@see RakutenAffiliateLinkBuilder} verbatim;
	 * see that class's docblock for the route-selection logic and
	 * the no-tracking passthrough behavior.
	 *
	 * @param ProductSnapshot  $snapshot Snapshot whose detail URL
	 *                                   should be tracked.
	 * @param AffiliateContext $context  Per-request affiliate
	 *                                   context.
	 */
	public function buildAffiliateLink(
		ProductSnapshot $snapshot,
		AffiliateContext $context
	): AffiliateLinkResult {
		return $this->affiliateLinkBuilder->build( $snapshot, $context );
	}

	/**
	 * Execute a keyword search per §6.2.
	 *
	 * Lifecycle:
	 *
	 *   1. Build the request via the request factory. Provider-
	 *      native input violations (per-page cap exceeded,
	 *      unsupported sort token) raise
	 *      {@see \CommerceLinkBuilder\Domain\Provider\Exception\ProviderRequestException}
	 *      from the factory and propagate verbatim.
	 *
	 *   2. Send the request via the {@see HttpClient}. Transport
	 *      failures raise {@see HttpClientException}, which this
	 *      method translates to
	 *      {@see TemporaryUnavailableException}.
	 *
	 *   3. On non-2xx, hand the response to the error mapper and
	 *      re-throw the produced §10.2 exception.
	 *
	 *   4. On 2xx, decode the body via {@see HttpResponse::jsonBody()}
	 *      and hand the decoded value to the response normalizer.
	 *      Body-decode and shape failures surface as
	 *      {@see ProviderResponseException}.
	 *
	 * @param SearchQuery $query Normalized cross-provider query.
	 *
	 * @throws ProviderException When any §10.2 failure mode applies.
	 */
	public function searchProducts( SearchQuery $query ): SearchResult {
		$now      = $this->clock->now();
		$request  = $this->requestFactory->buildSearchRequest( $query );
		$response = $this->dispatch( $request );

		if ( ! $response->isSuccessful() ) {
			throw $this->errorMapper->map( $response );
		}

		$decoded = $this->decodeBody( $response );

		return $this->responseNormalizer->normalizeSearchResult(
			$decoded,
			$query,
			$now
		);
	}

	/**
	 * Look up a single product by {@see ProviderProductId} per
	 * §6.3.
	 *
	 * Lifecycle mirrors {@see self::searchProducts()} except step
	 * 4 calls {@see RakutenResponseNormalizer::normalizeFirstItem()}
	 * — the normalizer returns null for an empty `Items` array,
	 * which this method translates into
	 * {@see ProductNotFoundException} carrying the looked-up id
	 * on the exception context per §6.3.
	 *
	 * @param ProviderProductId $id Identifier whose detail
	 *                              snapshot should be fetched.
	 *
	 * @throws ProviderException|ProductNotFoundException When any §10.2
	 *                           failure mode applies, including
	 *                           {@see ProductNotFoundException} when the
	 *                           upstream returned an empty `Items` array
	 *                           per §6.3.
	 */
	public function getProduct( ProviderProductId $id ): ProductSnapshot {
		$now      = $this->clock->now();
		$request  = $this->requestFactory->buildLookupRequest( $id );
		$response = $this->dispatch( $request );

		if ( ! $response->isSuccessful() ) {
			throw $this->errorMapper->map( $response );
		}

		$decoded  = $this->decodeBody( $response );
		$snapshot = $this->responseNormalizer->normalizeFirstItem( $decoded, $now );

		if ( $snapshot === null ) {
			throw new ProductNotFoundException(
				self::PROVIDER_KEY,
				sprintf(
					'Rakuten lookup did not match any product for "%s".',
					$id->value()
				),
				array(
					'provider_product_id' => $id->value(),
					'feature'             => 'lookup',
				)
			);
		}

		return $snapshot;
	}

	/**
	 * Dispatch a request through the HTTP client and translate
	 * transport failures into §10.2 exceptions.
	 *
	 * Centralizes the {@see HttpClientException} →
	 * {@see TemporaryUnavailableException} translation so search /
	 * lookup / health-check share a single seam. The translated
	 * exception carries the originating request's URL host and
	 * method on the context map for diagnostic purposes — without
	 * carrying the full URL (which includes the credential query
	 * string) per §10.1.
	 *
	 * @param \CommerceLinkBuilder\Domain\Http\HttpRequest $request Request to dispatch.
	 *
	 * @throws TemporaryUnavailableException When the transport
	 *                                       layer reports a
	 *                                       failure.
	 */
	private function dispatch(
		\CommerceLinkBuilder\Domain\Http\HttpRequest $request
	): HttpResponse {
		try {
			return $this->httpClient->send( $request );
		} catch ( HttpClientException $error ) {
			throw new TemporaryUnavailableException(
				self::PROVIDER_KEY,
				sprintf(
					'Rakuten request failed at the transport layer: %s',
					$error->getMessage()
				),
				array(
					'http_method' => $request->method(),
					'reason'      => 'transport_failure',
				),
				$error
			);
		}
	}

	/**
	 * Decode the JSON body of a successful Rakuten response.
	 *
	 * Centralizes the {@see HttpResponse::jsonBody()} →
	 * {@see ProviderResponseException} translation so search /
	 * lookup share a single seam. An empty body or a malformed
	 * JSON body raises {@see ProviderResponseException} carrying
	 * the parse-error context per §10.
	 *
	 * @param HttpResponse $response Response whose body to decode.
	 *
	 * @return mixed Decoded JSON value.
	 *
	 * @throws ProviderResponseException When the body cannot be
	 *                                   decoded as JSON.
	 */
	private function decodeBody( HttpResponse $response ) {
		try {
			return $response->jsonBody();
		} catch ( InvalidArgumentException $error ) {
			throw new ProviderResponseException(
				self::PROVIDER_KEY,
				sprintf(
					'Rakuten response body could not be decoded as JSON: %s',
					$error->getMessage()
				),
				array(
					'reason'      => 'json_decode_failed',
					'status_code' => $response->statusCode(),
				),
				$error
			);
		}
	}

	/**
	 * Translate a §10.2 exception into a {@see ProviderHealthStatus}
	 * snapshot for the {@see self::healthCheck()} path.
	 *
	 * The probe path branches the response through the error
	 * mapper to produce a structured §10.2 exception; this method
	 * folds the resulting exception into the corresponding
	 * `STATUS_*` token. The `reachable` axis is supplied by the
	 * caller so a transport failure ("we never got a response")
	 * and a 401 ("we got a response, just not a useful one") can
	 * surface different `reachable` values from the same helper.
	 *
	 * @param ProviderException  $exception Exception produced by
	 *                                      the error mapper.
	 * @param bool               $reachable Whether the upstream
	 *                                      responded at all.
	 * @param \DateTimeImmutable $now      Probe timestamp.
	 */
	private function translateExceptionToHealth(
		ProviderException $exception,
		bool $reachable,
		\DateTimeImmutable $now
	): ProviderHealthStatus {
		$message = $exception->getMessage() === '' ? null : $exception->getMessage();
		$details = array(
			'error_code' => $exception->errorCode(),
		);

		if ( $exception instanceof AuthenticationException ) {
			return new ProviderHealthStatus(
				ProviderHealthStatus::STATUS_INVALID_CREDENTIALS,
				false,
				$reachable,
				false,
				$message,
				$now,
				$details
			);
		}

		if ( $exception instanceof RateLimitExceededException ) {
			return new ProviderHealthStatus(
				ProviderHealthStatus::STATUS_RATE_LIMITED,
				true,
				$reachable,
				true,
				$message,
				$now,
				$details
			);
		}

		if ( $exception instanceof TemporaryUnavailableException ) {
			return new ProviderHealthStatus(
				ProviderHealthStatus::STATUS_DEGRADED,
				true,
				$reachable,
				false,
				$message,
				$now,
				$details
			);
		}

		// Fall-through covers ProviderRequestException /
		// ProviderResponseException / any other §10.2 subclass.
		// These are degraded states rather than full outages — the
		// upstream answered, but in a way the cross-provider model
		// cannot interpret as healthy.
		return new ProviderHealthStatus(
			ProviderHealthStatus::STATUS_DEGRADED,
			true,
			$reachable,
			false,
			$message,
			$now,
			$details
		);
	}
}
