<?php
/**
 * Block definition value object.
 *
 * Encapsulates one Gutenberg block's registration metadata for forwarding
 * to WordPress's `register_block_type_from_metadata()` through the
 * {@see \CommerceLinkBuilder\Presentation\Block\BlockRegistrar}. Each
 * definition pairs the absolute filesystem path of the block's metadata
 * directory (or its `block.json` file) with the optional second-arg
 * `$args` array that `register_block_type_from_metadata()` accepts (for
 * example to inject a `render_callback` that closes over collaborators
 * resolved from the service-provider registry).
 *
 * The class is deliberately a plain immutable VO with no behavior beyond
 * validation and a fluent {@see self::withArgs()} wither: it exists so
 * that the {@see BlockRegistrar} can be driven from data rather than
 * imperative `register_block_type_from_metadata()` calls scattered
 * across the codebase.
 *
 * Validation rules:
 *
 *   - `$blockMetadataPath` is trimmed and rejected when empty. The
 *     path may point to either the block's metadata directory or the
 *     `block.json` file directly because
 *     `register_block_type_from_metadata()` accepts either form;
 *     deeper validation (file-exists / directory-exists) is the
 *     registrar's concern at registration time, not the VO's.
 *
 *   - `$blockName` MUST match the spec §3.1 grammar
 *     `^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$` (namespace/name, lowercase
 *     ASCII + hyphen, single forward slash separator). Whitespace,
 *     uppercase, leading digit, and missing slash are all rejected
 *     with developer-facing exception messages.
 *
 *   - `$args` is permitted to be empty; phase-7 slice 7a wires an
 *     empty array because the render callback is added in slice 7b.
 *
 * @package CommerceLinkBuilder\Presentation\Block
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Block;

use InvalidArgumentException;

/**
 * Immutable value object describing a single block registration.
 *
 * See the file-level docblock for validation rules and the slice-7a
 * scope (empty `$args` until slice 7b populates `render_callback`).
 */
final class BlockDefinition {

	/**
	 * Spec §3.1 grammar for `namespace/name` block identifiers:
	 * lowercase ASCII + hyphen on each side of a single forward
	 * slash, with each side starting with a letter.
	 */
	private const BLOCK_NAME_PATTERN = '#^[a-z][a-z0-9-]*\/[a-z][a-z0-9-]*$#';

	/**
	 * Absolute filesystem path of the block's metadata directory or
	 * its `block.json` file. WordPress's
	 * `register_block_type_from_metadata()` accepts either form.
	 */
	private string $blockMetadataPath;

	/**
	 * `register_block_type_from_metadata()` second-arg overrides.
	 *
	 * Typically contains `render_callback`, `view_script_handles`,
	 * etc. Phase-7 slice 7a wires an empty array; slice 7b populates
	 * `render_callback`.
	 *
	 * @var array<string, mixed>
	 */
	private array $args;

	/**
	 * Block name in `namespace/name` form (e.g. `clb/product-card`).
	 */
	private string $blockName;

	/**
	 * Construct an immutable block definition.
	 *
	 * @param string               $blockMetadataPath Absolute path to the block's metadata directory
	 *                                                or `block.json` file.
	 * @param string               $blockName         Block name in `namespace/name` form.
	 * @param array<string, mixed> $args              Optional second-arg overrides forwarded to
	 *                                                `register_block_type_from_metadata()`.
	 *
	 * @throws InvalidArgumentException When `$blockMetadataPath` is empty after trimming.
	 * @throws InvalidArgumentException When `$blockName` is empty after trimming or violates the §3.1 grammar.
	 */
	public function __construct(
		string $blockMetadataPath,
		string $blockName,
		array $args = array()
	) {
		$trimmedPath = trim( $blockMetadataPath );
		if ( $trimmedPath === '' ) {
			$message = 'BlockDefinition blockMetadataPath must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

		if ( $trimmedName !== $blockName ) {
			$message = sprintf(
				'BlockDefinition blockName must not contain leading or trailing whitespace; got "%s".',
				$blockName
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( preg_match( self::BLOCK_NAME_PATTERN, $trimmedName ) !== 1 ) {
			$message = sprintf(
				'BlockDefinition blockName must match the spec §3.1 grammar "namespace/name" '
				. '(lowercase ASCII + hyphen, single forward slash); got "%s".',
				$trimmedName
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->blockMetadataPath = $trimmedPath;
		$this->blockName         = $trimmedName;
		$this->args              = $args;
	}

	/**
	 * Return the absolute path of the block's metadata directory or
	 * `block.json` file.
	 */
	public function blockMetadataPath(): string {
		return $this->blockMetadataPath;
	}

	/**
	 * Return the `register_block_type_from_metadata()` second-arg
	 * overrides.
	 *
	 * @return array<string, mixed>
	 */
	public function args(): array {
		return $this->args;
	}

	/**
	 * Return the block's `namespace/name` identifier.
	 */
	public function blockName(): string {
		return $this->blockName;
	}

	/**
	 * Return a copy of this definition with the supplied `$args`
	 * array replacing the current one.
	 *
	 * Used by downstream slices (slice 7b) to attach a
	 * `render_callback` after the BlockDefinition has been
	 * constructed without exposing a mutable setter.
	 *
	 * @param array<string, mixed> $args Replacement args array.
	 */
	public function withArgs( array $args ): self {
		return new self( $this->blockMetadataPath, $this->blockName, $args );
	}

	/**
	 * Report whether `$other` describes the same block registration
	 * as this definition.
	 *
	 * Equality is structural: the metadata path, block name, and
	 * args array must all be `===` equivalent. Closures inside
	 * `args` therefore compare by identity, which matches the
	 * registrar's forwarding semantics.
	 *
	 * @param self $other Definition to compare against.
	 */
	public function equals( self $other ): bool {
		return $this->blockMetadataPath === $other->blockMetadataPath
			&& $this->blockName === $other->blockName
			&& $this->args === $other->args;
	}
}
