<?php
/**
 * REST error-envelope value object.
 *
 * Immutable value object that encodes the §4.3 ("エラーレスポンス基本形")
 * and §4.4 ("フィールドエラー形式") error response shapes documented in
 * `specifications/rest-api-spec.md` so every REST controller emits
 * exactly one canonical error envelope rather than hand-assembling
 * the `success` / `error` / `meta` triple at each call site.
 *
 * Field semantics match `rest-api-spec.md` §4.3 / §4.4 / §14.1 /
 * §15.1 / §15.2 exactly:
 *
 *   - `code` is the §15.1 stable error code, validated against the
 *     `^affilun_[a-z][a-z0-9_]*$` grammar at construction time. The
 *     §15.2 representative codes the initial slices need are exposed
 *     as `CODE_*` constants so call sites can reference the exact
 *     token without duplicating the literal.
 *
 *   - `message` is the human-readable summary the §10.3
 *     admin-facing translation will surface as fallback text.
 *
 *   - `status` is the HTTP status code per §14.1; constrained to the
 *     closed set the §14.1 table enumerates so a typo in a
 *     controller surfaces here rather than producing an envelope
 *     whose status code WordPress refuses to forward. The set is
 *     exposed as `STATUS_*` constants and as
 *     {@see self::validStatusCodes()} so call sites and tests can
 *     reference the same source of truth.
 *
 *   - `details` is an `array<string, mixed>` of structured
 *     diagnostic context. For §4.4 field-validation responses the
 *     {@see self::validation()} factory wraps a fields map into
 *     `array('fields' => array('keywords' => array('Required.'), ...))`.
 *
 *   - `retryable` is the §10.2 retryability hint a future client
 *     surface uses to decide whether to expose a "try again" UI
 *     affordance.
 *
 *   - `requestId` / `correlationId` / `timestamp` mirror the
 *     {@see SuccessEnvelope} meta block; the same
 *     "omit correlation_id when null" rule applies.
 *
 * Validation failures raise {@see InvalidArgumentException}; the
 * messages are developer-facing and never rendered to HTML.
 *
 * @package Affilun\Presentation\Rest\Response
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Response;

use Affilun\Presentation\Rest\RequestContext;
use InvalidArgumentException;

/**
 * Immutable §4.3 / §4.4 error envelope.
 *
 * See the file-level docblock for the mapping to
 * `rest-api-spec.md` §4.3 / §4.4 / §14.1 / §15.1 / §15.2 and the
 * per-field validation rules.
 */
final class ErrorEnvelope {

	/**
	 * §15.1 grammar for the `code` field.
	 *
	 * Held as a private constant so the constructor uses the same
	 * regex literal as future debugging surfaces.
	 */
	private const CODE_GRAMMAR = '/^affilun_[a-z][a-z0-9_]*$/';

	/**
	 * §14.1 HTTP status: parameter format invalid.
	 */
	public const STATUS_BAD_REQUEST = 400;

	/**
	 * §14.1 HTTP status: caller is not authenticated.
	 */
	public const STATUS_UNAUTHORIZED = 401;

	/**
	 * §14.1 HTTP status: caller lacks the required capability.
	 */
	public const STATUS_FORBIDDEN = 403;

	/**
	 * §14.1 HTTP status: target resource does not exist.
	 */
	public const STATUS_NOT_FOUND = 404;

	/**
	 * §14.1 HTTP status: state conflict (e.g. delete-in-use).
	 */
	public const STATUS_CONFLICT = 409;

	/**
	 * §14.1 HTTP status: validation failure.
	 */
	public const STATUS_UNPROCESSABLE_ENTITY = 422;

	/**
	 * §14.1 HTTP status: rate-limit refusal.
	 */
	public const STATUS_TOO_MANY_REQUESTS = 429;

	/**
	 * §14.1 HTTP status: unexpected internal failure.
	 */
	public const STATUS_INTERNAL_SERVER_ERROR = 500;

	/**
	 * §14.1 HTTP status: transient upstream / provider unavailability.
	 */
	public const STATUS_SERVICE_UNAVAILABLE = 503;

	/**
	 * §15.2 representative code: validation failed.
	 */
	public const CODE_VALIDATION_FAILED = 'affilun_validation_failed';

	/**
	 * §15.2 representative code: unsupported provider supplied.
	 */
	public const CODE_INVALID_PROVIDER = 'affilun_invalid_provider';

	/**
	 * §15.2 representative code: product lookup miss.
	 */
	public const CODE_PRODUCT_NOT_FOUND = 'affilun_product_not_found';

	/**
	 * §15.2 representative code: caller lacks the required capability.
	 */
	public const CODE_FORBIDDEN = 'affilun_forbidden';

	/**
	 * §15.2 representative code: caller is not authenticated.
	 */
	public const CODE_UNAUTHORIZED = 'affilun_unauthorized';

	/**
	 * §15.2 representative code: rate-limit refusal surfaced to the API.
	 */
	public const CODE_RATE_LIMITED = 'affilun_rate_limited';

	/**
	 * §15.2 representative code: provider transient unavailability.
	 */
	public const CODE_PROVIDER_UNAVAILABLE = 'affilun_provider_unavailable';

	/**
	 * §15.2 representative code: catch-all internal failure.
	 */
	public const CODE_INTERNAL_ERROR = 'affilun_internal_error';

	/**
	 * §15.2 representative code: sync-job lookup miss.
	 */
	public const CODE_SYNC_JOB_NOT_FOUND = 'affilun_sync_job_not_found';

	/**
	 * §15.1 stable error code.
	 */
	private string $code;

	/**
	 * Human-readable summary of the failure.
	 */
	private string $message;

	/**
	 * §14.1 HTTP status code.
	 */
	private int $status;

	/**
	 * Structured diagnostic context.
	 *
	 * @var array<string, mixed>
	 */
	private array $details;

	/**
	 * §10.2 retryability hint.
	 */
	private bool $retryable;

	/**
	 * §4.3 `meta.request_id` token.
	 */
	private string $requestId;

	/**
	 * §4.3 `meta.correlation_id` token, or null to omit from meta.
	 */
	private ?string $correlationId;

	/**
	 * §2.4 RFC 3339 / ISO 8601 timestamp.
	 */
	private string $timestamp;

	/**
	 * Construct an error envelope with explicit values for every
	 * §4.3 field.
	 *
	 * @param string               $code          §15.1 stable error code.
	 * @param string               $message       Human-readable summary.
	 * @param int                  $status        §14.1 HTTP status code.
	 * @param array<string, mixed> $details       Structured diagnostic context.
	 * @param bool                 $retryable     §10.2 retryability hint.
	 * @param string               $requestId     §4.3 request id.
	 * @param string|null          $correlationId §4.3 correlation id, or null to omit.
	 * @param string               $timestamp     §2.4 RFC 3339 timestamp.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field validation rules.
	 */
	public function __construct(
		string $code,
		string $message,
		int $status,
		array $details,
		bool $retryable,
		string $requestId,
		?string $correlationId,
		string $timestamp
	) {
		if ( preg_match( self::CODE_GRAMMAR, $code ) !== 1 ) {
			$invalid = sprintf(
				'ErrorEnvelope code must match %s; got "%s".',
				self::CODE_GRAMMAR,
				$code
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $invalid );
		}

		if ( trim( $message ) === '' ) {
			$invalid = 'ErrorEnvelope message must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $invalid );
		}

		if ( ! in_array( $status, self::validStatusCodes(), true ) ) {
			$invalid = sprintf(
				'ErrorEnvelope status must be one of [%s]; got %d.',
				implode( ', ', array_map( 'strval', self::validStatusCodes() ) ),
				$status
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $invalid );
		}

		foreach ( $details as $key => $_value ) {
			if ( ! is_string( $key ) || trim( $key ) === '' ) {
				$invalid = 'ErrorEnvelope details keys must be non-empty strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $invalid );
			}
		}

		if ( trim( $requestId ) === '' ) {
			$invalid = 'ErrorEnvelope requestId must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $invalid );
		}

		if ( $correlationId !== null && trim( $correlationId ) === '' ) {
			$invalid = 'ErrorEnvelope correlationId must be a non-empty string when non-null.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $invalid );
		}

		if ( trim( $timestamp ) === '' ) {
			$invalid = 'ErrorEnvelope timestamp must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $invalid );
		}

		$this->code          = $code;
		$this->message       = $message;
		$this->status        = $status;
		$this->details       = $details;
		$this->retryable     = $retryable;
		$this->requestId     = $requestId;
		$this->correlationId = $correlationId;
		$this->timestamp     = $timestamp;
	}

	/**
	 * §15.1 stable error code carried by this envelope.
	 */
	public function code(): string {
		return $this->code;
	}

	/**
	 * Human-readable summary of the failure.
	 */
	public function message(): string {
		return $this->message;
	}

	/**
	 * §14.1 HTTP status code.
	 */
	public function status(): int {
		return $this->status;
	}

	/**
	 * Structured diagnostic context.
	 *
	 * @return array<string, mixed>
	 */
	public function details(): array {
		return $this->details;
	}

	/**
	 * §10.2 retryability hint.
	 */
	public function retryable(): bool {
		return $this->retryable;
	}

	/**
	 * §4.3 `meta.request_id` token.
	 */
	public function requestId(): string {
		return $this->requestId;
	}

	/**
	 * §4.3 `meta.correlation_id` token, or null when the field
	 * should be omitted from the meta block.
	 */
	public function correlationId(): ?string {
		return $this->correlationId;
	}

	/**
	 * §2.4 RFC 3339 / ISO 8601 timestamp.
	 */
	public function timestamp(): string {
		return $this->timestamp;
	}

	/**
	 * Serialize the envelope into the §4.3 wire shape.
	 *
	 * The `meta.correlation_id` key is omitted entirely when the
	 * correlation id is null so the on-the-wire JSON does not carry
	 * a `"correlation_id": null` placeholder.
	 *
	 * @return array<string, mixed>
	 */
	public function toArray(): array {
		$meta = array(
			'request_id' => $this->requestId,
		);

		if ( $this->correlationId !== null ) {
			$meta['correlation_id'] = $this->correlationId;
		}

		$meta['timestamp'] = $this->timestamp;

		return array(
			'success' => false,
			'error'   => array(
				'code'      => $this->code,
				'message'   => $this->message,
				'status'    => $this->status,
				'details'   => $this->details,
				'retryable' => $this->retryable,
			),
			'meta'    => $meta,
		);
	}

	/**
	 * Convenience factory that pulls `requestId` / `correlationId`
	 * from a {@see RequestContext} and stamps the current UTC
	 * timestamp.
	 *
	 * The timestamp is rendered as `Y-m-d\\TH:i:s\\Z` (UTC) per
	 * §2.4 "原則: UTC で返す".
	 *
	 * @param string               $code      §15.1 stable error code.
	 * @param string               $message   Human-readable summary.
	 * @param int                  $status    §14.1 HTTP status code.
	 * @param RequestContext       $ctx       Per-request meta source.
	 * @param array<string, mixed> $details   Structured diagnostic context.
	 * @param bool                 $retryable §10.2 retryability hint.
	 */
	public static function fromContext(
		string $code,
		string $message,
		int $status,
		RequestContext $ctx,
		array $details = array(),
		bool $retryable = false
	): self {
		return new self(
			$code,
			$message,
			$status,
			$details,
			$retryable,
			$ctx->requestId(),
			$ctx->correlationId(),
			gmdate( 'Y-m-d\TH:i:s\Z' )
		);
	}

	/**
	 * Convenience factory for §4.4 field-validation responses.
	 *
	 * Wraps the supplied per-field error map into the canonical
	 * `array('fields' => $fields)` shape documented by §4.4 and uses
	 * {@see self::CODE_VALIDATION_FAILED} / status 422.
	 *
	 * @param array<string, list<string>> $fields  Per-field error map.
	 * @param RequestContext              $ctx     Per-request meta source.
	 * @param string                      $message Human-readable summary.
	 */
	public static function validation(
		array $fields,
		RequestContext $ctx,
		string $message = 'Validation failed.'
	): self {
		return self::fromContext(
			self::CODE_VALIDATION_FAILED,
			$message,
			self::STATUS_UNPROCESSABLE_ENTITY,
			$ctx,
			array( 'fields' => $fields ),
			false
		);
	}

	/**
	 * Return the §14.1 closed set of HTTP status codes the envelope
	 * accepts.
	 *
	 * @return list<int>
	 */
	public static function validStatusCodes(): array {
		return array(
			self::STATUS_BAD_REQUEST,
			self::STATUS_UNAUTHORIZED,
			self::STATUS_FORBIDDEN,
			self::STATUS_NOT_FOUND,
			self::STATUS_CONFLICT,
			self::STATUS_UNPROCESSABLE_ENTITY,
			self::STATUS_TOO_MANY_REQUESTS,
			self::STATUS_INTERNAL_SERVER_ERROR,
			self::STATUS_SERVICE_UNAVAILABLE,
		);
	}
}
