<?php
/**
 * Abstract base for the provider exception hierarchy.
 *
 * Every provider-layer exception in the AFFILUN domain extends this
 * class. The base captures the cross-cutting fields the
 * {@see https://example.invalid/dev/null/provider-api-contracts.md §10}
 * "error contract" (`specifications/provider-api-contracts.md` §10)
 * mandates for every concrete subclass:
 *
 *   - A stable error code string (the §10.2 `error code` column).
 *   - A provider key identifying which provider implementation
 *     raised the exception.
 *   - A structured context array carrying machine-readable diagnostic
 *     fields (request id, retry-after timestamp, response body
 *     digest, etc.) that the application / observability layers can
 *     consume without parsing the human-readable message.
 *
 * Field semantics match `provider-api-contracts.md` §10 exactly:
 *
 *   - `errorCode` is one of the eight stable codes from §10.2
 *     (`authentication_failed`, `rate_limit_exceeded`,
 *     `temporary_unavailable`, `provider_request_failed`,
 *     `provider_response_invalid`, `product_not_found`,
 *     `unsupported_feature`, `provider_misconfigured`). Each
 *     concrete subclass defines its own
 *     {@see self::ERROR_CODE-style} constant and forwards it through
 *     the constructor; the base does not enforce a closed set
 *     because the §10.2 table is the canonical source of truth and
 *     subclasses are deliberately the only entry point that picks
 *     the code.
 *
 *   - `provider` is the stable provider key per
 *     `provider-api-contracts.md` §4 — the same lowercase ASCII key
 *     that {@see \Affilun\Domain\Provider\ProviderProductId}
 *     carries. Required and rejected when empty after trimming so
 *     log lines, REST error envelopes, and operations dashboards can
 *     reliably attribute the failure to a specific provider.
 *
 *   - `context` is a structured `array<string, mixed>` of
 *     additional diagnostic fields. §10.2 specifically calls out the
 *     `PolicyViolation` case as a `reason: "policy_violation"` field
 *     inside this map rather than a separate exception class, and
 *     §12.3 calls out a `retry_after`-shaped field for rate-limit
 *     refusals. Keys MUST be non-empty strings; values are opaque to
 *     the base because the rendering / observability layers each
 *     consume their own subset.
 *
 * Inheritance contract:
 *
 *   - Concrete subclasses MUST extend this base.
 *   - Concrete subclasses SHOULD expose a stable per-class error
 *     code constant (`AuthenticationException::ERROR_CODE`, etc.)
 *     that they pass to the parent constructor. This keeps the
 *     §10.2 table 1:1 with the type system and lets call sites
 *     match on the constant rather than the string.
 *   - The base extends {@see \RuntimeException} (not
 *     {@see \LogicException}) because every code in §10.2 represents
 *     an externally-induced failure mode (network, credentials,
 *     upstream contract drift) that is unavoidable at runtime, not a
 *     programming error the static type system could have caught.
 *
 * `provider-api-contracts.md` §10.1 forbids:
 *
 *   - Collapsing every failure into a bare `RuntimeException`.
 *   - Returning empty arrays / null to mask a failure.
 *   - Including secret material in the exception message.
 *
 * The third rule binds every consumer of {@see self::context()}: the
 * map MUST NOT carry credentials, signing keys, or unredacted
 * authorization headers. Provider implementations are responsible
 * for redacting before populating the context.
 *
 * @package Affilun\Domain\Provider\Exception
 */

declare(strict_types=1);

namespace Affilun\Domain\Provider\Exception;

use InvalidArgumentException;
use RuntimeException;
use Throwable;

/**
 * Abstract base class shared by every provider-layer exception.
 *
 * See the file-level docblock for the mapping to
 * `provider-api-contracts.md` §10, the per-field validation rules,
 * and the inheritance contract concrete subclasses must honor.
 */
abstract class ProviderException extends RuntimeException {

	/**
	 * Stable §10.2 error code carried by this exception.
	 *
	 * Forwarded by concrete subclasses through their `ERROR_CODE`
	 * constant; held verbatim on the base so call sites that catch
	 * at the abstract type can still consult the code without
	 * resorting to `instanceof` chains.
	 */
	private string $errorCode;

	/**
	 * Stable provider key per §4 identifying which provider
	 * implementation raised this exception.
	 */
	private string $provider;

	/**
	 * Structured diagnostic context per §10 / §12.3.
	 *
	 * @var array<string, mixed>
	 */
	private array $context;

	/**
	 * Construct a provider exception with explicit values for every
	 * cross-cutting field.
	 *
	 * The constructor is exhaustive so a programming error in a
	 * concrete subclass (forgetting to pass the error code,
	 * mismatching the provider key against the actual call site)
	 * surfaces at the type system boundary rather than at the
	 * observability layer.
	 *
	 * Validation:
	 *
	 *   - `errorCode` is trimmed and rejected when empty after
	 *     trimming. The base does NOT verify it against the §10.2
	 *     closed set because subclasses are the canonical source of
	 *     truth for the per-class code; verifying here would either
	 *     duplicate the table or force a circular dependency.
	 *
	 *   - `provider` is trimmed and rejected when empty after
	 *     trimming.
	 *
	 *   - `message` is forwarded to {@see RuntimeException::__construct()}
	 *     verbatim (no trimming, no validation) because the §10.1
	 *     "no secret material in the exception message" rule binds
	 *     the caller, not this base — silently mutating the message
	 *     would only obscure a programming error.
	 *
	 *   - `context` keys MUST be non-empty strings; values are
	 *     opaque (mixed). Non-string keys raise
	 *     {@see InvalidArgumentException} so the JSON-encoding
	 *     contract on the §10 error envelope stays stable.
	 *
	 * @param string               $errorCode Stable §10.2 error code.
	 * @param string               $provider  §4 provider key.
	 * @param string               $message   Human-readable, non-secret summary.
	 * @param array<string, mixed> $context   Structured diagnostic context.
	 * @param Throwable|null       $previous  Underlying cause, or null.
	 *
	 * @throws InvalidArgumentException When `errorCode` or `provider`
	 *                                  is empty after trimming, or
	 *                                  when `context` carries a
	 *                                  non-string key.
	 */
	public function __construct(
		string $errorCode,
		string $provider,
		string $message = '',
		array $context = array(),
		?Throwable $previous = null
	) {
		$normalizedErrorCode = trim( $errorCode );
		if ( $normalizedErrorCode === '' ) {
			$invalid = 'ProviderException requires a non-empty error code per provider-api-contracts.md §10.2.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $invalid );
		}

		$normalizedProvider = trim( $provider );
		if ( $normalizedProvider === '' ) {
			$invalid = 'ProviderException requires a non-empty provider key per provider-api-contracts.md §4.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $invalid );
		}

		$this->errorCode = $normalizedErrorCode;
		$this->provider  = $normalizedProvider;
		$this->context   = self::normalizeContext( $context );

		parent::__construct( $message, 0, $previous );
	}

	/**
	 * Stable §10.2 error code carried by this exception.
	 *
	 * Used by REST error envelopes (per the §10 error contract),
	 * log lines, and admin diagnostics views to render a stable
	 * machine-readable identifier for the failure mode.
	 */
	public function errorCode(): string {
		return $this->errorCode;
	}

	/**
	 * Stable provider key per §4 identifying which provider
	 * implementation raised this exception.
	 */
	public function provider(): string {
		return $this->provider;
	}

	/**
	 * Structured diagnostic context per §10 / §12.3.
	 *
	 * Keys are non-empty strings; values are opaque. Consuming
	 * surfaces SHOULD treat the map as a documentation-defined
	 * key-value bag (canonical fields include `reason`, `request_id`,
	 * `retry_after`, `response_digest`) rather than as a free-form
	 * payload.
	 *
	 * Per §10.1 the map MUST NOT carry secret material; provider
	 * implementations are responsible for redacting before
	 * populating it.
	 *
	 * @return array<string, mixed>
	 */
	public function context(): array {
		return $this->context;
	}

	/**
	 * Validate the `context` map's keys.
	 *
	 * Values are NOT validated because §10 expressly preserves
	 * provider-native data here for downstream observability;
	 * the rendering / log layers each consume their own subset, so
	 * shape constraints on values would only obstruct legitimate
	 * use.
	 *
	 * @param array<int|string, mixed> $context Raw context map.
	 *
	 * @return array<string, mixed>
	 *
	 * @throws InvalidArgumentException When any key is not a
	 *                                  non-empty string.
	 */
	private static function normalizeContext( array $context ): array {
		$normalized = array();
		foreach ( $context as $key => $value ) {
			if ( ! is_string( $key ) ) {
				$message = sprintf(
					'ProviderException context keys must be strings; got %s.',
					gettype( $key )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$trimmedKey = trim( $key );
			if ( $trimmedKey === '' ) {
				$message = 'ProviderException context keys must be non-empty strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[ $trimmedKey ] = $value;
		}

		return $normalized;
	}
}
