<?php
/**
 * REST request context value object.
 *
 * Immutable value object that carries the per-request §3 / §4
 * metadata documented in `specifications/rest-api-spec.md`:
 *
 *   - The §4.1 `meta.request_id` identifier the controller stamps
 *     on every success and error envelope so log lines and
 *     diagnostics views can correlate a rendered response with the
 *     originating request.
 *
 *   - The §4.1 `meta.correlation_id` identifier the controller
 *     stamps when the originating operation spans multiple
 *     requests / jobs / provider calls. Per §3.1 the correlation
 *     id is allowed to be sourced from a validated external
 *     header; when no external value is supplied the controller
 *     either omits the field entirely or generates a fresh one,
 *     and the choice is encoded by passing `null` (omit) or a
 *     concrete string (use) to {@see self::generate()}.
 *
 *   - The §3.2 / §3.3 caller identity: the WordPress user id
 *     (or null when the request is anonymous, which §3.1 only
 *     permits for the public settings endpoint) plus the map of
 *     capability tokens currently held by the user. The map is
 *     keyed by capability token; values are always literally
 *     `'true'` so the type stays uniform and the
 *     {@see self::hasCapability()} check is a single `array_key_exists`
 *     lookup.
 *
 * Field semantics match `rest-api-spec.md` §3 / §4 exactly:
 *
 *   - `requestId` is non-empty and matches
 *     `^[a-zA-Z0-9_-]{8,64}$`. The grammar is wide enough to
 *     accept `req_` + hex (the {@see self::generate()} default),
 *     ULID-style identifiers, and slug-style identifiers a
 *     downstream slice may want to wire up later, but narrow
 *     enough to forbid whitespace and punctuation that would
 *     break log-line parsing.
 *
 *   - `correlationId` is null OR matches the same grammar as
 *     `requestId`. Null encodes the §4.1 "omit the field
 *     entirely" posture; a concrete value encodes the
 *     "stamp this id into meta" posture.
 *
 *   - `userId` is a positive WordPress user id when the request
 *     is authenticated, or null when the request is anonymous.
 *     The constructor does NOT enforce positivity beyond
 *     non-null because future surfaces (REST under WP-CLI) may
 *     legitimately surface a synthesized id of 0; the controller
 *     boundary is responsible for the policy check.
 *
 *   - `userCapabilities` is a `array<string, string>` map of
 *     capability tokens currently held; values are always
 *     literally `'true'`. Keys are validated as non-empty
 *     trimmed strings so a typo'd capability list ("" / " ")
 *     fails loudly here rather than silently masking a
 *     deny-by-default capability check.
 *
 * Validation failures raise {@see InvalidArgumentException}; the
 * messages are developer-facing and never rendered to HTML.
 *
 * @package Affilun\Presentation\Rest
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest;

use InvalidArgumentException;

/**
 * Immutable per-request §3 / §4 metadata carried through the
 * REST presentation pipeline.
 *
 * See the file-level docblock for the mapping to
 * `rest-api-spec.md` §3 / §4 and the per-field validation rules.
 */
final class RequestContext {

	/**
	 * Grammar for the `requestId` and `correlationId` fields.
	 *
	 * Held as a private constant so the constructor and
	 * {@see self::generate()} share the same regex literal.
	 */
	private const ID_GRAMMAR = '/^[a-zA-Z0-9_-]{8,64}$/';

	/**
	 * §4.1 request identifier stamped into the response envelope's
	 * `meta.request_id` field.
	 */
	private string $requestId;

	/**
	 * §4.1 correlation identifier stamped into the response
	 * envelope's `meta.correlation_id` field, or null when the
	 * field should be omitted from meta entirely.
	 */
	private ?string $correlationId;

	/**
	 * WordPress user id of the caller, or null when the request is
	 * anonymous (per §3.1 only the public settings endpoint
	 * legitimately permits anonymous access).
	 */
	private ?int $userId;

	/**
	 * Map of capability tokens currently held by the caller.
	 *
	 * Keys are capability tokens (`edit_posts`, `manage_options`,
	 * `affilun_run_sync`, ...); values are always literally `'true'`
	 * to keep the type uniform and the {@see self::hasCapability()}
	 * check a single `array_key_exists` lookup.
	 *
	 * @var array<string, string>
	 */
	private array $userCapabilities;

	/**
	 * Construct a request context with explicit values for every
	 * §3 / §4 field.
	 *
	 * @param string                $requestId        §4.1 request identifier.
	 * @param string|null           $correlationId    §4.1 correlation identifier
	 *                                                or null to omit.
	 * @param int|null              $userId           WordPress user id or null
	 *                                                when anonymous.
	 * @param array<string, string> $userCapabilities Map of capability tokens
	 *                                                currently held.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field validation rules.
	 */
	public function __construct(
		string $requestId,
		?string $correlationId,
		?int $userId,
		array $userCapabilities
	) {
		if ( preg_match( self::ID_GRAMMAR, $requestId ) !== 1 ) {
			$message = sprintf(
				'RequestContext requestId must match %s; got "%s".',
				self::ID_GRAMMAR,
				$requestId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $correlationId !== null && preg_match( self::ID_GRAMMAR, $correlationId ) !== 1 ) {
			$message = sprintf(
				'RequestContext correlationId must match %s when non-null; got "%s".',
				self::ID_GRAMMAR,
				$correlationId
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$normalized = array();
		foreach ( $userCapabilities as $cap => $value ) {
			if ( ! is_string( $cap ) ) {
				$message = sprintf(
					'RequestContext userCapabilities keys must be strings; got %s.',
					gettype( $cap )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

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

			if ( $value !== 'true' ) {
				$message = sprintf(
					'RequestContext userCapabilities values must be literal "true"; got "%s" for "%s".',
					is_string( $value ) ? $value : gettype( $value ),
					$trimmed
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[ $trimmed ] = 'true';
		}

		$this->requestId        = $requestId;
		$this->correlationId    = $correlationId;
		$this->userId           = $userId;
		$this->userCapabilities = $normalized;
	}

	/**
	 * §4.1 request identifier carried by this context.
	 */
	public function requestId(): string {
		return $this->requestId;
	}

	/**
	 * §4.1 correlation identifier carried by this context, or null
	 * when the field should be omitted from the response envelope.
	 */
	public function correlationId(): ?string {
		return $this->correlationId;
	}

	/**
	 * WordPress user id of the caller, or null when anonymous.
	 */
	public function userId(): ?int {
		return $this->userId;
	}

	/**
	 * Map of capability tokens currently held by the caller.
	 *
	 * Keys are capability tokens; values are always literally
	 * `'true'`.
	 *
	 * @return array<string, string>
	 */
	public function userCapabilities(): array {
		return $this->userCapabilities;
	}

	/**
	 * Report whether the caller currently holds the given capability
	 * token.
	 *
	 * @param string $capability Capability token to test.
	 */
	public function hasCapability( string $capability ): bool {
		return array_key_exists( $capability, $this->userCapabilities );
	}

	/**
	 * Return a copy of this context with the given correlation id.
	 *
	 * Pass `null` to drop the correlation id from the resulting
	 * context (so the response envelope omits the field entirely);
	 * pass a non-null value to set a new correlation id (validated
	 * against the same grammar as the constructor).
	 *
	 * @param string|null $newId New correlation id, or null to drop.
	 *
	 * @throws InvalidArgumentException When `$newId` is non-null and
	 *                                  fails the grammar check.
	 */
	public function withCorrelationId( ?string $newId ): self {
		return new self(
			$this->requestId,
			$newId,
			$this->userId,
			$this->userCapabilities
		);
	}

	/**
	 * Generate a fresh request context with a random request id.
	 *
	 * The request id is `req_` + 16 hex chars
	 * (`bin2hex(random_bytes(8))`), which fits the constructor's
	 * 8..64 character grammar with margin to spare.
	 *
	 * The correlation id policy is:
	 *
	 *   - `$externalCorrelationId === null` → no correlation id is
	 *     stamped onto the resulting context (so the response
	 *     envelope omits the field entirely).
	 *
	 *   - `$externalCorrelationId !== null` → the supplied value is
	 *     validated against the constructor's grammar and used
	 *     verbatim. Callers that want a generated correlation id
	 *     should pass `'corr_' . bin2hex(random_bytes(8))` (or any
	 *     other grammar-compliant token) directly.
	 *
	 * @param int|null              $userId                 WordPress user id, or null when anonymous.
	 * @param array<string, string> $capabilities           Capability map for {@see self::__construct()}.
	 * @param string|null           $externalCorrelationId  Validated external correlation id, or null
	 *                                                      to omit.
	 *
	 * @throws InvalidArgumentException When `$externalCorrelationId`
	 *                                  is non-null and fails the
	 *                                  grammar check, or when
	 *                                  `$capabilities` carries an
	 *                                  invalid entry.
	 */
	public static function generate(
		?int $userId,
		array $capabilities,
		?string $externalCorrelationId = null
	): self {
		$requestId = 'req_' . bin2hex( random_bytes( 8 ) );

		return new self(
			$requestId,
			$externalCorrelationId,
			$userId,
			$capabilities
		);
	}
}
