<?php
/**
 * Service provider registry.
 *
 * Holds the ordered list of {@see ServiceProvider} instances that make up
 * the Commerce Link Builder bootstrap pipeline, and drives them through
 * the two-phase register() / boot() lifecycle documented on
 * {@see ServiceProvider}.
 *
 * The registry is also the single place through which providers may
 * resolve collaborators that were bound during register(). A minimal
 * key/value binding store is provided for that purpose; it is deliberately
 * not a full IoC container. More elaborate dependency resolution will be
 * layered on top in a later slice if required.
 *
 * Lifecycle:
 *
 *   1. The {@see \CommerceLinkBuilder\Bootstrap\Plugin} bootstrap creates
 *      a registry and adds providers to it via {@see self::add()}.
 *
 *   2. {@see self::registerAll()} is called exactly once. It iterates
 *      every provider in insertion order and invokes register(). After
 *      this call the registry is considered "registered" and further
 *      calls to add() will throw.
 *
 *   3. {@see self::bootAll()} is called exactly once, after WordPress has
 *      fired `plugins_loaded`. It iterates every provider in insertion
 *      order and invokes boot(), passing the shared HookRegistrar.
 *
 * All methods are safe to call from test contexts that do not have
 * WordPress loaded; the registry itself performs no WordPress API calls.
 *
 * @package CommerceLinkBuilder\Bootstrap
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Bootstrap;

use LogicException;
use OutOfBoundsException;

/**
 * Ordered registry of service providers plus a minimal binding store.
 */
final class ServiceProviderRegistry {

	/**
	 * Providers in insertion order.
	 *
	 * @var list<ServiceProvider>
	 */
	private array $providers = array();

	/**
	 * Key/value bindings resolved by providers during register().
	 *
	 * Keys are arbitrary strings chosen by the binding provider; values
	 * may be any type. Consumers should treat unknown keys as missing
	 * and handle them explicitly via {@see self::has()} or
	 * {@see self::get()}.
	 *
	 * @var array<string, mixed>
	 */
	private array $bindings = array();

	/**
	 * Whether {@see self::registerAll()} has already been invoked.
	 */
	private bool $registered = false;

	/**
	 * Whether {@see self::bootAll()} has already been invoked.
	 */
	private bool $booted = false;

	/**
	 * Append a provider to the pipeline.
	 *
	 * Providers are invoked during registerAll() / bootAll() in the order
	 * they were added. Callers that require a specific ordering (for
	 * example, a logging provider that must register before any other
	 * provider tries to read the logger binding) should add that provider
	 * first.
	 *
	 * @param ServiceProvider $provider Provider instance to append.
	 *
	 * @throws LogicException When called after registerAll() has run.
	 */
	public function add( ServiceProvider $provider ): void {
		if ( $this->registered ) {
			throw new LogicException(
				'ServiceProviderRegistry::add() cannot be called after registerAll() has run.'
			);
		}

		$this->providers[] = $provider;
	}

	/**
	 * Return every provider that has been added, in insertion order.
	 *
	 * Primarily useful for tests and diagnostics; production code should
	 * not need to enumerate providers directly.
	 *
	 * @return list<ServiceProvider>
	 */
	public function providers(): array {
		return $this->providers;
	}

	/**
	 * Report how many providers have been added.
	 */
	public function count(): int {
		return count( $this->providers );
	}

	/**
	 * Bind a value under the given key.
	 *
	 * Intended to be called from within a provider's register() method.
	 * Re-binding an existing key is allowed and overwrites the previous
	 * value; callers that want to detect collisions should check
	 * {@see self::has()} first.
	 *
	 * @param string $key   Binding key.
	 * @param mixed  $value Value to store.
	 */
	public function bind( string $key, $value ): void {
		$this->bindings[ $key ] = $value;
	}

	/**
	 * Return whether a binding exists for the given key.
	 *
	 * @param string $key Binding key.
	 */
	public function has( string $key ): bool {
		return array_key_exists( $key, $this->bindings );
	}

	/**
	 * Resolve the value bound under the given key.
	 *
	 * @param string $key Binding key.
	 *
	 * @return mixed The stored value.
	 *
	 * @throws OutOfBoundsException When no binding exists for the key.
	 */
	public function get( string $key ) {
		if ( ! array_key_exists( $key, $this->bindings ) ) {
			throw new OutOfBoundsException(
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Exception messages are developer-facing and never rendered to HTML.
				sprintf( 'No binding registered for key "%s".', $key )
			);
		}

		return $this->bindings[ $key ];
	}

	/**
	 * Report whether registerAll() has already been invoked.
	 */
	public function isRegistered(): bool {
		return $this->registered;
	}

	/**
	 * Report whether bootAll() has already been invoked.
	 */
	public function isBooted(): bool {
		return $this->booted;
	}

	/**
	 * Invoke register() on every provider in insertion order.
	 *
	 * Safe to call exactly once per registry. Subsequent calls are no-ops
	 * so that accidental double-bootstrapping does not corrupt bindings.
	 */
	public function registerAll(): void {
		if ( $this->registered ) {
			return;
		}

		foreach ( $this->providers as $provider ) {
			$provider->register( $this );
		}

		$this->registered = true;
	}

	/**
	 * Invoke boot() on every provider in insertion order.
	 *
	 * Must be called after registerAll(); calling it first will throw so
	 * that ordering bugs surface loudly instead of leaving half-wired
	 * providers. Like registerAll(), repeat calls are no-ops.
	 *
	 * @param HookRegistrar $hooks Shared hook registrar passed to each
	 *                             provider's boot() implementation.
	 *
	 * @throws LogicException When called before registerAll().
	 */
	public function bootAll( HookRegistrar $hooks ): void {
		if ( ! $this->registered ) {
			throw new LogicException(
				'ServiceProviderRegistry::bootAll() cannot be called before registerAll().'
			);
		}

		if ( $this->booted ) {
			return;
		}

		foreach ( $this->providers as $provider ) {
			$provider->boot( $hooks );
		}

		$this->booted = true;
	}
}
