<?php
/**
 * Provider registry value object.
 *
 * Phase 12 slice 12c Application-layer container that holds the
 * canonical map of registered {@see ProviderInterface} implementations
 * keyed by their §4 stable provider key (`amazon`, `rakuten`,
 * `yahoo_shopping`). The registry is the single seam every consumer of
 * the §10.3 / §12.1 enumerations resolves through, replacing the
 * Phase-6 / Phase-11 minimum that wired each consumer with its own
 * empty `array()` default.
 *
 * Contract:
 *
 *   - Construction validates that every key is a non-empty lowercase
 *     ASCII identifier matching `^[a-z][a-z0-9_]*$` (the same grammar
 *     {@see \CommerceLinkBuilder\Application\Settings\ProviderCredentials}
 *     enforces) AND that the key matches its entry's
 *     {@see ProviderInterface::key()} value. The cross-check rules
 *     out registries whose array key disagrees with the provider's
 *     own canonical key — a class of programming error that would
 *     otherwise surface only at the §10.3 enumeration boundary.
 *
 *   - The empty registry is permitted. Fresh installs without
 *     configured providers must yield an empty registry rather than
 *     a fatal boot error so the editor / diagnostics surfaces remain
 *     operational while the admin completes provider setup.
 *
 *   - Iteration order is canonical: {@see self::all()} and
 *     {@see self::keys()} preserve the constructor's input order
 *     verbatim. Callers (typically {@see \CommerceLinkBuilder\Presentation\Rest\ProviderServiceProvider})
 *     are responsible for handing the constructor a map already in
 *     the desired display order (`amazon`, `rakuten`,
 *     `yahoo_shopping`).
 *
 *   - Lookup helpers ({@see self::has()}, {@see self::get()}) operate
 *     on the canonical key. {@see self::get()} raises
 *     {@see InvalidArgumentException} when the key is absent — this
 *     is a programming-error path because callers are expected to
 *     gate {@see self::get()} behind {@see self::has()} or rely on
 *     {@see self::all()} when iterating.
 *
 * Instances are immutable; mutation is achieved by constructing a
 * fresh registry from the current map.
 *
 * @package CommerceLinkBuilder\Application\Provider
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Provider;

use CommerceLinkBuilder\Domain\Provider\ProviderInterface;
use InvalidArgumentException;

/**
 * Immutable, canonical container of registered providers.
 *
 * See the file-level docblock for the validation rules every
 * registry entry must satisfy and the iteration-order guarantee
 * the {@see self::all()} / {@see self::keys()} accessors honor.
 */
final class ProviderRegistry {

	/**
	 * Grammar every registry key MUST match. Mirrors the
	 * {@see \CommerceLinkBuilder\Application\Settings\ProviderCredentials}
	 * provider-key grammar so the two surfaces accept the same
	 * closed set of identifiers.
	 */
	private const KEY_PATTERN = '/^[a-z][a-z0-9_]*$/';

	/**
	 * Registered providers keyed by their §4 stable token.
	 *
	 * Iteration order is the constructor's input order; every
	 * accessor preserves it verbatim.
	 *
	 * @var array<string, ProviderInterface>
	 */
	private array $providersByKey;

	/**
	 * Construct a registry with the supplied provider map.
	 *
	 * Validation:
	 *
	 *   - Empty map is allowed.
	 *
	 *   - Every key MUST match {@see self::KEY_PATTERN}.
	 *     Non-string keys, empty-string keys, and keys with
	 *     uppercase / non-ASCII characters all raise
	 *     {@see InvalidArgumentException}.
	 *
	 *   - Every value MUST implement {@see ProviderInterface}.
	 *     Other types raise {@see InvalidArgumentException}.
	 *
	 *   - The array key MUST equal the entry's
	 *     {@see ProviderInterface::key()} value. The cross-check
	 *     rules out registries whose array key disagrees with the
	 *     provider's own canonical key — a class of programming
	 *     error that would otherwise surface only at the §10.3
	 *     enumeration boundary.
	 *
	 * @param array<string, ProviderInterface> $providersByKey Provider map.
	 *
	 * @throws InvalidArgumentException When any registry entry
	 *                                  violates the validation
	 *                                  rules above.
	 */
	public function __construct( array $providersByKey ) {
		$normalized = array();
		foreach ( $providersByKey as $key => $provider ) {
			if ( ! is_string( $key ) ) {
				$message = sprintf(
					'ProviderRegistry: provider keys must be strings; got %s.',
					gettype( $key )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( preg_match( self::KEY_PATTERN, $key ) !== 1 ) {
				$message = sprintf(
					'ProviderRegistry: provider key "%s" must match ^[a-z][a-z0-9_]*$.',
					$key
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( ! $provider instanceof ProviderInterface ) {
				$message = sprintf(
					'ProviderRegistry: provider["%s"] must implement ProviderInterface; got %s.',
					$key,
					is_object( $provider ) ? get_class( $provider ) : gettype( $provider )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$providerKey = $provider->key();
			if ( $providerKey !== $key ) {
				$message = sprintf(
					'ProviderRegistry: registry key "%s" must match provider key "%s".',
					$key,
					$providerKey
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[ $key ] = $provider;
		}

		$this->providersByKey = $normalized;
	}

	/**
	 * Return the full provider map in registration order.
	 *
	 * @return array<string, ProviderInterface>
	 */
	public function all(): array {
		return $this->providersByKey;
	}

	/**
	 * Report whether a provider is registered under the given key.
	 *
	 * @param string $key §4 stable provider key.
	 */
	public function has( string $key ): bool {
		return array_key_exists( $key, $this->providersByKey );
	}

	/**
	 * Resolve the provider registered under the given key.
	 *
	 * @param string $key §4 stable provider key.
	 *
	 * @throws InvalidArgumentException When no provider is
	 *                                  registered under `$key`.
	 */
	public function get( string $key ): ProviderInterface {
		if ( ! array_key_exists( $key, $this->providersByKey ) ) {
			$message = sprintf(
				'ProviderRegistry: no provider registered under "%s".',
				$key
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

	/**
	 * Return the registered keys in registration order.
	 *
	 * @return list<string>
	 */
	public function keys(): array {
		return array_keys( $this->providersByKey );
	}
}
