<?php
/**
 * Yahoo!ショッピング error mapper.
 *
 * Translates Yahoo!ショッピング商品検索 v3 failure responses into
 * the cross-provider §10.2 exception hierarchy. Provider
 * implementations dispatch a request through the
 * {@see \CommerceLinkBuilder\Domain\Http\HttpClient}, hand the
 * resulting non-2xx {@see HttpResponse} to this mapper, and let
 * the mapper raise the matching
 * {@see \CommerceLinkBuilder\Domain\Provider\Exception\ProviderException}
 * subclass from `provider-api-contracts.md` §10.2.
 *
 * Design notes:
 *
 *   - The mapper inspects two axes to pick a §10.2 subclass:
 *
 *       1. The HTTP status code on the {@see HttpResponse}. The
 *          4xx / 5xx ranges are the broad classifier:
 *            - 401 / 403 → authentication failure.
 *            - 429       → rate-limit refusal.
 *            - other 4xx → request-shape failure.
 *            - 5xx       → temporary outage.
 *
 *       2. The Yahoo-native error envelope when present. Yahoo's
 *          商品検索 v3 returns errors as either a top-level
 *          `Error` object (`{"Error": {"Code": "...",
 *          "Message": "..."}}`) or as the YDN-style
 *          `{"Errors":[{"Code": "...", "Message": "..."}]}`
 *          envelope. The mapper accepts both and picks the more
 *          specific signal when the token disagrees with the
 *          status-class default.
 *
 *   - The translation table is held as a `ERROR_CODE_MAP` class
 *     constant mapping each known Yahoo error token to the
 *     §10.2 subclass FQCN. Unknown tokens fall back to the
 *     status-class default.
 *
 *   - Per `provider-api-contracts.md` §10, every produced
 *     exception carries a structured `context` map with at
 *     least:
 *
 *       * `status_code` — the HTTP status code observed.
 *       * `yahoo_error_code` — the upstream error token, or null.
 *       * `yahoo_error_message` — the upstream description, or null.
 *       * `retry_after` — the `Retry-After` header value, or null
 *         (only forwarded for the rate-limit class).
 *
 *     Per §10.1 the context map MUST NOT carry credential
 *     material.
 *
 *   - The mapper accepts a {@see HttpResponse} that may or may
 *     not carry a JSON body. A non-JSON body decodes to a
 *     missing error token and degrades gracefully to the
 *     status-class default.
 *
 * Failure semantics summary:
 *
 *   - 2xx: this mapper is not the right surface; calling it on a
 *     successful response raises {@see \LogicException}.
 *
 *   - 401 / 403, or any 4xx/5xx with a known authentication-
 *     class Yahoo error token → {@see AuthenticationException}.
 *
 *   - 429, or any 4xx/5xx with a known rate-limit-class Yahoo
 *     error token → {@see RateLimitExceededException}.
 *
 *   - 4xx with a known request-shape-class Yahoo error token →
 *     {@see ProviderRequestException}.
 *
 *   - 5xx, or any 4xx/5xx with a known transient-class Yahoo
 *     error token → {@see TemporaryUnavailableException}.
 *
 *   - 4xx with an unknown Yahoo error token →
 *     {@see ProviderRequestException}.
 *
 *   - Anything else → {@see ProviderResponseException}.
 *
 * @package CommerceLinkBuilder\Infrastructure\Provider\YahooShopping
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Infrastructure\Provider\YahooShopping;

use CommerceLinkBuilder\Domain\Http\HttpResponse;
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 LogicException;

/**
 * Maps Yahoo!ショッピング商品検索 v3 failure responses to the
 * §10.2 cross-provider exception hierarchy.
 */
final class YahooShoppingErrorMapper {

	/**
	 * §4 stable provider key for Yahoo!ショッピング. Every
	 * produced exception carries this on its `provider` axis.
	 */
	public const PROVIDER_KEY = 'yahoo_shopping';

	/**
	 * Mapping from Yahoo-native error tokens to the §10.2
	 * subclass FQCN that should be raised.
	 *
	 * The set covers tokens documented at the Yahoo! Developer
	 * Network商品検索 v3 reference plus a few that have been
	 * observed in production. Unknown tokens fall back to the
	 * status-class default.
	 *
	 * @var array<string, class-string<ProviderException>>
	 */
	private const ERROR_CODE_MAP = array(
		// Authentication / configuration failures.
		'InvalidAppId'         => AuthenticationException::class,
		'MissingAppId'         => AuthenticationException::class,
		'InvalidApplicationId' => AuthenticationException::class,
		'Unauthorized'         => AuthenticationException::class,
		'Forbidden'            => AuthenticationException::class,
		'AppIdRequired'        => AuthenticationException::class,

		// Rate-limit refusals.
		'TooManyRequests'      => RateLimitExceededException::class,
		'RateLimitExceeded'    => RateLimitExceededException::class,
		'QuotaExceeded'        => RateLimitExceededException::class,

		// Request-shape failures.
		'InvalidParameter'     => ProviderRequestException::class,
		'MissingParameter'     => ProviderRequestException::class,
		'InvalidValue'         => ProviderRequestException::class,
		'BadRequest'           => ProviderRequestException::class,

		// Transient-failure tokens.
		'InternalServerError'  => TemporaryUnavailableException::class,
		'ServiceUnavailable'   => TemporaryUnavailableException::class,
		'Timeout'              => TemporaryUnavailableException::class,
		'TemporaryFailure'     => TemporaryUnavailableException::class,
	);

	/**
	 * Translate a non-2xx Yahoo!ショッピング response into a §10.2
	 * exception.
	 *
	 * @param HttpResponse $response Non-2xx response to translate.
	 *
	 * @return ProviderException Concrete §10.2 subclass instance.
	 *
	 * @throws LogicException When `$response` carries a 2xx status.
	 */
	public function map( HttpResponse $response ): ProviderException {
		$statusCode = $response->statusCode();

		if ( $response->isSuccessful() ) {
			$message = sprintf(
				'YahooShoppingErrorMapper::map() must not be called for successful '
				. 'responses; got status %d.',
				$statusCode
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$envelope     = $this->decodeBody( $response );
		$errorToken   = $this->extractErrorCode( $envelope );
		$errorMessage = $this->extractErrorMessage( $envelope );
		$retryAfter   = $this->extractRetryAfter( $response );

		$exceptionClass = $this->pickExceptionClass( $statusCode, $errorToken );

		$context = array(
			'status_code'         => $statusCode,
			'yahoo_error_code'    => $errorToken,
			'yahoo_error_message' => $errorMessage,
			'retry_after'         => $retryAfter,
		);

		$message = $this->buildMessage(
			$exceptionClass,
			$statusCode,
			$errorToken,
			$errorMessage
		);

		/** @var ProviderException $exception */
		$exception = new $exceptionClass(
			self::PROVIDER_KEY,
			$message,
			$context
		);

		return $exception;
	}

	/**
	 * Closed set of Yahoo-native error tokens this mapper
	 * recognizes via {@see self::ERROR_CODE_MAP}, in declaration
	 * order.
	 *
	 * @return list<string>
	 */
	public static function knownErrorTokens(): array {
		return array_keys( self::ERROR_CODE_MAP );
	}

	/**
	 * Pick the §10.2 subclass for the supplied (status, token)
	 * pair.
	 *
	 * The token-driven mapping wins when the token is in
	 * {@see self::ERROR_CODE_MAP}; otherwise the status-class
	 * default applies.
	 *
	 * @param int         $statusCode HTTP status code.
	 * @param string|null $errorToken Yahoo-native error token, or null.
	 *
	 * @return class-string<ProviderException>
	 */
	private function pickExceptionClass( int $statusCode, ?string $errorToken ): string {
		if ( $errorToken !== null && array_key_exists( $errorToken, self::ERROR_CODE_MAP ) ) {
			return self::ERROR_CODE_MAP[ $errorToken ];
		}

		if ( $statusCode === 401 || $statusCode === 403 ) {
			return AuthenticationException::class;
		}

		if ( $statusCode === 429 ) {
			return RateLimitExceededException::class;
		}

		if ( $statusCode >= 400 && $statusCode <= 499 ) {
			return ProviderRequestException::class;
		}

		if ( $statusCode >= 500 && $statusCode <= 599 ) {
			return TemporaryUnavailableException::class;
		}

		return ProviderResponseException::class;
	}

	/**
	 * Build the human-readable summary for the produced exception.
	 *
	 * @param class-string<ProviderException> $exceptionClass    Chosen subclass.
	 * @param int                             $statusCode        HTTP status code.
	 * @param string|null                     $errorToken        Yahoo error token.
	 * @param string|null                     $errorMessage      Yahoo error message.
	 */
	private function buildMessage(
		string $exceptionClass,
		int $statusCode,
		?string $errorToken,
		?string $errorMessage
	): string {
		$friendly = $this->friendlyClassLabel( $exceptionClass );
		$summary  = sprintf( 'Yahoo!ショッピング %s (HTTP %d)', $friendly, $statusCode );

		if ( $errorToken !== null ) {
			$summary .= sprintf( ': %s', $errorToken );
		}

		if ( $errorMessage !== null ) {
			$summary .= sprintf( ' — %s', $errorMessage );
		}

		return $summary;
	}

	/**
	 * Render a short label for the chosen §10.2 subclass.
	 *
	 * @param class-string<ProviderException> $exceptionClass Chosen subclass.
	 */
	private function friendlyClassLabel( string $exceptionClass ): string {
		switch ( $exceptionClass ) {
			case AuthenticationException::class:
				return 'authentication failure';
			case RateLimitExceededException::class:
				return 'rate-limit refusal';
			case TemporaryUnavailableException::class:
				return 'temporary outage';
			case ProviderRequestException::class:
				return 'request rejected';
			case ProviderResponseException::class:
				return 'response contract violation';
			default:
				return 'failure';
		}
	}

	/**
	 * Decode the response body as JSON and return the resulting
	 * associative array, or null when the body is empty / not
	 * valid JSON / not an object at the top level.
	 *
	 * @param HttpResponse $response Response under test.
	 *
	 * @return array<string, mixed>|null Decoded envelope, or null.
	 */
	private function decodeBody( HttpResponse $response ): ?array {
		if ( $response->body() === '' ) {
			return null;
		}

		try {
			/** @var mixed $decoded */
			$decoded = $response->jsonBody();
		} catch ( \InvalidArgumentException $unused ) {
			unset( $unused );
			return null;
		}

		if ( ! is_array( $decoded ) ) {
			return null;
		}

		if ( $decoded !== array() && array_is_list( $decoded ) ) {
			return null;
		}

		/** @var array<string, mixed> $decoded */
		return $decoded;
	}

	/**
	 * Extract the Yahoo-native error code from the decoded
	 * envelope.
	 *
	 * Yahoo's商品検索 v3 returns errors in either a top-level
	 * `Error` object or in an `Errors[]` list. The mapper
	 * accepts both shapes.
	 *
	 * @param array<string, mixed>|null $envelope Decoded envelope.
	 */
	private function extractErrorCode( ?array $envelope ): ?string {
		if ( $envelope === null ) {
			return null;
		}

		$singular = $envelope['Error'] ?? null;
		if ( is_array( $singular ) ) {
			$code = $singular['Code'] ?? null;
			if ( is_string( $code ) ) {
				$trimmed = trim( $code );
				if ( $trimmed !== '' ) {
					return $trimmed;
				}
			}
		}

		$plural = $envelope['Errors'] ?? null;
		if ( is_array( $plural ) && $plural !== array() ) {
			$first = array_values( $plural )[0];
			if ( is_array( $first ) ) {
				$code = $first['Code'] ?? null;
				if ( is_string( $code ) ) {
					$trimmed = trim( $code );
					if ( $trimmed !== '' ) {
						return $trimmed;
					}
				}
			}
		}

		// Fallback for low-level error envelopes that surface a
		// plain `code` key.
		$flatCode = $envelope['code'] ?? null;
		if ( is_string( $flatCode ) ) {
			$trimmed = trim( $flatCode );
			if ( $trimmed !== '' ) {
				return $trimmed;
			}
		}

		return null;
	}

	/**
	 * Extract the Yahoo-native error message from the decoded
	 * envelope.
	 *
	 * @param array<string, mixed>|null $envelope Decoded envelope.
	 */
	private function extractErrorMessage( ?array $envelope ): ?string {
		if ( $envelope === null ) {
			return null;
		}

		$singular = $envelope['Error'] ?? null;
		if ( is_array( $singular ) ) {
			$message = $singular['Message'] ?? null;
			if ( is_string( $message ) ) {
				$trimmed = trim( $message );
				if ( $trimmed !== '' ) {
					return $trimmed;
				}
			}
		}

		$plural = $envelope['Errors'] ?? null;
		if ( is_array( $plural ) && $plural !== array() ) {
			$first = array_values( $plural )[0];
			if ( is_array( $first ) ) {
				$message = $first['Message'] ?? null;
				if ( is_string( $message ) ) {
					$trimmed = trim( $message );
					if ( $trimmed !== '' ) {
						return $trimmed;
					}
				}
			}
		}

		$flatMessage = $envelope['message'] ?? null;
		if ( is_string( $flatMessage ) ) {
			$trimmed = trim( $flatMessage );
			if ( $trimmed !== '' ) {
				return $trimmed;
			}
		}

		return null;
	}

	/**
	 * Extract the `Retry-After` response header, when present, as
	 * the verbatim header value (callers can parse it as either
	 * delta-seconds or HTTP-date per RFC 9110).
	 *
	 * @param HttpResponse $response Response under test.
	 */
	private function extractRetryAfter( HttpResponse $response ): ?string {
		$value = $response->header( 'Retry-After' );
		if ( $value === null ) {
			return null;
		}

		$trimmed = trim( $value );
		return $trimmed === '' ? null : $trimmed;
	}
}
