<?php
/**
 * Yahoo!ショッピング provider implementation.
 *
 * Aggregates the slice 12a Yahoo!ショッピング infrastructure pieces
 * ({@see YahooShoppingCredentials}, {@see YahooShoppingRequestFactory},
 * {@see YahooShoppingResponseNormalizer}, {@see YahooShoppingErrorMapper},
 * {@see YahooShoppingAffiliateLinkBuilder}) 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
 * Yahoo!ショッピング商品検索 v3 endpoint without binding to any
 * Yahoo-specific type.
 *
 * Design notes:
 *
 *   - The class is the single seam where the cross-provider
 *     contract meets the Yahoo-specific collaborator graph. Every
 *     Yahoo-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.3 the
 *     provider declares the following capability surface:
 *
 *       * `supportsSearch` = true (canonical商品検索 v3 use)
 *       * `supportsLookup` = true (lookup-via-search through the
 *         `itemcode` filter per §9.3 任意対応)
 *       * `supportsMultipleImages` = false (the商品検索 v3
 *         envelope exposes only `image.medium` / `image.small`,
 *         not a gallery list)
 *       * `supportsPrice` = true
 *       * `supportsAvailability` = true (`inStock` boolean)
 *       * `supportsRating` = false (商品検索 v3 does not commit a
 *         stable review-aggregate axis)
 *       * `supportsCategories` = false (genre IDs exist but the
 *         cross-provider model does not currently surface them
 *         for Yahoo)
 *       * `supportsSorts` = true (relevance / price_asc /
 *         price_desc — see {@see YahooShoppingRequestFactory}'s
 *         SUPPORTED_SORTS)
 *       * `supportsAffiliateLinkBuild` = true (Value Commerce
 *         redirect endpoint, see
 *         {@see YahooShoppingAffiliateLinkBuilder})
 *       * `supportsHealthCheck` = true
 *       * `supportsRateLimitInspection` = false (the upstream
 *         does not expose a structured rate-limit window)
 *
 *   - {@see self::healthCheck()} dispatches a minimal "probe"
 *     search (`keyword=yahoo`, `results=1`, `page=1`) and
 *     translates the outcome into a {@see ProviderHealthStatus}
 *     snapshot per the §6.1 / §7.10 contract:
 *
 *       * 2xx response → `STATUS_HEALTHY`.
 *       * 401 / 403 / authentication-class Yahoo error token →
 *         `STATUS_INVALID_CREDENTIALS`.
 *       * 429 / rate-limit-class error token →
 *         `STATUS_RATE_LIMITED`.
 *       * 5xx / transient-class error token → `STATUS_DEGRADED`.
 *       * Transport failure ({@see HttpClientException}) →
 *         `STATUS_UNREACHABLE`.
 *       * Anything else → `STATUS_DEGRADED` with the produced
 *         exception's message preserved on the snapshot's
 *         human-readable summary.
 *
 *     The probe deliberately uses a non-empty keyword (`yahoo`)
 *     so the upstream does not reject the call with a
 *     missing-parameter 400 — the商品検索 v3 endpoint requires
 *     either a `query` or an `itemcode` filter.
 *
 *   - {@see self::buildAffiliateLink()} delegates to
 *     {@see YahooShoppingAffiliateLinkBuilder} 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 YahooShoppingErrorMapper} and re-throw the
 *          resulting §10.2 exception.
 *
 *       4. On a 2xx response, decode the body and hand it to the
 *          {@see YahooShoppingResponseNormalizer}. Body-decode
 *          and shape failures surface as
 *          {@see ProviderResponseException}.
 *
 *       5. For lookup, an empty `hits` array surfaces as
 *          {@see ProductNotFoundException} carrying the looked-up
 *          id in context per §6.3. The normalizer raises this
 *          itself; the provider lets it propagate.
 *
 *   - The `now` axis used by the response normalizer is sourced
 *     from the injected {@see Clock} so tests can drive
 *     timestamp-dependent code paths deterministically.
 *
 *   - Per §10.1 every produced exception carries the `provider`
 *     axis equal to {@see self::PROVIDER_KEY}; never any
 *     credential field. This binds the §10.1 secret-material
 *     rule on every error site this class touches.
 *
 * @package CommerceLinkBuilder\Infrastructure\Provider\YahooShopping
 */

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

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\ProviderException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderRequestException;
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;

/**
 * Yahoo!ショッピング商品検索 v3 provider implementation.
 *
 * See the file-level docblock for the cross-provider contract
 * mapping (§9.3 → 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 YahooShoppingProvider implements
	ProviderInterface,
	SearchProviderInterface,
	ItemLookupProviderInterface {

	/**
	 * §4 stable provider key for Yahoo!ショッピング per §4.1
	 * (`yahoo_shopping`, with underscore). Held as a class
	 * constant so every collaborator agrees on the key the
	 * provider tags responses, snapshots, and exceptions with.
	 */
	public const PROVIDER_KEY = 'yahoo_shopping';

	/**
	 * 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 = 'Yahoo!ショッピング';

	/**
	 * HTTP client used to dispatch every Yahoo!ショッピング
	 * 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
	 * Yahoo-specific {@see \CommerceLinkBuilder\Domain\Http\HttpRequest}
	 * instances.
	 */
	private YahooShoppingRequestFactory $requestFactory;

	/**
	 * Response normalizer that translates raw Yahoo!ショッピング
	 * 商品検索 v3 JSON payloads into cross-provider
	 * {@see SearchResult} / {@see ProductSnapshot} values.
	 */
	private YahooShoppingResponseNormalizer $responseNormalizer;

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

	/**
	 * Affiliate-link builder that mints tracked URLs for
	 * Yahoo!ショッピング snapshots through the Value Commerce
	 * redirect endpoint.
	 */
	private YahooShoppingAffiliateLinkBuilder $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 YahooShoppingRequestFactory       $requestFactory       Request builder.
	 * @param YahooShoppingResponseNormalizer   $responseNormalizer   Response decoder.
	 * @param YahooShoppingErrorMapper          $errorMapper          Error translator.
	 * @param YahooShoppingAffiliateLinkBuilder $affiliateLinkBuilder Affiliate URL minter.
	 * @param Clock                             $clock                "Now" source.
	 */
	public function __construct(
		HttpClient $httpClient,
		YahooShoppingRequestFactory $requestFactory,
		YahooShoppingResponseNormalizer $responseNormalizer,
		YahooShoppingErrorMapper $errorMapper,
		YahooShoppingAffiliateLinkBuilder $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.3.
	 *
	 * Constructed fresh on each call rather than cached so a
	 * future per-deployment capability variant can branch on
	 * {@see YahooShoppingCredentials} state without restructuring
	 * the provider.
	 */
	public function capabilities(): ProviderCapabilities {
		return new ProviderCapabilities(
			true,  // supportsSearch.
			true,  // supportsLookup.
			false, // supportsMultipleImages — image.medium / small only.
			true,  // supportsPrice.
			true,  // supportsAvailability — inStock boolean.
			false, // supportsRating — no stable review-aggregate axis.
			false, // 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 `query=yahoo&results=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(
				self::PROVIDER_KEY,
				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 YahooShoppingAffiliateLinkBuilder}
	 * 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 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(
			$query,
			$decoded,
			$now
		);
	}

	/**
	 * Look up a single product by {@see ProviderProductId} per
	 * §6.3.
	 *
	 * Lifecycle mirrors {@see self::searchProducts()} except step
	 * 4 calls {@see YahooShoppingResponseNormalizer::normalizeProduct()}
	 * — the normalizer raises {@see \CommerceLinkBuilder\Domain\Provider\Exception\ProductNotFoundException}
	 * itself when the upstream returns an empty `hits` array,
	 * carrying the looked-up id on the exception context per
	 * §6.3.
	 *
	 * Provider-mismatch ids are rejected by the request factory
	 * before any network I/O occurs, surfacing as
	 * {@see ProviderRequestException}.
	 *
	 * @param ProviderProductId $id Identifier whose detail
	 *                              snapshot should be fetched.
	 *
	 * @throws ProviderException|ProviderRequestException When `$id`
	 *                          belongs to a different provider per
	 *                          §6.3 (surfaces as
	 *                          {@see ProviderRequestException}) or
	 *                          when any §10.2 failure mode applies.
	 */
	public function getProduct( ProviderProductId $id ): ProductSnapshot {
		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(),
				)
			);
		}

		$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 );

		return $this->responseNormalizer->normalizeProduct( $decoded, $id, $now );
	}

	/**
	 * 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 HTTP 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(
					'Yahoo!ショッピング 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 Yahoo!ショッピング
	 * 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(
					'Yahoo!ショッピング 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
		);
	}
}
