<?php
/**
 * Admin-menu registrar.
 *
 * Holds an ordered list of {@see AdminPageDefinition} values and
 * registers each one with WordPress when {@see self::register()} is
 * called. The registrar is the single seam through which the
 * Phase-8 slice 8c-2 admin-service provider invokes WordPress's
 * `add_menu_page()` / `add_submenu_page()` functions, so tests can
 * inject recording callables and assert that every configured page
 * was forwarded with the expected arguments without booting
 * WordPress.
 *
 * Lifecycle (slice 8c-2 wires the surrounding plumbing; this slice
 * delivers the registrar primitive only):
 *
 *   1. The admin-service provider constructs a registrar during
 *      register() and binds it into the service-provider registry.
 *
 *   2. Subsequent slices append concrete {@see AdminPageDefinition}
 *      values via {@see self::add()} / {@see self::addAll()}.
 *
 *   3. The provider subscribes an `admin_menu` action callback that
 *      calls {@see self::register()} so every accumulated page is
 *      forwarded to WordPress at the canonical registration moment.
 *
 * The dependencies on `add_menu_page()` / `add_submenu_page()` are
 * wrapped behind two optional callables injected at construction so
 * the registrar is fully testable without WordPress loaded; the
 * default callables forward to the global functions when available
 * and raise a {@see LogicException} otherwise so a misconfigured
 * production runtime fails loudly rather than silently dropping
 * admin pages.
 *
 * Load-time hook wiring: when an {@see AdminPageDefinition}'s
 * `loadCallback` is non-null, the default forwarders also register
 * an action on `load-{$hookSuffix}` (the suffix WordPress returns
 * from `add_menu_page()` / `add_submenu_page()`) so the page's
 * load-time logic runs at the canonical moment. The `add_action()`
 * forwarding is guarded by `function_exists()` so the registrar
 * remains testable without WordPress.
 *
 * @package CommerceLinkBuilder\Presentation\Admin
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Presentation\Admin;

use LogicException;

/**
 * Holds {@see AdminPageDefinition} values and registers them with
 * WordPress through two injected callables (one for top-level
 * menus, one for submenus).
 *
 * See the file-level docblock for the lifecycle the slice 8c-2
 * admin-service provider drives, the testability rationale for the
 * injected registration callables, and the load-time hook wiring
 * the default forwarders perform.
 */
final class AdminMenuRegistrar {

	/**
	 * Accumulated admin-page definitions in insertion order.
	 *
	 * @var list<AdminPageDefinition>
	 */
	private array $definitions = array();

	/**
	 * Callable that forwards a single top-level admin-page
	 * registration to WordPress.
	 *
	 * Signature: `function (AdminPageDefinition $definition): void`.
	 *
	 * @var callable
	 */
	private $registerMenuFn;

	/**
	 * Callable that forwards a single submenu admin-page
	 * registration to WordPress.
	 *
	 * Signature: `function (AdminPageDefinition $definition): void`.
	 *
	 * @var callable
	 */
	private $registerSubmenuFn;

	/**
	 * Construct a registrar with optional overrides for the two
	 * registration callables.
	 *
	 * The default callables forward to the global
	 * `add_menu_page()` / `add_submenu_page()` functions when
	 * available (the normal runtime path) and raise a
	 * {@see LogicException} otherwise so a misconfigured production
	 * runtime fails loudly rather than silently dropping admin
	 * pages. Tests pass in-memory recording callables to drive the
	 * registrar without booting WordPress.
	 *
	 * @param callable|null $registerMenuFn    Override for the top-level registration callable.
	 * @param callable|null $registerSubmenuFn Override for the submenu registration callable.
	 *
	 * @phpstan-param (callable(AdminPageDefinition): void)|null $registerMenuFn
	 * @phpstan-param (callable(AdminPageDefinition): void)|null $registerSubmenuFn
	 */
	public function __construct(
		?callable $registerMenuFn = null,
		?callable $registerSubmenuFn = null
	) {
		$this->registerMenuFn    = $registerMenuFn ?? self::defaultRegisterMenuFn();
		$this->registerSubmenuFn = $registerSubmenuFn ?? self::defaultRegisterSubmenuFn();
	}

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

		return $this;
	}

	/**
	 * Append every definition in `$definitions` in order.
	 *
	 * @param list<AdminPageDefinition> $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 appropriate
	 * registration callable: top-level definitions go to
	 * `$registerMenuFn`, submenus go to `$registerSubmenuFn`.
	 *
	 * 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
	 * `add_menu_page()` / `add_submenu_page()` are not idempotent
	 * on repeated calls with identical args.
	 */
	public function register(): void {
		foreach ( $this->definitions as $definition ) {
			if ( $definition->isTopLevel() ) {
				( $this->registerMenuFn )( $definition );
				continue;
			}

			( $this->registerSubmenuFn )( $definition );
		}
	}

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

	/**
	 * Build the default top-level registration callable.
	 *
	 * Forwards to the global `add_menu_page()` when available and
	 * wires a `load-{$hookSuffix}` action when the definition's
	 * `loadCallback` is non-null and `add_action()` is available.
	 *
	 * Phase 2 KI-010 closeout: the function-existence probe is
	 * injectable so the LogicException branch can be exercised
	 * without process isolation. Default resolves to the global
	 * `function_exists()` so production behavior is unchanged.
	 *
	 * @param callable(string): bool|null $functionExistsFn Optional probe;
	 *                                                      defaults to the
	 *                                                      global
	 *                                                      `function_exists()`.
	 *
	 * @phpstan-return callable(AdminPageDefinition): void
	 */
	public static function defaultRegisterMenuFn( ?callable $functionExistsFn = null ): callable {
		$probe = $functionExistsFn ?? static fn ( string $name ): bool => function_exists( $name );

		return static function ( AdminPageDefinition $definition ) use ( $probe ): void {
			if ( ! $probe( 'add_menu_page' ) ) {
				throw new LogicException(
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
					'AdminMenuRegistrar default forwarder requires the global add_menu_page() function.'
				);
			}

			$hookSuffix = add_menu_page(
				$definition->pageTitle(),
				$definition->menuTitle(),
				$definition->capability(),
				$definition->menuSlug(),
				$definition->renderCallback(),
				$definition->iconUrl(),
				$definition->position() === 0 ? null : $definition->position()
			);

			self::wireLoadCallback( $definition, $hookSuffix );
		};
	}

	/**
	 * Build the default submenu registration callable.
	 *
	 * Forwards to the global `add_submenu_page()` when available
	 * and wires a `load-{$hookSuffix}` action when the definition's
	 * `loadCallback` is non-null and `add_action()` is available.
	 *
	 * Phase 2 KI-010 closeout: the function-existence probe is
	 * injectable so the LogicException branch can be exercised
	 * without process isolation. Default resolves to the global
	 * `function_exists()` so production behavior is unchanged.
	 *
	 * @param callable(string): bool|null $functionExistsFn Optional probe;
	 *                                                      defaults to the
	 *                                                      global
	 *                                                      `function_exists()`.
	 *
	 * @phpstan-return callable(AdminPageDefinition): void
	 */
	public static function defaultRegisterSubmenuFn( ?callable $functionExistsFn = null ): callable {
		$probe = $functionExistsFn ?? static fn ( string $name ): bool => function_exists( $name );

		return static function ( AdminPageDefinition $definition ) use ( $probe ): void {
			if ( ! $probe( 'add_submenu_page' ) ) {
				throw new LogicException(
					// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
					'AdminMenuRegistrar default forwarder requires the global add_submenu_page() function.'
				);
			}

			$hookSuffix = add_submenu_page(
				$definition->parentSlug(),
				$definition->pageTitle(),
				$definition->menuTitle(),
				$definition->capability(),
				$definition->menuSlug(),
				$definition->renderCallback(),
				$definition->position() === 0 ? null : $definition->position()
			);

			self::wireLoadCallback( $definition, $hookSuffix );
		};
	}

	/**
	 * Register the definition's `loadCallback` against the
	 * `load-{$hookSuffix}` action when both are available.
	 *
	 * No-op when the definition carries no load callback, when
	 * WordPress returned a non-string hook suffix (which happens
	 * when the user lacks the required capability), or when the
	 * `add_action()` global is missing.
	 *
	 * @param AdminPageDefinition $definition Source definition.
	 * @param mixed               $hookSuffix Hook suffix returned by `add_menu_page()` / `add_submenu_page()`.
	 */
	private static function wireLoadCallback(
		AdminPageDefinition $definition,
		$hookSuffix
	): void {
		$loadCallback = $definition->loadCallback();
		if ( $loadCallback === null ) {
			return;
		}

		if ( ! is_string( $hookSuffix ) || $hookSuffix === '' ) {
			return;
		}

		if ( ! function_exists( 'add_action' ) ) {
			return;
		}

		add_action( 'load-' . $hookSuffix, $loadCallback );
	}
}
