<?php
/**
 * Rakuten error mapper.
 *
 * Translates Rakuten Web Service IchibaItem Search failure responses
 * into the cross-provider §10.2 exception hierarchy. Provider
 * implementations dispatch a request through the
 * {@see \Affilun\Domain\Http\HttpClient}, hand the
 * resulting non-2xx {@see HttpResponse} to this mapper, and let the
 * mapper raise the matching
 * {@see \Affilun\Domain\Provider\Exception\ProviderException}
 * subclass from `provider-api-contracts.md` §10.2.
 *
 * Design notes:
 *
 *   - The mapper sits between the transport layer and the provider
 *     orchestration. Transport-level failures (DNS, TCP, TLS) never
 *     reach this class — they surface as
 *     {@see \Affilun\Domain\Http\HttpClientException}
 *     and are translated to
 *     {@see \Affilun\Domain\Provider\Exception\TemporaryUnavailableException}
 *     by the provider layer (slice 4g) before this mapper would see
 *     them.
 *
 *   - 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 — a 401 / 403 is
 *          (almost) always an authentication failure, a 429 is
 *          always a rate-limit refusal, a 5xx is always a transient
 *          outage.
 *
 *       2. The Rakuten-native `error` token in the JSON body when
 *          present. Rakuten's IchibaItem Search returns a small
 *          enum of error codes — `wrong_parameter`,
 *          `not_found_application`, `wrong_format`,
 *          `unauthorized_user`, `system_error`,
 *          `service_unavailable`, ... — and the mapper uses these
 *          to refine the status-class default. For example, a 400
 *          with `error: "not_found_application"` is an
 *          authentication / configuration failure, not a generic
 *          request-shape failure.
 *
 *     Both axes are consulted because the upstream's documentation
 *     does not commit to a stable status-code-to-error mapping;
 *     observed responses sometimes return 200 with an error
 *     envelope (when CDN caching is involved), 400 with no body
 *     (rate-limit refusals from the front-end gateway), or 5xx with
 *     a structured error envelope. The mapper prefers the more
 *     specific signal when the two disagree.
 *
 *   - The translation table is held as a `ERROR_CODE_MAP` class
 *     constant mapping each known Rakuten error token to the
 *     §10.2 subclass FQCN. Unknown tokens fall back to the
 *     status-class default. Adding a new known token is a single-
 *     entry change to the constant.
 *
 *   - Per `provider-api-contracts.md` §10, every produced
 *     exception carries a structured `context` map with at least:
 *
 *       * `status_code` — the HTTP status code observed.
 *       * `rakuten_error` — the upstream error token, or null.
 *       * `rakuten_error_description` — the upstream human-
 *         readable description, or null.
 *       * `request_id` — the upstream request id, when available.
 *
 *     Per §10.1 the context map MUST NOT carry credential material
 *     (applicationId, accessKey, affiliateId). Provider implementations that
 *     stamp credentials onto the request via
 *     {@see \Affilun\Domain\Http\HttpRequest::withQueryParameter()}
 *     are responsible for redacting before constructing the
 *     {@see \Affilun\Domain\Http\HttpClientException}
 *     they hand to this mapper; this mapper itself never receives
 *     the originating request, so it cannot leak credentials by
 *     accident.
 *
 *   - The mapper accepts a {@see HttpResponse} that may or may not
 *     carry a JSON body. A non-JSON body (for example, the bare
 *     503 HTML page Rakuten's CDN serves during planned
 *     maintenance) decodes to a missing `error` token, and the
 *     mapper degrades gracefully to the status-class default.
 *     A malformed JSON body is treated the same way — the goal is
 *     to produce a §10.2 exception in every failure case, not to
 *     fail the failure-translation path on a parse error.
 *
 * Failure semantics summary:
 *
 *   - 2xx: this mapper is not the right surface; calling it on a
 *     successful response raises {@see \LogicException} so a
 *     programming error in the provider orchestration surfaces at
 *     the type system boundary.
 *
 *   - 401 / 403, or any 4xx/5xx with a known authentication-class
 *     Rakuten error token → {@see AuthenticationException}.
 *
 *   - 429, or any 4xx/5xx with a known rate-limit-class Rakuten
 *     error token → {@see RateLimitExceededException}.
 *
 *   - 4xx with a known request-shape-class Rakuten error token →
 *     {@see ProviderRequestException}.
 *
 *   - 5xx, or any 4xx/5xx with a known transient-class Rakuten
 *     error token → {@see TemporaryUnavailableException}.
 *
 *   - 4xx with an unknown Rakuten error token →
 *     {@see ProviderRequestException} (the upstream rejected the
 *     request shape and we do not know more).
 *
 *   - Anything else → {@see ProviderResponseException} (the
 *     response itself violates the cross-provider contract).
 *
 * @package Affilun\Infrastructure\Provider\Rakuten
 */

declare(strict_types=1);

namespace Affilun\Infrastructure\Provider\Rakuten;

use Affilun\Domain\Http\HttpResponse;
use Affilun\Domain\Provider\Exception\AuthenticationException;
use Affilun\Domain\Provider\Exception\ProviderException;
use Affilun\Domain\Provider\Exception\ProviderRequestException;
use Affilun\Domain\Provider\Exception\ProviderResponseException;
use Affilun\Domain\Provider\Exception\RateLimitExceededException;
use Affilun\Domain\Provider\Exception\TemporaryUnavailableException;
use LogicException;

/**
 * Maps Rakuten Web Service IchibaItem Search failure responses to
 * the §10.2 cross-provider exception hierarchy.
 *
 * See the file-level docblock for the two-axis classification
 * (HTTP status + Rakuten error token), the structured context
 * fields every produced exception carries, and the §10.1 secret-
 * material rule that binds the surrounding orchestration code.
 */
final class RakutenErrorMapper {

	/**
	 * §4 stable provider key for Rakuten. Every produced exception
	 * carries this key on its `provider` axis so observability
	 * surfaces can attribute the failure correctly.
	 */
	public const PROVIDER_KEY = 'rakuten';

	/**
	 * Mapping from Rakuten-native `error` tokens to the §10.2
	 * subclass FQCN that should be raised.
	 *
	 * The set covers the error codes documented at
	 * https://webservice.rakuten.co.jp/documentation plus a few
	 * that have been observed in production but are not in the
	 * canonical reference. Unknown tokens fall back to the
	 * status-class default — see {@see self::map()} for the
	 * fallback ladder.
	 *
	 * @var array<string, class-string<ProviderException>>
	 */
	private const ERROR_CODE_MAP = array(
		// Authentication / configuration failures.
		'unauthorized_user'      => AuthenticationException::class,
		'not_found_application'  => AuthenticationException::class,
		'expired_application'    => AuthenticationException::class,
		'invalid_developer_id'   => AuthenticationException::class,
		'invalid_application_id' => AuthenticationException::class,
		'invalid_access_key'     => AuthenticationException::class,
		'missing_access_key'     => AuthenticationException::class,
		'invalid_affiliate_id'   => AuthenticationException::class,

		// Rate-limit refusals.
		'too_many_requests'      => RateLimitExceededException::class,
		'rate_limit_exceeded'    => RateLimitExceededException::class,

		// Request-shape failures.
		'wrong_parameter'        => ProviderRequestException::class,
		'wrong_format'           => ProviderRequestException::class,
		'invalid_parameter'      => ProviderRequestException::class,
		'parameter_required'     => ProviderRequestException::class,
		'parameter_too_long'     => ProviderRequestException::class,

		// Transient-failure tokens.
		'system_error'           => TemporaryUnavailableException::class,
		'service_unavailable'    => TemporaryUnavailableException::class,
		'timeout'                => TemporaryUnavailableException::class,
		'temporary_failure'      => TemporaryUnavailableException::class,
	);

	/**
	 * Translate a non-2xx Rakuten response into a §10.2 exception.
	 *
	 * Lifecycle:
	 *
	 *   1. Reject 2xx responses with {@see \LogicException} — this
	 *      mapper is the failure-translation surface and a
	 *      successful response is a programming error in the
	 *      provider orchestration.
	 *
	 *   2. Decode the response body as JSON when present. A
	 *      decode failure or non-array result is tolerated: the
	 *      mapper falls back to the status-class default.
	 *
	 *   3. Extract the Rakuten-native `error` /
	 *      `error_description` / `request_id` fields from the
	 *      decoded body when present.
	 *
	 *   4. Pick the §10.2 subclass via {@see self::ERROR_CODE_MAP}
	 *      when the error token is known, or via the status-class
	 *      default when it is not.
	 *
	 *   5. Build the structured context map and the human-readable
	 *      summary, then construct the chosen subclass.
	 *
	 * @param HttpResponse $response The non-2xx response from the
	 *                               upstream that the provider
	 *                               layer wants translated.
	 *
	 * @return ProviderException A concrete §10.2 subclass instance
	 *                           ready to be thrown.
	 *
	 * @throws LogicException When `$response` carries a 2xx status
	 *                        — this mapper is not the right
	 *                        surface for successful responses.
	 */
	public function map( HttpResponse $response ): ProviderException {
		$statusCode = $response->statusCode();

		if ( $response->isSuccessful() ) {
			$message = sprintf(
				'RakutenErrorMapper::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 );
		$gatewayErrors    = is_array( $envelope['errors'] ?? null )
			? $envelope['errors']
			: array();
		$errorToken       = $this->extractToken( $envelope, 'error' );
		$errorDescription = $this->extractToken( $envelope, 'error_description' );
		$requestId        = $this->extractToken( $envelope, 'request_id' );
		$gatewayErrorCode = $this->extractScalarToken( $gatewayErrors, 'errorCode' );
		$gatewayMessage   = $this->extractToken( $gatewayErrors, 'errorMessage' );

		if ( $errorToken === null ) {
			$errorToken = $gatewayErrorCode;
		}
		if ( $errorDescription === null ) {
			$errorDescription = $gatewayMessage;
		}

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

		$context = array(
			'status_code'               => $statusCode,
			'rakuten_error'             => $errorToken,
			'rakuten_error_description' => $errorDescription,
			'request_id'                => $requestId,
			'gateway_error_code'        => $gatewayErrorCode,
		);

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

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

		return $exception;
	}

	/**
	 * Closed set of Rakuten-native error tokens this mapper
	 * recognizes via {@see self::ERROR_CODE_MAP}, in declaration
	 * order.
	 *
	 * Exposed for tests and for diagnostics surfaces that want to
	 * enumerate the recognized tokens (for example, populating an
	 * admin "known Rakuten error codes" reference table) without
	 * duplicating the list.
	 *
	 * @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:
	 *
	 *   - 401 / 403 → {@see AuthenticationException}
	 *   - 429 → {@see RateLimitExceededException}
	 *   - Other 4xx → {@see ProviderRequestException}
	 *   - 5xx → {@see TemporaryUnavailableException}
	 *   - 1xx / 3xx → {@see ProviderResponseException} (the
	 *     upstream returned a response that the cross-provider
	 *     model cannot interpret as a clean failure).
	 *
	 * @param int         $statusCode       HTTP status code on the response.
	 * @param string|null $errorToken       Rakuten-native error token, or null.
	 * @param string|null $errorDescription Upstream error description, or null.
	 *
	 * @return class-string<ProviderException>
	 */
	private function pickExceptionClass(
		int $statusCode,
		?string $errorToken,
		?string $errorDescription
	): string {
		if ( $errorToken !== null && array_key_exists( $errorToken, self::ERROR_CODE_MAP ) ) {
			return self::ERROR_CODE_MAP[ $errorToken ];
		}

		if ( $statusCode >= 400
			&& $statusCode <= 499
			&& $this->describesCredentialFailure( $errorDescription )
		) {
			return AuthenticationException::class;
		}

		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;
	}

	/**
	 * Detect current-gateway messages for account-wide credential
	 * failures that arrive as a generic HTTP 400 envelope.
	 *
	 * @param string|null $description Upstream error description.
	 */
	private function describesCredentialFailure( ?string $description ): bool {
		if ( $description === null ) {
			return false;
		}

		$normalized = strtolower( $description );

		return strpos( $normalized, 'accesskey' ) !== false
			|| strpos( $normalized, 'access key' ) !== false
			|| strpos( $normalized, 'applicationid' ) !== false
			|| strpos( $normalized, 'application id' ) !== false;
	}

	/**
	 * Build the human-readable summary for the produced exception.
	 *
	 * The summary names the §10.2 subclass, the HTTP status, and
	 * the Rakuten-native token / description when present. Per
	 * §10.1 the message MUST NOT carry credential material; the
	 * mapper has no access to credentials, so the rule is
	 * satisfied by construction.
	 *
	 * @param class-string<ProviderException> $exceptionClass    Chosen subclass.
	 * @param int                             $statusCode        HTTP status code.
	 * @param string|null                     $errorToken        Rakuten-native error token.
	 * @param string|null                     $errorDescription  Rakuten-native description.
	 */
	private function buildMessage(
		string $exceptionClass,
		int $statusCode,
		?string $errorToken,
		?string $errorDescription
	): string {
		$friendly = $this->friendlyClassLabel( $exceptionClass );
		$summary  = sprintf( 'Rakuten %s (HTTP %d)', $friendly, $statusCode );

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

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

		return $summary;
	}

	/**
	 * Render a short label for the chosen §10.2 subclass.
	 *
	 * Used by {@see self::buildMessage()} so log lines and
	 * diagnostics views can grep for "Rakuten authentication" /
	 * "Rakuten rate limit" / "Rakuten temporary outage" without
	 * mining the FQCN.
	 *
	 * @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.
	 *
	 * The mapper deliberately tolerates a missing or malformed
	 * body so the failure-translation path always produces a
	 * §10.2 exception. A "we could not even parse the error
	 * envelope" outcome is itself useful diagnostic information,
	 * captured implicitly by the resulting null `rakuten_error`
	 * context entry.
	 *
	 * @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;
		}

		// Reject positional arrays — we only consume associative
		// envelopes here. PHP's json_decode does not distinguish
		// between [] and {}, so guard explicitly.
		if ( $decoded !== array() && array_is_list( $decoded ) ) {
			return null;
		}

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

	/**
	 * Extract a string token from the decoded envelope, returning
	 * null when the field is missing, not a string, or empty after
	 * trimming.
	 *
	 * @param array<string, mixed>|null $envelope Decoded envelope.
	 * @param string                    $field    Field name to read.
	 */
	private function extractToken( ?array $envelope, string $field ): ?string {
		if ( $envelope === null ) {
			return null;
		}

		if ( ! array_key_exists( $field, $envelope ) ) {
			return null;
		}

		$value = $envelope[ $field ];
		if ( ! is_string( $value ) ) {
			return null;
		}

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

	/**
	 * Extract a string or integer gateway error code.
	 *
	 * @param array<string, mixed> $envelope Decoded gateway error object.
	 * @param string               $field    Field name to read.
	 */
	private function extractScalarToken( array $envelope, string $field ): ?string {
		if ( ! array_key_exists( $field, $envelope ) ) {
			return null;
		}

		$value = $envelope[ $field ];
		if ( ! is_string( $value ) && ! is_int( $value ) ) {
			return null;
		}

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