<?php
/**
 * REST success-envelope value object.
 *
 * Immutable value object that encodes the §4.1 ("成功レスポンス基本形")
 * and §4.2 ("一覧レスポンス基本形") response shapes documented in
 * `specifications/rest-api-spec.md` so every REST controller emits
 * exactly one canonical success envelope rather than hand-assembling
 * the `success` / `data` / `meta` triple at each call site.
 *
 * Field semantics match `rest-api-spec.md` §4.1 / §4.2 exactly:
 *
 *   - `data` is the payload-shaped `array<string, mixed>` the
 *     controller wants to surface under the §4.1 `data` key. For
 *     §4.2 list responses the {@see self::list()} factory wraps the
 *     items / pagination tuple into the canonical
 *     `array('items' => ..., 'pagination' => ...)` shape so the
 *     controller does not have to repeat that structure.
 *
 *   - `requestId` is the §4.1 `meta.request_id` token; non-empty
 *     enforced at construction time so a programming error in a
 *     controller surfaces here rather than producing an envelope
 *     whose meta block is silently missing the correlation hook
 *     that operations dashboards depend on.
 *
 *   - `correlationId` is the §4.1 `meta.correlation_id` token, or
 *     null when the controller wants the field omitted from meta
 *     entirely. The {@see self::toArray()} serializer drops the key
 *     when the value is null so the on-the-wire JSON does not carry
 *     a `"correlation_id": null` placeholder.
 *
 *   - `timestamp` is the §2.4 RFC 3339 / ISO 8601 timestamp of the
 *     response. Always rendered in UTC by {@see self::fromContext()}
 *     ("`Y-m-d\\TH:i:s\\Z`") to match the §2.4 "原則: UTC で返す"
 *     posture; callers that need a timezone-suffixed form may pass
 *     it explicitly to the constructor.
 *
 *   - `extraMeta` is an `array<string, mixed>` of additional meta
 *     keys that get merged into the meta block. Used by §4.2 list
 *     responses to attach pagination metadata at the meta level
 *     when a future slice wants that, and as the open-ended
 *     extension point §18.2 ("フィールド追加は許可") relies on.
 *
 * 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.1 / §4.2 success envelope.
 *
 * See the file-level docblock for the mapping to
 * `rest-api-spec.md` §4.1 / §4.2 and the per-field validation
 * rules.
 */
final class SuccessEnvelope {

	/**
	 * Payload to surface under the §4.1 `data` key.
	 *
	 * @var array<string, mixed>
	 */
	private array $data;

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

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

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

	/**
	 * Additional meta keys merged into the meta block.
	 *
	 * @var array<string, mixed>
	 */
	private array $extraMeta;

	/**
	 * Construct a success envelope with explicit values for every
	 * §4.1 field.
	 *
	 * @param array<string, mixed> $data          §4.1 payload.
	 * @param string               $requestId     §4.1 request id.
	 * @param string|null          $correlationId §4.1 correlation id, or null to omit.
	 * @param string               $timestamp     §2.4 RFC 3339 timestamp.
	 * @param array<string, mixed> $extraMeta     Additional meta keys to merge.
	 *
	 * @throws InvalidArgumentException When `requestId`, `timestamp`,
	 *                                  or non-null `correlationId`
	 *                                  is empty after trimming.
	 */
	public function __construct(
		array $data,
		string $requestId,
		?string $correlationId,
		string $timestamp,
		array $extraMeta = array()
	) {
		if ( trim( $requestId ) === '' ) {
			$message = 'SuccessEnvelope requestId must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $correlationId !== null && trim( $correlationId ) === '' ) {
			$message = 'SuccessEnvelope 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( $message );
		}

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

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

		$this->data          = $data;
		$this->requestId     = $requestId;
		$this->correlationId = $correlationId;
		$this->timestamp     = $timestamp;
		$this->extraMeta     = $extraMeta;
	}

	/**
	 * §4.1 payload to surface under the `data` key.
	 *
	 * @return array<string, mixed>
	 */
	public function data(): array {
		return $this->data;
	}

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

	/**
	 * §4.1 `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;
	}

	/**
	 * Additional meta keys merged into the meta block.
	 *
	 * @return array<string, mixed>
	 */
	public function extraMeta(): array {
		return $this->extraMeta;
	}

	/**
	 * Serialize the envelope into the §4.1 / §4.2 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.
	 *
	 * `extraMeta` keys are merged into the meta block AFTER the
	 * canonical `request_id` / `correlation_id` / `timestamp`
	 * entries; a colliding extra-meta key wins so a future slice
	 * can override the timestamp shape if needed.
	 *
	 * @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;

		foreach ( $this->extraMeta as $key => $value ) {
			$meta[ $key ] = $value;
		}

		return array(
			'success' => true,
			'data'    => $this->data,
			'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 array<string, mixed> $data      §4.1 payload.
	 * @param RequestContext       $ctx       Per-request meta source.
	 * @param array<string, mixed> $extraMeta Additional meta keys to merge.
	 */
	public static function fromContext(
		array $data,
		RequestContext $ctx,
		array $extraMeta = array()
	): self {
		return new self(
			$data,
			$ctx->requestId(),
			$ctx->correlationId(),
			gmdate( 'Y-m-d\TH:i:s\Z' ),
			$extraMeta
		);
	}

	/**
	 * Convenience factory for §4.2 list responses.
	 *
	 * Wraps the supplied items / pagination tuple into the canonical
	 * `array('items' => $items, 'pagination' => $pagination)` shape
	 * documented by §4.2 so the controller does not have to repeat
	 * the structure at every call site.
	 *
	 * @param list<array<string, mixed>> $items      List of item payloads.
	 * @param array<string, mixed>       $pagination §4.2 pagination block.
	 * @param RequestContext             $ctx        Per-request meta source.
	 */
	public static function list(
		array $items,
		array $pagination,
		RequestContext $ctx
	): self {
		return self::fromContext(
			array(
				'items'      => $items,
				'pagination' => $pagination,
			),
			$ctx
		);
	}
}
