<?php
/**
 * REST route-definition value object.
 *
 * Immutable value object that encapsulates one REST route's
 * registration metadata in the shape WordPress's
 * `register_rest_route()` consumes, plus the §3 route-token used by
 * {@see \Affilun\Presentation\Rest\Permission\CapabilityPolicy}
 * to resolve the required-capability list. The type lets every
 * concrete REST controller produce a single value the
 * {@see RouteRegistrar} can register without each controller having
 * to know the WordPress registration API directly.
 *
 * Field semantics match `specifications/rest-api-spec.md` §2.1 /
 * §3.2 / §19.1 exactly:
 *
 *   - `namespace` is the §2.1 fully-joined namespace string
 *     (`'affilun/v1'`); validated non-empty.
 *
 *   - `route` is the path portion of the REST URL, e.g.
 *     `'/products/search'`. Validated to start with `/` so a typo
 *     (`'products/search'`) fails loudly here rather than silently
 *     producing a route WordPress registers under an unexpected
 *     URL.
 *
 *   - `method` is the HTTP method; constrained to the closed set
 *     `{ GET, POST, PATCH, DELETE }` exposed as `METHOD_*`
 *     constants. {@see self::validMethods()} returns the same set
 *     so tests and diagnostics can reference one source of truth.
 *
 *   - `routeToken` is the §3 route-token used by
 *     {@see CapabilityPolicy::requiredForRead()} /
 *     {@see CapabilityPolicy::requiredForWrite()} to resolve the
 *     required-capability list. Held as a plain string at this
 *     layer; the policy is the canonical source of truth for which
 *     tokens are known.
 *
 *   - `isWrite` is true for POST / PATCH / DELETE, false for GET.
 *     Computed at construction time from `method` and exposed as a
 *     public accessor so consuming surfaces (the registrar,
 *     diagnostics views, tests) can branch on read-vs-write
 *     without re-deriving the rule.
 *
 *   - `args` is the WordPress `register_rest_route()` `args` array
 *     (per-parameter schema, sanitization, etc.). Held opaque at
 *     this layer; controllers populate it.
 *
 *   - `callback` is the controller callable invoked with the
 *     WP_REST_Request. Signature
 *     `function (mixed $request): array<string, mixed>`; the
 *     callable is expected to return the success / error envelope
 *     array.
 *
 *   - `permissionCallback` is the permission callable invoked with
 *     the WP_REST_Request; returns bool.
 *
 * Validation failures raise {@see InvalidArgumentException}; the
 * messages are developer-facing and never rendered to HTML.
 *
 * @package Affilun\Presentation\Rest\Routing
 */

declare(strict_types=1);

namespace Affilun\Presentation\Rest\Routing;

use Affilun\Presentation\Rest\Permission\CapabilityPolicy;
use InvalidArgumentException;

/**
 * Immutable per-route registration metadata.
 *
 * See the file-level docblock for the mapping to
 * `rest-api-spec.md` §2.1 / §3 / §19.1 and the per-field
 * validation rules.
 */
final class RouteDefinition {

	/**
	 * §14.1 / WordPress HTTP method: read.
	 */
	public const METHOD_GET = 'GET';

	/**
	 * §14.1 / WordPress HTTP method: create.
	 */
	public const METHOD_POST = 'POST';

	/**
	 * §14.1 / WordPress HTTP method: partial update.
	 */
	public const METHOD_PATCH = 'PATCH';

	/**
	 * §14.1 / WordPress HTTP method: delete.
	 */
	public const METHOD_DELETE = 'DELETE';

	/**
	 * Closed set of HTTP methods recognized by this slice.
	 *
	 * Held as a private constant so the constructor and
	 * {@see self::validMethods()} share the same source of truth.
	 *
	 * @var list<string>
	 */
	private const VALID_METHODS = array(
		self::METHOD_GET,
		self::METHOD_POST,
		self::METHOD_PATCH,
		self::METHOD_DELETE,
	);

	/**
	 * Closed set of write-side HTTP methods.
	 *
	 * @var list<string>
	 */
	private const WRITE_METHODS = array(
		self::METHOD_POST,
		self::METHOD_PATCH,
		self::METHOD_DELETE,
	);

	/**
	 * §2.1 fully-joined namespace string (e.g. `'affilun/v1'`).
	 */
	private string $namespace;

	/**
	 * Route path beginning with `/` (e.g. `'/products/search'`).
	 */
	private string $route;

	/**
	 * HTTP method; one of {@see self::VALID_METHODS}.
	 */
	private string $method;

	/**
	 * §3 route-token consumed by
	 * {@see \Affilun\Presentation\Rest\Permission\CapabilityPolicy}.
	 */
	private string $routeToken;

	/**
	 * Whether this route is a write-side route (POST / PATCH /
	 * DELETE).
	 */
	private bool $isWrite;

	/**
	 * WordPress `register_rest_route()` `args` array.
	 *
	 * @var array<string, mixed>
	 */
	private array $args;

	/**
	 * Controller callback invoked with the WP_REST_Request.
	 *
	 * @var callable
	 */
	private $callback;

	/**
	 * Permission callback invoked with the WP_REST_Request.
	 *
	 * @var callable
	 */
	private $permissionCallback;

	/**
	 * Construct a route definition with explicit values for every
	 * registration field.
	 *
	 * @param string               $namespace          §2.1 namespace string.
	 * @param string               $route              Route path beginning with `/`.
	 * @param string               $method             HTTP method; one of {@see self::VALID_METHODS}.
	 * @param string               $routeToken         §3 route-token for capability resolution.
	 * @param array<string, mixed> $args               `register_rest_route()` args.
	 * @param callable             $callback           Controller callback.
	 * @param callable             $permissionCallback Permission callback.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field validation rules.
	 */
	public function __construct(
		// phpcs:ignore Universal.NamingConventions.NoReservedKeywordParameterNames.namespaceFound -- "namespace" is the canonical REST domain term per spec §2.1; the parameter name mirrors WordPress's own register_rest_route() signature.
		string $namespace,
		string $route,
		string $method,
		string $routeToken,
		array $args,
		callable $callback,
		callable $permissionCallback
	) {
		if ( trim( $namespace ) === '' ) {
			$message = 'RouteDefinition namespace must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $route === '' || $route[0] !== '/' ) {
			$message = sprintf(
				'RouteDefinition route must be a non-empty string starting with "/"; got "%s".',
				$route
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

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

		$this->namespace          = $namespace;
		$this->route              = $route;
		$this->method             = $method;
		$this->routeToken         = $routeToken;
		$this->isWrite            = in_array( $method, self::WRITE_METHODS, true );
		$this->args               = $args;
		$this->callback           = $callback;
		$this->permissionCallback = self::withCapabilityPermission(
			$routeToken,
			$this->isWrite,
			$permissionCallback
		);
	}

	/**
	 * §2.1 fully-joined namespace string.
	 */
	public function namespace(): string {
		return $this->namespace;
	}

	/**
	 * Route path beginning with `/`.
	 */
	public function route(): string {
		return $this->route;
	}

	/**
	 * HTTP method; one of {@see self::VALID_METHODS}.
	 */
	public function method(): string {
		return $this->method;
	}

	/**
	 * §3 route-token consumed by the capability policy.
	 */
	public function routeToken(): string {
		return $this->routeToken;
	}

	/**
	 * Report whether this route is a write-side route.
	 */
	public function isWrite(): bool {
		return $this->isWrite;
	}

	/**
	 * WordPress `register_rest_route()` `args` array.
	 *
	 * @return array<string, mixed>
	 */
	public function args(): array {
		return $this->args;
	}

	/**
	 * Controller callback invoked with the WP_REST_Request.
	 */
	public function callback(): callable {
		return $this->callback;
	}

	/**
	 * Permission callback invoked with the WP_REST_Request.
	 */
	public function permissionCallback(): callable {
		return $this->permissionCallback;
	}

	/**
	 * Closed set of HTTP methods recognized by this slice.
	 *
	 * @return list<string>
	 */
	public static function validMethods(): array {
		return self::VALID_METHODS;
	}

	/**
	 * Wrap route-specific permission logic with WordPress capability checks.
	 *
	 * @param string   $routeToken         Capability-policy route token.
	 * @param bool     $isWrite            Whether the route mutates state.
	 * @param callable $permissionCallback Route-specific permission callback.
	 */
	private static function withCapabilityPermission( string $routeToken, bool $isWrite, callable $permissionCallback ): callable {
		return static function ( $request = null ) use ( $routeToken, $isWrite, $permissionCallback ): bool {
			if ( ! (bool) $permissionCallback( $request ) ) {
				return false;
			}

			$required = $isWrite
				? CapabilityPolicy::requiredForWrite( $routeToken )
				: CapabilityPolicy::requiredForRead( $routeToken );
			if ( $required === array() ) {
				return true;
			}
			if ( ! function_exists( 'current_user_can' ) ) {
				return false;
			}
			foreach ( $required as $capability ) {
				if ( current_user_can( $capability ) ) {
					return true;
				}
			}
			return false;
		};
	}

	/**
	 * Return the route registration metadata in the shape
	 * WordPress's `register_rest_route()` consumes.
	 *
	 * @return array<string, mixed>
	 */
	public function toArgsArray(): array {
		return array(
			'methods'             => $this->method,
			'callback'            => $this->callback,
			'permission_callback' => $this->permissionCallback,
			'args'                => $this->args,
		);
	}
}
