<?php
/**
 * Block registrar.
 *
 * Holds an ordered list of {@see BlockDefinition} values and registers
 * each one with WordPress when {@see self::register()} is called. The
 * registrar is the single seam through which the
 * {@see BlockServiceProvider} invokes WordPress's
 * `register_block_type_from_metadata()`, so tests can inject a recording
 * callable and assert that every configured block was forwarded with
 * the expected arguments without booting WordPress.
 *
 * Lifecycle:
 *
 *   1. The {@see BlockServiceProvider} constructs a registrar during
 *      register() and binds it into the service-provider registry under
 *      the `BINDING_BLOCK_REGISTRAR` key.
 *
 *   2. The provider appends a {@see BlockDefinition} for the
 *      `affilun/product-card` block during register(); subsequent slices
 *      (7b+) may resolve the registrar from the registry and append
 *      additional definitions or replace existing args via
 *      {@see BlockDefinition::withArgs()}.
 *
 *   3. The provider subscribes an `init` action callback that calls
 *      {@see self::register()} so every accumulated definition is
 *      forwarded to WordPress at the canonical block-registration
 *      moment (before `enqueue_block_editor_assets`).
 *
 * The dependency on `register_block_type_from_metadata()` 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 block
 * registrations.
 *
 * @package Affilun\Presentation\Block
 */

declare(strict_types=1);

namespace Affilun\Presentation\Block;

use LogicException;

/**
 * Holds {@see BlockDefinition} values and registers them with WordPress
 * through an injected callable.
 *
 * See the file-level docblock for the lifecycle the
 * {@see BlockServiceProvider} drives and the testability rationale for
 * the injected registration callable.
 */
final class BlockRegistrar {

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

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

	/**
	 * Construct a registrar with an optional override for the
	 * registration callable.
	 *
	 * The default callable forwards to the global
	 * `register_block_type_from_metadata()` 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 block registrations. Tests pass an in-memory
	 * recording callable to drive the registrar without booting
	 * WordPress.
	 *
	 * @param callable|null $registerFn Override for the registration callable.
	 *
	 * @phpstan-param (callable(string, array<string, mixed>): mixed)|null $registerFn
	 */
	public function __construct( ?callable $registerFn = null ) {
		$this->registerFn = $registerFn ?? static function (
			string $blockMetadataPath,
			array $args
		): mixed {
			if ( ! function_exists( 'register_block_type_from_metadata' ) ) {
				throw new LogicException(
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
					'BlockRegistrar default forwarder requires the global '
					. 'register_block_type_from_metadata() function.'
				);
			}

			return register_block_type_from_metadata( $blockMetadataPath, $args );
		};
	}

	/**
	 * Append a single {@see BlockDefinition} to the registrar.
	 *
	 * Returns the registrar for fluent chaining.
	 *
	 * @param BlockDefinition $definition Definition to append.
	 */
	public function add( BlockDefinition $definition ): self {
		$this->definitions[] = $definition;

		return $this;
	}

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

		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_block_type_from_metadata()` is not idempotent on
	 * repeated calls with identical args.
	 */
	public function register(): void {
		foreach ( $this->definitions as $definition ) {
			( $this->registerFn )(
				$definition->blockMetadataPath(),
				$definition->args()
			);
		}
	}

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