<?php
/**
 * Amazon Creators API error mapper.
 *
 * Phase B (KI-019, ADR-0022) collaborator that inspects a
 * non-2xx Amazon Creators API HTTP response and translates it
 * into the appropriate §10.2 provider exception subclass so the
 * upstream sync / search pipelines can branch deterministically
 * on the failure category without having to reason about
 * Amazon-specific HTTP status codes themselves.
 *
 * Why this class exists:
 *
 *   - The Creators API surfaces failures through a small but
 *     varied set of HTTP status codes (400 / 401 / 403 / 404 /
 *     429 / 500 / 503) and a JSON envelope that carries a
 *     human-readable `Errors[].Message` (or `errors[].message`,
 *     depending on the operation) plus a machine-readable
 *     `Errors[].Code` token. The codebase models failure
 *     categories at the domain layer through the §10.2 provider
 *     exception hierarchy
 *     ({@see AuthenticationException} /
 *     {@see RateLimitExceededException} /
 *     {@see TemporaryUnavailableException} /
 *     {@see ProductNotFoundException} /
 *     {@see ProviderRequestException} /
 *     {@see ProviderResponseException}); this mapper is the
 *     single place that knows the wire-shape -> exception-shape
 *     translation.
 *
 *   - Centralizing the mapping here keeps
 *     {@see AmazonCreatorsApiProvider} free of HTTP-status
 *     branching and lets the test suite exercise every
 *     status-code / error-token combination through this single
 *     class rather than duplicating fixtures across the provider
 *     and its collaborators.
 *
 * Mapping policy:
 *
 *   - HTTP 200..299 — never reaches this mapper (the calling
 *     provider only invokes this class on non-2xx responses).
 *
 *   - HTTP 400 — Bad request. Ambiguous between "credentials
 *     are bad" and "request body is malformed"; we inspect the
 *     decoded error code when present and surface as
 *     {@see AuthenticationException} for credential-shaped
 *     codes (`InvalidParameterValue` containing `partnerTag` or
 *     `credentialId`, `InvalidSignature`, `InvalidAssociate`)
 *     and {@see ProviderRequestException} otherwise.
 *
 *   - HTTP 401 / 403 — {@see AuthenticationException}
 *     unconditionally. The token provider already classifies
 *     401/403 on the token endpoint itself; reaching this
 *     mapper with a 401/403 means the catalog API rejected the
 *     credentials post-token-fetch (e.g. associate not approved
 *     for the marketplace, partner tag suspended, region
 *     mismatch).
 *
 *   - HTTP 404 — Maps to {@see ProductNotFoundException} when
 *     the request was a GetItems lookup for a single ASIN, and
 *     to {@see ProviderRequestException} otherwise (a 404 on
 *     SearchItems is structurally surprising and deserves the
 *     generic request-failed branch). The lookup-vs-search
 *     discrimination is provided by the caller through the
 *     `$productId` parameter on
 *     {@see self::mapResponse()}.
 *
 *   - HTTP 429 — {@see RateLimitExceededException}.
 *
 *   - HTTP 5xx — {@see TemporaryUnavailableException}. This
 *     covers Amazon's documented 500 / 502 / 503 / 504 codes;
 *     callers SHOULD honor any retry-after hint the surrounding
 *     pipeline carries (the §13 sync worker handles that
 *     orthogonally to this mapper).
 *
 *   - Any other non-2xx (3xx, exotic 4xx) — wrapped as
 *     {@see ProviderRequestException} with the status code in
 *     the context payload so log readers can correlate.
 *
 * Body parsing posture:
 *
 *   - Body parsing is best-effort. A successful JSON decode
 *     surfaces the upstream `errors[].code` and
 *     `errors[].message` in the exception's structured context
 *     for log correlation; an unparseable body still produces a
 *     valid exception (with `body_parse_failed` in the context)
 *     so a malformed Amazon response never propagates as an
 *     uncaught {@see JsonException}.
 *
 *   - The mapper accepts both PascalCase (`Errors[].Message`)
 *     and lowerCamelCase (`errors[].message`) error envelopes
 *     because Amazon's documentation is inconsistent across
 *     operations and we'd rather pay the small inspection cost
 *     than crash on a casing variant.
 *
 * Failure semantics this class does NOT take responsibility for:
 *
 *   - Token-fetch failures. Those originate in
 *     {@see AmazonCreatorsApiTokenProvider} which raises the
 *     same §10.2 exception subclasses directly.
 *
 *   - Transport failures (DNS, TLS, connect timeout). Those are
 *     wrapped by {@see AmazonCreatorsApiProvider} as
 *     {@see ProviderRequestException} at the
 *     {@see HttpClient::send()} call site; this mapper is only
 *     invoked once an {@see HttpResponse} (with a status code)
 *     has been received.
 *
 *   - Successful 2xx responses with malformed bodies. Those are
 *     {@see AmazonCreatorsApiResponseNormalizer}'s responsibility.
 *
 * @package CommerceLinkBuilder\Infrastructure\Provider\Amazon\CreatorsApi
 */

declare(strict_types=1);

// phpcs:disable Generic.Files.LineLength.TooLong -- Developer-facing exception strings include upstream context.

namespace CommerceLinkBuilder\Infrastructure\Provider\Amazon\CreatorsApi;

use CommerceLinkBuilder\Domain\Http\HttpResponse;
use CommerceLinkBuilder\Domain\Provider\Exception\AuthenticationException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProductNotFoundException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderException;
use CommerceLinkBuilder\Domain\Provider\Exception\ProviderRequestException;
use CommerceLinkBuilder\Domain\Provider\Exception\RateLimitExceededException;
use CommerceLinkBuilder\Domain\Provider\Exception\TemporaryUnavailableException;
use CommerceLinkBuilder\Domain\Provider\ProviderProductId;
use JsonException;

/**
 * Translate non-2xx Creators API HTTP responses into the §10.2
 * provider exception hierarchy.
 *
 * See the file-level docblock for the per-status-code mapping
 * policy, the credential-shaped 400 inspection rule, the
 * lookup-vs-search 404 discrimination, the best-effort error
 * body parsing posture, and the explicit list of failure
 * categories this class does NOT take responsibility for.
 */
final class AmazonCreatorsApiErrorMapper {

	/**
	 * §4 stable provider key stamped on every exception this
	 * class raises.
	 *
	 * Mirrors {@see AmazonCreatorsApiProvider::PROVIDER_KEY} but
	 * is duplicated here as a class constant so this mapper can
	 * be loaded and exercised in tests without forcing the full
	 * provider class graph to be autoloaded first.
	 */
	public const PROVIDER_KEY = 'amazon';

	/**
	 * Endpoint label stamped into exception context for the
	 * SearchItems operation. Used so log / audit consumers can
	 * distinguish a search failure from a lookup failure
	 * without parsing the URL.
	 */
	public const ENDPOINT_SEARCH = 'searchItems';

	/**
	 * Endpoint label stamped into exception context for the
	 * GetItems operation.
	 */
	public const ENDPOINT_LOOKUP = 'getItems';

	/**
	 * Closed set of upstream error codes that, when surfaced on
	 * a 400 response, indicate a credential / partner-tag
	 * problem rather than a malformed request body.
	 *
	 * The set is conservative: a code we have not classified is
	 * left to fall through to {@see ProviderRequestException},
	 * which is the safer default (an authentication misroute
	 * surfaces only as "request failed" rather than crying wolf
	 * on credential rotation flows).
	 *
	 * @var list<string>
	 */
	private const CREDENTIAL_SHAPED_400_CODES = array(
		'InvalidAssociate',
		'InvalidPartnerTag',
		'InvalidSignature',
		'AccessDenied',
		'AccessDeniedAwsUsers',
		'UnrecognizedClient',
	);

	/**
	 * Translate the supplied non-2xx HTTP response into the
	 * appropriate §10.2 provider exception.
	 *
	 * Callers MUST only invoke this method on responses whose
	 * {@see HttpResponse::statusCode()} is outside the 2xx
	 * range; this method does not validate that precondition
	 * because the only sensible behavior on a 2xx response would
	 * be a no-op + log noise, and the calling provider already
	 * checks the status before deciding to invoke the mapper.
	 *
	 * @param HttpResponse           $response  The non-2xx HTTP
	 *                                          response Amazon
	 *                                          returned.
	 * @param string                 $endpoint  One of
	 *                                          {@see self::ENDPOINT_SEARCH}
	 *                                          or
	 *                                          {@see self::ENDPOINT_LOOKUP}.
	 *                                          Stamped into the
	 *                                          exception context
	 *                                          and used for the
	 *                                          lookup-vs-search
	 *                                          404 branch.
	 * @param ProviderProductId|null $productId Provider-scoped
	 *                                          product identifier
	 *                                          when the failing
	 *                                          request was a
	 *                                          single-ASIN lookup;
	 *                                          null for search and
	 *                                          batch contexts. Used
	 *                                          by the 404 branch
	 *                                          to construct
	 *                                          {@see ProductNotFoundException}.
	 */
	public function mapResponse(
		HttpResponse $response,
		string $endpoint,
		?ProviderProductId $productId = null
	): ProviderException {
		$status  = $response->statusCode();
		$body    = (string) $response->body();
		$decoded = self::decodeErrorEnvelope( $body );

		$context = array(
			'endpoint'    => $endpoint,
			'http_status' => $status,
		);
		if ( $decoded['parsed'] ) {
			if ( $decoded['code'] !== null ) {
				$context['upstream_code'] = $decoded['code'];
			}
			if ( $decoded['message'] !== null ) {
				$context['upstream_message'] = $decoded['message'];
			}
		} else {
			$context['body_parse_failed'] = true;
		}

		$summary = self::buildSummary( $endpoint, $status, $decoded );

		// 401 / 403 — unambiguous credential rejection.
		if ( $status === 401 || $status === 403 ) {
			return new AuthenticationException(
				self::PROVIDER_KEY,
				$summary,
				$context
			);
		}

		// 404 — lookup-vs-search branch.
		if ( $status === 404 ) {
			if ( $productId !== null ) {
				$lookupContext                        = $context;
				$lookupContext['provider_product_id'] = $productId->value();
				$lookupContext['reason']              = 'http_404';
				return new ProductNotFoundException(
					self::PROVIDER_KEY,
					$summary,
					$lookupContext
				);
			}
			return new ProviderRequestException(
				self::PROVIDER_KEY,
				$summary,
				$context
			);
		}

		// 429 — rate-limited.
		if ( $status === 429 ) {
			return new RateLimitExceededException(
				self::PROVIDER_KEY,
				$summary,
				$context
			);
		}

		// 5xx — transient upstream issue.
		if ( $status >= 500 && $status < 600 ) {
			return new TemporaryUnavailableException(
				self::PROVIDER_KEY,
				$summary,
				$context
			);
		}

		// 400 — inspect the upstream code to decide between
		// credential-shaped (auth) and body-shaped (request)
		// failures.
		if ( $status === 400 ) {
			if ( $decoded['code'] !== null
				&& in_array( $decoded['code'], self::CREDENTIAL_SHAPED_400_CODES, true )
			) {
				return new AuthenticationException(
					self::PROVIDER_KEY,
					$summary,
					$context
				);
			}
			return new ProviderRequestException(
				self::PROVIDER_KEY,
				$summary,
				$context
			);
		}

		// Anything else (3xx, exotic 4xx) — generic request
		// failure with the status preserved for log correlation.
		return new ProviderRequestException(
			self::PROVIDER_KEY,
			$summary,
			$context
		);
	}

	/**
	 * Compose the human-readable exception message stamped on
	 * the returned {@see ProviderException}.
	 *
	 * Includes the endpoint label, the HTTP status code, and
	 * the upstream `errors[].code` / `errors[].message` when
	 * the body parser successfully extracted them. The message
	 * is intentionally compact — full body content stays in the
	 * exception's structured context, not in the message — so
	 * log lines stay scannable.
	 *
	 * @param string                                               $endpoint Endpoint label.
	 * @param int                                                  $status   HTTP status code.
	 * @param array{parsed: bool, code: ?string, message: ?string} $decoded  Decoded
	 *                                                                       error
	 *                                                                       envelope.
	 */
	private static function buildSummary( string $endpoint, int $status, array $decoded ): string {
		if ( $decoded['parsed'] && $decoded['code'] !== null && $decoded['message'] !== null ) {
			return sprintf(
				'Amazon Creators API %s returned HTTP %d (%s — %s).',
				$endpoint,
				$status,
				$decoded['code'],
				$decoded['message']
			);
		}
		if ( $decoded['parsed'] && $decoded['message'] !== null ) {
			return sprintf(
				'Amazon Creators API %s returned HTTP %d (%s).',
				$endpoint,
				$status,
				$decoded['message']
			);
		}
		if ( $decoded['parsed'] && $decoded['code'] !== null ) {
			return sprintf(
				'Amazon Creators API %s returned HTTP %d (%s).',
				$endpoint,
				$status,
				$decoded['code']
			);
		}
		return sprintf(
			'Amazon Creators API %s returned HTTP %d.',
			$endpoint,
			$status
		);
	}

	/**
	 * Best-effort parse of the Amazon error envelope into a
	 * `(code, message)` tuple.
	 *
	 * Accepts both PascalCase (`Errors[].Code` / `.Message`)
	 * and lowerCamelCase (`errors[].code` / `.message`) shapes
	 * because Amazon's documentation is inconsistent across
	 * operations. Returns a `parsed: false` tuple when the body
	 * is empty, not valid JSON, or does not contain a
	 * recognizable error envelope; the caller stamps a
	 * `body_parse_failed` flag into the exception context in
	 * that case so log readers can spot a malformed Amazon
	 * response distinct from a "no envelope" 4xx.
	 *
	 * @param string $body Raw response body.
	 *
	 * @return array{parsed: bool, code: ?string, message: ?string}
	 */
	private static function decodeErrorEnvelope( string $body ): array {
		$miss = array(
			'parsed'  => false,
			'code'    => null,
			'message' => null,
		);

		if ( $body === '' ) {
			return $miss;
		}

		try {
			$decoded = json_decode( $body, true, 32, JSON_THROW_ON_ERROR );
		} catch ( JsonException $error ) {
			unset( $error );
			return $miss;
		}

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

		$errors = self::pickErrorsList( $decoded );
		if ( $errors === null || $errors === array() ) {
			// Not the standard envelope shape — try the flatter
			// `{message: "..."}` form some auxiliary endpoints
			// (notably the OAuth token endpoint) return.
			$flatMessage = self::pickStringField( $decoded, array( 'message', 'Message', 'error_description', 'error' ) );
			if ( $flatMessage !== null ) {
				return array(
					'parsed'  => true,
					'code'    => self::pickStringField( $decoded, array( 'code', 'Code', 'error' ) ),
					'message' => $flatMessage,
				);
			}
			return $miss;
		}

		$first = $errors[0];
		if ( ! is_array( $first ) ) {
			return $miss;
		}

		return array(
			'parsed'  => true,
			'code'    => self::pickStringField( $first, array( 'Code', 'code' ) ),
			'message' => self::pickStringField( $first, array( 'Message', 'message' ) ),
		);
	}

	/**
	 * Pull the `Errors` / `errors` array out of the decoded
	 * envelope, returning null when neither key is present or
	 * the value is not a list.
	 *
	 * @param array<int|string, mixed> $decoded Decoded JSON root.
	 *
	 * @return list<mixed>|null
	 */
	private static function pickErrorsList( array $decoded ): ?array {
		foreach ( array( 'Errors', 'errors' ) as $key ) {
			if ( array_key_exists( $key, $decoded )
				&& is_array( $decoded[ $key ] )
				&& array_is_list( $decoded[ $key ] )
			) {
				/** @var list<mixed> $list */
				$list = $decoded[ $key ];
				return $list;
			}
		}
		return null;
	}

	/**
	 * Return the first non-empty string value at any of the
	 * supplied candidate keys, or null when none match.
	 *
	 * @param array<int|string, mixed> $payload Map to inspect.
	 * @param list<string>             $keys    Candidate keys in
	 *                                          priority order.
	 */
	private static function pickStringField( array $payload, array $keys ): ?string {
		foreach ( $keys as $key ) {
			$value = $payload[ $key ] ?? null;
			if ( is_string( $value ) ) {
				$trimmed = trim( $value );
				if ( $trimmed !== '' ) {
					return $trimmed;
				}
			}
		}
		return null;
	}
}
