<?php
/**
 * REST request adapter.
 *
 * Pure-function helper that converts a WordPress
 * `WP_REST_Request` (or any object exposing the same duck-typed
 * surface) into the small, controller-shaped inputs the
 * products / editor REST controllers actually need. The adapter
 * is the single boundary at which the controllers acknowledge
 * the WordPress request type; everything below this boundary
 * sees plain PHP scalars / arrays / a {@see RequestContext}
 * value object, so the controllers and their supporting use
 * cases stay decoupled from the WordPress class hierarchy and
 * stay trivially unit-testable without bootstrapping WP.
 *
 * The adapter is intentionally dual-mode so the same code path
 * is exercised by production traffic and by the unit-test suite:
 *
 *   - **Object mode (production).** `$request` is any object
 *     exposing the duck-typed surface
 *     `get_param(string $name): mixed`,
 *     `get_params(): array`,
 *     `get_header(string $name): ?string`,
 *     `get_json_params(): ?array`. `WP_REST_Request` is the
 *     canonical implementer; any test-double that re-implements
 *     the same surface works identically.
 *
 *   - **Array mode (unit-test shortcut).** `$request` is an
 *     associative array with optional keys `'params'` (array
 *     of query / route parameters), `'headers'` (associative
 *     array consulted with case-insensitive key comparison),
 *     and `'json'` (already-decoded JSON body). Tests use this
 *     mode to drive the controller without instantiating
 *     `WP_REST_Request` and without pulling in the WP REST
 *     bootstrap. Each lookup falls back gracefully to an
 *     adapter-default value when the corresponding key is
 *     absent or carries an unexpected shape.
 *
 * The duck check is `is_object($request) && method_exists(...)`
 * on a per-method basis rather than a class check against
 * `WP_REST_Request` so the adapter does not require the
 * WordPress request class to be loadable at the type level
 * (PHPStan analysis runs without WP stubs in this slice).
 *
 * The {@see self::buildRequestContext()} method projects the
 * adapter's header / capability inputs into the §3 / §4
 * {@see RequestContext} value object documented in
 * `specifications/rest-api-spec.md` §3 (request_id /
 * correlation_id grammar, capability map shape, anonymous-id
 * policy) so the controllers do not need to know the grammar
 * literally; the adapter validates it once on the inbound side
 * and the controllers only consume the validated value object.
 *
 * @package Affilun\Presentation\Rest\Controller
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Controller;

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

/**
 * Stateless WordPress-REST-request → controller-input adapter.
 *
 * See the file-level docblock for the dual-mode (object /
 * array) duck-typing design and the projection rules from
 * inbound request headers to the §3 / §4
 * {@see RequestContext} value object.
 */
final class RestRequestAdapter {

	/**
	 * Grammar for `X-Request-Id` / `X-Correlation-Id` headers.
	 *
	 * Mirrors {@see RequestContext}'s constructor grammar so the
	 * adapter can short-circuit invalid inbound headers to
	 * `null` (and let `RequestContext::generate()` synthesize a
	 * fresh value) instead of letting an invalid header crash
	 * the controller boundary with an `InvalidArgumentException`.
	 */
	private const ID_GRAMMAR = '/^[a-zA-Z0-9_-]{8,64}$/';

	/**
	 * Look up an arbitrary parameter from the request.
	 *
	 * In object mode this delegates to `$request->get_param()`;
	 * in array mode it consults `$request['params'][$name]`.
	 * In every mode the per-key default is returned when the
	 * source value is `null` or absent.
	 *
	 * @param mixed  $request Request object or array.
	 * @param string $name    Parameter name to look up.
	 * @param mixed  $default Default to return when the parameter
	 *                       is absent or null.
	 */
	public function param(
		mixed $request,
		string $name,
		// phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.defaultFound -- mirrors WordPress's get_param( $name, $default ) idiom; renaming would obscure the WP-aligned semantics.
		mixed $default = null
	): mixed {
		if ( is_object( $request ) && method_exists( $request, 'get_param' ) ) {
			$value = $request->get_param( $name );

			return $value ?? $default;
		}

		if ( is_array( $request ) ) {
			$params = $request['params'] ?? array();
			if ( is_array( $params ) && array_key_exists( $name, $params ) ) {
				return $params[ $name ] ?? $default;
			}

			return $default;
		}

		return $default;
	}

	/**
	 * Look up a parameter and coerce it to a non-empty trimmed
	 * string, falling back to the supplied default otherwise.
	 *
	 * Returns the trimmed value when the underlying parameter is
	 * a string that is non-empty after trimming; returns
	 * `$default` when the parameter is null, not a string, or
	 * empty after trimming. The return type is therefore the
	 * caller-controlled `?string` rather than a forced empty
	 * string.
	 *
	 * @param mixed       $request Request object or array.
	 * @param string      $name    Parameter name to look up.
	 * @param string|null $default Default when the parameter is
	 *                             absent / non-string / blank.
	 */
	public function paramString(
		mixed $request,
		string $name,
		// phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.defaultFound -- mirrors WordPress's get_param( $name, $default ) idiom; renaming would obscure the WP-aligned semantics.
		?string $default = null
	): ?string {
		$value = $this->param( $request, $name );
		if ( $value === null ) {
			return $default;
		}

		if ( ! is_string( $value ) ) {
			return $default;
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			return $default;
		}

		return $trimmed;
	}

	/**
	 * Look up a parameter and coerce it to an integer, falling
	 * back to the supplied default otherwise.
	 *
	 * Accepts native ints verbatim and decimal-integer strings
	 * (matching `^-?\d+$`); every other shape (floats, non-numeric
	 * strings, arrays, booleans) returns `$default`.
	 *
	 * @param mixed    $request Request object or array.
	 * @param string   $name    Parameter name to look up.
	 * @param int|null $default Default when the parameter is
	 *                          absent / not int-coercible.
	 */
	public function paramInt(
		mixed $request,
		string $name,
		// phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.defaultFound -- mirrors WordPress's get_param( $name, $default ) idiom; renaming would obscure the WP-aligned semantics.
		?int $default = null
	): ?int {
		$value = $this->param( $request, $name );
		if ( $value === null ) {
			return $default;
		}

		if ( is_int( $value ) ) {
			return $value;
		}

		if ( is_string( $value ) && preg_match( '/\A-?\d+\z/', $value ) === 1 ) {
			return (int) $value;
		}

		return $default;
	}

	/**
	 * Return the decoded JSON body of the request as an array.
	 *
	 * Always returns an array, never `null`: in object mode an
	 * absent body (`get_json_params()` returning `null`) is
	 * coerced to `array()`, and in array mode an absent
	 * `'json'` key is coerced to `array()` as well. Callers can
	 * therefore treat the return value uniformly with
	 * `array_key_exists()` / `??` lookups.
	 *
	 * @param mixed $request Request object or array.
	 *
	 * @return array<mixed>
	 */
	public function jsonBody( mixed $request ): array {
		if ( is_object( $request ) && method_exists( $request, 'get_json_params' ) ) {
			$json = $request->get_json_params();

			return is_array( $json ) ? $json : array();
		}

		if ( is_array( $request ) ) {
			$json = $request['json'] ?? array();

			return is_array( $json ) ? $json : array();
		}

		return array();
	}

	/**
	 * Look up a request header by name.
	 *
	 * Header name lookup is case-insensitive in both modes: in
	 * object mode the duck-typed `get_header()` already performs
	 * the case-folded lookup that `WP_REST_Request` exposes; in
	 * array mode the adapter iterates `$request['headers']` and
	 * compares `strtolower()` of each key against
	 * `strtolower($name)` so test fixtures can use whichever
	 * casing reads naturally.
	 *
	 * @param mixed  $request Request object or array.
	 * @param string $name    Header name to look up.
	 */
	public function header( mixed $request, string $name ): ?string {
		if ( is_object( $request ) && method_exists( $request, 'get_header' ) ) {
			$value = $request->get_header( $name );

			return is_string( $value ) ? $value : null;
		}

		if ( is_array( $request ) ) {
			$headers = $request['headers'] ?? array();
			if ( ! is_array( $headers ) ) {
				return null;
			}

			$needle = strtolower( $name );
			foreach ( $headers as $key => $value ) {
				if ( ! is_string( $key ) ) {
					continue;
				}
				if ( strtolower( $key ) === $needle ) {
					if ( $value === null ) {
						return null;
					}

					return is_string( $value ) ? $value : (string) $value;
				}
			}

			return null;
		}

		return null;
	}

	/**
	 * Build the §3 / §4 {@see RequestContext} value object that
	 * the controllers thread through their use cases and
	 * response envelopes.
	 *
	 * Header projection rules (per `rest-api-spec.md` §3):
	 *
	 *   - `X-Request-Id` is consulted; when present and matching
	 *     the §3 grammar `^[a-zA-Z0-9_-]{8,64}$` it is preserved
	 *     verbatim by constructing the {@see RequestContext}
	 *     directly so the inbound id round-trips into the
	 *     response envelope. When absent or invalid the adapter
	 *     falls through to {@see RequestContext::generate()},
	 *     which synthesizes a fresh `req_` + 16 hex chars id.
	 *
	 *   - `X-Correlation-Id` is consulted with the same grammar;
	 *     a valid value is forwarded as `$externalCorrelationId`
	 *     so it is stamped onto the resulting context, an absent
	 *     or invalid value is forwarded as `null` so the
	 *     correlation-id field is omitted from the response
	 *     envelope per §4.1.
	 *
	 * The `$userCapabilities` parameter is accepted in two
	 * shapes for caller convenience and normalized to the single
	 * shape {@see RequestContext} expects:
	 *
	 *   - List shape (`array('edit_posts', 'manage_options')`):
	 *     each entry is treated as a capability token and mapped
	 *     to `array('edit_posts' => 'true', 'manage_options' => 'true')`.
	 *
	 *   - Map shape (`array('edit_posts' => 'true')` already in
	 *     the {@see RequestContext} format): the value is
	 *     re-validated as the literal string `'true'` and
	 *     forwarded verbatim.
	 *
	 * Mixed-shape arrays are accepted: numeric keys are treated
	 * as list entries, string keys are treated as map entries.
	 * Every entry must ultimately resolve to a non-empty trimmed
	 * capability token; an entry that does not raises
	 * {@see InvalidArgumentException} so a typo'd capability
	 * list fails loudly here rather than silently masking a
	 * deny-by-default capability check downstream.
	 *
	 * @param mixed                     $request          Request object or array.
	 * @param int|null                  $userId           WordPress user id, or null
	 *                                                    when the request is anonymous.
	 * @param array<int|string, string> $userCapabilities Capability list or map.
	 *
	 * @throws InvalidArgumentException When `$userCapabilities`
	 *                                  carries an entry that is not
	 *                                  a non-empty trimmed string.
	 */
	public function buildRequestContext(
		mixed $request,
		?int $userId,
		array $userCapabilities
	): RequestContext {
		$externalRequestId     = $this->validatedIdHeader( $request, 'X-Request-Id' );
		$externalCorrelationId = $this->validatedIdHeader( $request, 'X-Correlation-Id' );

		$capabilitiesMap = $this->normalizeCapabilities( $userCapabilities );

		if ( $externalRequestId !== null ) {
			return new RequestContext(
				$externalRequestId,
				$externalCorrelationId,
				$userId,
				$capabilitiesMap
			);
		}

		return RequestContext::generate( $userId, $capabilitiesMap, $externalCorrelationId );
	}

	/**
	 * Look up a request header and validate it against the §3
	 * id grammar; return null when absent or invalid.
	 *
	 * @param mixed  $request Request object or array.
	 * @param string $name    Header name to look up.
	 */
	private function validatedIdHeader( mixed $request, string $name ): ?string {
		$value = $this->header( $request, $name );
		if ( $value === null ) {
			return null;
		}

		if ( preg_match( self::ID_GRAMMAR, $value ) !== 1 ) {
			return null;
		}

		return $value;
	}

	/**
	 * Normalize a list / map / mixed capability input to the
	 * `array<string, 'true'>` shape {@see RequestContext}
	 * expects.
	 *
	 * @param array<int|string, string> $userCapabilities Capability list or map.
	 *
	 * @return array<string, string>
	 *
	 * @throws InvalidArgumentException When an entry is not a
	 *                                  non-empty trimmed string.
	 */
	private function normalizeCapabilities( array $userCapabilities ): array {
		$normalized = array();
		foreach ( $userCapabilities as $key => $value ) {
			if ( is_int( $key ) ) {
				$cap = $value;
			} else {
				$cap = $key;
			}

			if ( ! is_string( $cap ) ) {
				$message = sprintf(
					'RestRequestAdapter capability tokens 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 = 'RestRequestAdapter capability tokens must be non-empty trimmed strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

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

		return $normalized;
	}
}
