<?php
/**
 * REST route registrar.
 *
 * Holds an ordered list of {@see RouteDefinition} values and
 * registers each one with WordPress when {@see self::register()} is
 * called. The registrar is the single seam through which the REST
 * service provider invokes WordPress's `register_rest_route()`, so
 * tests can inject a recording callable and assert that every
 * configured route was forwarded with the expected arguments
 * without booting WordPress.
 *
 * Lifecycle:
 *
 *   1. The {@see \CommerceLinkBuilder\Presentation\Rest\RestServiceProvider}
 *      constructs a registrar during register() and binds it into
 *      the service-provider registry under the
 *      `BINDING_ROUTE_REGISTRAR` key.
 *
 *   2. Subsequent slices' service providers (slice 6b+) resolve the
 *      registrar from the registry and append their concrete
 *      {@see RouteDefinition} values via {@see self::add()} /
 *      {@see self::addAll()} from their boot() methods.
 *
 *   3. The REST service provider subscribes a `rest_api_init`
 *      action callback that calls {@see self::register()} so every
 *      accumulated route is forwarded to WordPress at the canonical
 *      registration moment.
 *
 * The dependency on `register_rest_route()` is wrapped behind a
 * single optional callable injected at construction so the
 * registrar is fully testable without WordPress loaded; the default
 * callable forwards to the global function when available and
 * raises a {@see \LogicException} otherwise so a misconfigured
 * production runtime fails loudly rather than silently dropping
 * routes.
 *
 * @package CommerceLinkBuilder\Presentation\Rest\Routing
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Rest\Routing;

use LogicException;

/**
 * Holds {@see RouteDefinition} values and registers them with
 * WordPress through an injected callable.
 *
 * See the file-level docblock for the lifecycle the
 * {@see \CommerceLinkBuilder\Presentation\Rest\RestServiceProvider}
 * drives and the testability rationale for the injected
 * registration callable.
 */
final class RouteRegistrar {

	/**
	 * Accumulated route definitions in insertion order.
	 *
	 * @var list<RouteDefinition>
	 */
	private array $definitions = array();

	/**
	 * Callable that forwards a single route registration to
	 * WordPress.
	 *
	 * Signature: `function (string $namespace, string $route, array<string, mixed> $args): void`.
	 *
	 * @var callable
	 */
	private $registerRouteFn;

	/**
	 * Construct a registrar with an optional override for the
	 * registration callable.
	 *
	 * The default callable forwards to the global
	 * `register_rest_route()` when it is available (the normal
	 * runtime path) and raises a {@see LogicException} otherwise so
	 * a misconfigured production runtime fails loudly rather than
	 * silently dropping routes. Tests pass an in-memory recording
	 * callable to drive the registrar without booting WordPress.
	 *
	 * @param callable|null $registerRouteFn Override for the registration callable.
	 *
	 * @phpstan-param (callable(string, string, array<string, mixed>): void)|null $registerRouteFn
	 */
	public function __construct( ?callable $registerRouteFn = null ) {
		$this->registerRouteFn = $registerRouteFn ?? static function (
			// 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,
			array $args
		): void {
			if ( ! function_exists( 'register_rest_route' ) ) {
				throw new LogicException(
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
					'RouteRegistrar default forwarder requires the global register_rest_route() function.'
				);
			}

			register_rest_route( $namespace, $route, $args );
		};
	}

	/**
	 * Append a single {@see RouteDefinition} to the registrar.
	 *
	 * Returns the registrar for fluent chaining; subsequent slices
	 * typically append several definitions at once via
	 * {@see self::addAll()}.
	 *
	 * @param RouteDefinition $def Definition to append.
	 */
	public function add( RouteDefinition $def ): self {
		$this->definitions[] = $def;

		return $this;
	}

	/**
	 * Append every definition in `$defs` in order.
	 *
	 * @param list<RouteDefinition> $defs Definitions to append.
	 */
	public function addAll( array $defs ): self {
		foreach ( $defs as $def ) {
			$this->add( $def );
		}

		return $this;
	}

	/**
	 * Forward every accumulated definition to the configured
	 * registration callable.
	 *
	 * The registrar does not clear its internal list after
	 * registration so tests can re-assert against the configured
	 * definitions; production callers MUST NOT invoke this method
	 * more than once per request because WordPress's
	 * `register_rest_route()` is not idempotent on repeated calls
	 * with identical args.
	 */
	public function register(): void {
		foreach ( $this->definitions as $def ) {
			( $this->registerRouteFn )(
				$def->namespace(),
				$def->route(),
				$def->toArgsArray()
			);
		}
	}

	/**
	 * Return every definition that has been added, in insertion
	 * order.
	 *
	 * @return list<RouteDefinition>
	 */
	public function definitions(): array {
		return $this->definitions;
	}
}
