<?php
/**
 * REST provider-exception mapper.
 *
 * Pure-function mapper that translates a Phase-3
 * {@see \CommerceLinkBuilder\Domain\Provider\Exception\ProviderException}
 * subclass (or any other {@see \Throwable}) into a §4.3 / §15.2
 * {@see ErrorEnvelope} the REST controller can return verbatim.
 *
 * Mapping rules match `specifications/rest-api-spec.md` §14.1 / §15.2
 * and `specifications/provider-api-contracts.md` §10.2 exactly:
 *
 *   - {@see ProductNotFoundException} → `clb_product_not_found`,
 *     status 404, retryable false.
 *
 *   - {@see AuthenticationException} → `clb_provider_unauthorized`,
 *     status 503, retryable false. The `provider` key is forwarded
 *     into `details` so admin diagnostics surfaces can attribute the
 *     failure to a specific provider implementation.
 *
 *   - {@see RateLimitExceededException} → `clb_rate_limited`,
 *     status 429, retryable true. The `provider` key is forwarded
 *     into `details`; a §12.3 `retry_after` context entry, when
 *     present, is forwarded as `retry_after_seconds` so the API
 *     client surfaces a normalized name.
 *
 *   - {@see TemporaryUnavailableException} → `clb_provider_unavailable`,
 *     status 503, retryable true.
 *
 *   - {@see ProviderConfigurationException} → `clb_provider_misconfigured`,
 *     status 503, retryable false.
 *
 *   - {@see UnsupportedFeatureException} → `clb_provider_unsupported_feature`,
 *     status 400, retryable false.
 *
 *   - {@see ProviderResponseException} → `clb_provider_response_invalid`,
 *     status 503, retryable true. When the exception carries a
 *     {@see ProviderResponseException::REASON_POLICY_VIOLATION}
 *     `reason` context entry, the mapping is overridden to
 *     `clb_provider_policy_violation` / status 422 / retryable false
 *     per the §10.2 "policy violation is not a separate exception
 *     class" convention.
 *
 *   - {@see ProviderRequestException} → `clb_provider_request_failed`,
 *     status 503, retryable true. (502 would be a more natural
 *     status code for the "bad upstream response / request" buckets,
 *     but §14.1 deliberately does NOT enumerate 502; the
 *     {@see ErrorEnvelope} closed-set guard would reject it. 503
 *     is used as the closest in-set bucket for "upstream not
 *     reachable / upstream not usable".)
 *
 *   - Any other {@see ProviderException} subclass (the abstract
 *     base or an unknown future subclass) → `clb_provider_error`,
 *     status 503, retryable true.
 *
 *   - Any non-{@see ProviderException} {@see \Throwable} →
 *     `clb_internal_error`, status 500, retryable false. The
 *     envelope's human-readable `message` is the literal string
 *     `'Internal server error.'` to avoid leaking internal
 *     diagnostic detail to end-users; the original exception's
 *     class and message are NOT forwarded into the envelope at this
 *     layer. (A future debug-mode hook can add an opt-in
 *     pass-through; this slice deliberately defaults to the safe
 *     posture.)
 *
 * For every {@see ProviderException} subclass, the `provider` key
 * exposed by {@see ProviderException::provider()} is forwarded into
 * the envelope's `details` map via the
 * {@see self::extractDetails()} helper so observability surfaces
 * have a stable attribution field. Subclass-specific helpers (for
 * example the rate-limit `retry_after` lookup) are layered on top
 * of that base map, not via independent extraction.
 *
 * The mapper is stateless and exposes a single public method,
 * {@see self::map()}; callers SHOULD instantiate one instance per
 * REST service-provider scope and reuse it across controllers.
 *
 * @package CommerceLinkBuilder\Presentation\Rest\Response
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Rest\Response;

use CommerceLinkBuilder\Domain\Provider\Exception\AuthenticationException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProductNotFoundException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderConfigurationException;
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\Exception\UnsupportedFeatureException;
use CommerceLinkBuilder\Presentation\Rest\RequestContext;
use Throwable;

/**
 * Pure-function mapper translating Phase-3 provider exceptions
 * (and arbitrary throwables) into §4.3 / §15.2 error envelopes.
 *
 * See the file-level docblock for the per-subclass mapping table,
 * the §10.2 policy-violation override, and the safe-default
 * posture used for non-provider throwables.
 */
final class ProviderExceptionMapper {

	/**
	 * Map an exception (any {@see Throwable}) to a §4.3 error
	 * envelope.
	 *
	 * The mapping table is documented on the class-level docblock.
	 *
	 * @param Throwable      $exception Exception to translate.
	 * @param RequestContext $ctx       Per-request meta source for
	 *                                  the resulting envelope.
	 */
	public function map( Throwable $exception, RequestContext $ctx ): ErrorEnvelope {
		if ( $exception instanceof ProductNotFoundException ) {
			return ErrorEnvelope::fromContext(
				ErrorEnvelope::CODE_PRODUCT_NOT_FOUND,
				$this->safeMessage( $exception, 'Product not found.' ),
				ErrorEnvelope::STATUS_NOT_FOUND,
				$ctx,
				$this->extractDetails( $exception ),
				false
			);
		}

		if ( $exception instanceof AuthenticationException ) {
			return ErrorEnvelope::fromContext(
				'clb_provider_unauthorized',
				$this->safeMessage( $exception, 'Provider rejected the supplied credentials.' ),
				ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE,
				$ctx,
				$this->extractDetails( $exception ),
				false
			);
		}

		if ( $exception instanceof RateLimitExceededException ) {
			$details = $this->extractDetails( $exception );
			$context = $exception->context();
			if ( array_key_exists( 'retry_after', $context ) ) {
				$details['retry_after_seconds'] = $context['retry_after'];
			}

			return ErrorEnvelope::fromContext(
				ErrorEnvelope::CODE_RATE_LIMITED,
				$this->safeMessage( $exception, 'Provider rate limit exceeded.' ),
				ErrorEnvelope::STATUS_TOO_MANY_REQUESTS,
				$ctx,
				$details,
				true
			);
		}

		if ( $exception instanceof TemporaryUnavailableException ) {
			return ErrorEnvelope::fromContext(
				ErrorEnvelope::CODE_PROVIDER_UNAVAILABLE,
				$this->safeMessage( $exception, 'Provider is temporarily unavailable.' ),
				ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE,
				$ctx,
				$this->extractDetails( $exception ),
				true
			);
		}

		if ( $exception instanceof ProviderConfigurationException ) {
			return ErrorEnvelope::fromContext(
				'clb_provider_misconfigured',
				$this->safeMessage( $exception, 'Provider configuration is incomplete.' ),
				ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE,
				$ctx,
				$this->extractDetails( $exception ),
				false
			);
		}

		if ( $exception instanceof UnsupportedFeatureException ) {
			return ErrorEnvelope::fromContext(
				'clb_provider_unsupported_feature',
				$this->safeMessage( $exception, 'Provider does not support the requested feature.' ),
				ErrorEnvelope::STATUS_BAD_REQUEST,
				$ctx,
				$this->extractDetails( $exception ),
				false
			);
		}

		if ( $exception instanceof ProviderResponseException ) {
			$context = $exception->context();
			$reason  = $context['reason'] ?? null;
			if ( $reason === ProviderResponseException::REASON_POLICY_VIOLATION ) {
				return ErrorEnvelope::fromContext(
					'clb_provider_policy_violation',
					$this->safeMessage( $exception, 'Provider rejected the request on policy grounds.' ),
					ErrorEnvelope::STATUS_UNPROCESSABLE_ENTITY,
					$ctx,
					$this->extractDetails( $exception ),
					false
				);
			}

			return ErrorEnvelope::fromContext(
				'clb_provider_response_invalid',
				$this->safeMessage( $exception, 'Provider returned an invalid response.' ),
				ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE,
				$ctx,
				$this->extractDetails( $exception ),
				true
			);
		}

		if ( $exception instanceof ProviderRequestException ) {
			return ErrorEnvelope::fromContext(
				'clb_provider_request_failed',
				$this->safeMessage( $exception, 'Provider request failed.' ),
				ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE,
				$ctx,
				$this->extractDetails( $exception ),
				true
			);
		}

		if ( $exception instanceof ProviderException ) {
			return ErrorEnvelope::fromContext(
				'clb_provider_error',
				$this->safeMessage( $exception, 'Provider error.' ),
				ErrorEnvelope::STATUS_SERVICE_UNAVAILABLE,
				$ctx,
				$this->extractDetails( $exception ),
				true
			);
		}

		// Non-provider throwable: never expose internal diagnostic
		// detail through the envelope's message field. The literal
		// "Internal server error." string is the safe default; a
		// future debug-mode hook can add an opt-in pass-through.
		return ErrorEnvelope::fromContext(
			ErrorEnvelope::CODE_INTERNAL_ERROR,
			'Internal server error.',
			ErrorEnvelope::STATUS_INTERNAL_SERVER_ERROR,
			$ctx,
			array(),
			false
		);
	}

	/**
	 * Pull the canonical `provider` field off a
	 * {@see ProviderException} into a fresh details map.
	 *
	 * Subclass-specific augmentation (rate-limit retry-after,
	 * unsupported-feature `feature` key, ...) is layered by the
	 * caller on top of this base map.
	 *
	 * @param ProviderException $exception Exception to inspect.
	 *
	 * @return array<string, mixed>
	 */
	private function extractDetails( ProviderException $exception ): array {
		return array(
			'provider' => $exception->provider(),
		);
	}

	/**
	 * Return a safe, non-empty human-readable message for the
	 * envelope.
	 *
	 * The provider exception's own message is forwarded when
	 * non-empty (per §10.1 the raise site is responsible for not
	 * including secrets); otherwise the supplied fallback is used
	 * so the envelope's `message` invariant (non-empty) is always
	 * satisfied.
	 *
	 * @param Throwable $exception Exception to inspect.
	 * @param string    $fallback  Fallback message when the
	 *                             exception message is empty.
	 */
	private function safeMessage( Throwable $exception, string $fallback ): string {
		$message = trim( $exception->getMessage() );
		return $message === '' ? $fallback : $message;
	}
}
