<?php
/**
 * Search products use case.
 *
 * Phase 6 Application-layer UseCase that drives the §8.1
 * `GET /clb/v1/products/search` endpoint. The class translates the
 * REST controller's normalized {@see SearchQuery} + caller-supplied
 * provider token into a {@see SearchResultBundle}, dispatching to
 * one or more registered {@see SearchProviderInterface}
 * implementations and capturing per-provider failures into the
 * §8.1 `meta.warnings` map when the caller requested cross-provider
 * mode (`provider=all`).
 *
 * Dispatch semantics match `specifications/rest-api-spec.md` §8.1
 * exactly:
 *
 *   - When the caller supplies {@see self::PROVIDER_TOKEN_ALL}, the
 *     UseCase invokes every registered provider's
 *     {@see SearchProviderInterface::searchProducts()} in
 *     registration order and collects:
 *
 *       * Successful results into the bundle's `results` list, in
 *         the same registration order. Providers that returned an
 *         empty {@see SearchResult} (zero matches is a normal-path
 *         outcome per `provider-api-contracts.md` §6.2) contribute
 *         the empty entry — they did not fail.
 *
 *       * Provider exceptions ({@see ProviderException} and any
 *         §10.2 subclass) into the bundle's `providerWarnings`
 *         map, keyed by the registry token the caller would have
 *         used to address the provider individually. The caught
 *         exception is recorded verbatim so the REST mapper can
 *         render its §10.2 error code, the §10.3 admin-facing
 *         label, and the §10 structured context fields onto
 *         `meta.warnings[]`. The §8.1 partial-success contract
 *         binds here: a single provider failing in `all` mode
 *         MUST NOT propagate as a request-level error.
 *
 *   - When the caller supplies a single provider token, the
 *     UseCase invokes that provider once. Provider exceptions
 *     PROPAGATE up to the controller verbatim — single-provider
 *     mode treats a provider failure as the request's failure
 *     because there is no fallback peer that could carry the
 *     response. The §8.1 `meta.warnings` envelope is reserved
 *     for the cross-provider case; single-provider failures map
 *     to the request-level error envelope instead.
 *
 *   - Unknown single-provider tokens are a programming error in
 *     the controller (the §5.1 capability gating should have
 *     caught it before reaching the UseCase) and surface as
 *     {@see InvalidArgumentException} carrying the offending
 *     token for diagnosis.
 *
 * Design notes:
 *
 *   - The provider registry is supplied as a positional
 *     `array<string, SearchProviderInterface>` keyed by the §4
 *     stable provider token. The UseCase honors the array's
 *     iteration order verbatim so the bundle's `results` list
 *     reflects the order the controller / DI container chose;
 *     the REST mapper relies on this ordering to render the §8.1
 *     example's "Amazon → Yahoo → Rakuten" sequence.
 *
 *   - The registry MUST carry at least one entry. An empty
 *     registry is a programming error at boot time (the DI
 *     container forgot to wire any provider) and surfaces as
 *     {@see \LogicException}; the caller cannot meaningfully
 *     recover from this state at runtime.
 *
 *   - The constructor validates every key/value pair so a
 *     misconfigured DI binding fails loudly at construction
 *     rather than at the first dispatch.
 *
 *   - Non-{@see ProviderException} throwables (programming errors,
 *     {@see \TypeError}, {@see \LogicException}) are NOT captured
 *     into the warnings map even in `all` mode: they propagate so
 *     the failure surfaces verbatim rather than being silently
 *     downgraded to a partial-success envelope.
 *
 * @package CommerceLinkBuilder\Application\Product\UseCase
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Product\UseCase;

use CommerceLinkBuilder\Domain\Provider\Exception\ProviderException;
use CommerceLinkBuilder\Domain\Provider\SearchProviderInterface;
use CommerceLinkBuilder\Domain\Search\SearchQuery;
use CommerceLinkBuilder\Domain\Search\SearchResult;
use InvalidArgumentException;
use LogicException;

/**
 * Phase 6 UseCase that drives the §8.1
 * `GET /clb/v1/products/search` endpoint.
 *
 * See the file-level docblock for the §8.1 partial-success
 * contract that binds the `provider=all` dispatch path, the
 * single-provider exception-propagation contract, and the
 * registration-order invariant on the returned bundle's results.
 */
final class SearchProductsUseCase {

	/**
	 * Sentinel provider token requesting cross-provider dispatch.
	 *
	 * Matches the §8.1 `provider=all` query parameter value
	 * verbatim so the REST controller can forward the value
	 * through without remapping.
	 */
	public const PROVIDER_TOKEN_ALL = 'all';

	/**
	 * Registered providers keyed by their §4 stable token.
	 *
	 * Iteration order is the registration order; the UseCase
	 * honors it verbatim so callers can rely on a deterministic
	 * ordering when rendering the §8.1 envelope.
	 *
	 * @var array<string, SearchProviderInterface>
	 */
	private array $providersByKey;

	/**
	 * Construct the UseCase with an explicit provider registry.
	 *
	 * Validation:
	 *
	 *   - Empty registry → {@see LogicException}; a search UseCase
	 *     wired without any providers cannot answer any request.
	 *
	 *   - Every key MUST be a non-empty string after trimming.
	 *     Non-string keys, empty-string keys, and whitespace-only
	 *     keys raise {@see InvalidArgumentException}.
	 *
	 *   - Every value MUST implement
	 *     {@see SearchProviderInterface}. Other types raise
	 *     {@see InvalidArgumentException}.
	 *
	 * @param array<string, SearchProviderInterface> $providersByKey Provider registry.
	 *
	 * @throws LogicException           When the registry is empty.
	 * @throws InvalidArgumentException When any registry entry
	 *                                  violates the validation
	 *                                  rules summarized above.
	 */
	public function __construct( array $providersByKey ) {
		if ( $providersByKey === array() ) {
			$message = 'SearchProductsUseCase requires at least one registered provider.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new LogicException( $message );
		}

		$normalized = array();
		foreach ( $providersByKey as $key => $provider ) {
			if ( ! is_string( $key ) ) {
				$message = sprintf(
					'SearchProductsUseCase: provider registry 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 ( trim( $key ) === '' ) {
				$message = 'SearchProductsUseCase: provider registry keys must be non-empty strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			if ( ! $provider instanceof SearchProviderInterface ) {
				$message = sprintf(
					'SearchProductsUseCase: provider registry["%s"] must implement SearchProviderInterface; 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 );
			}

			$normalized[ $key ] = $provider;
		}

		$this->providersByKey = $normalized;
	}

	/**
	 * Execute the §8.1 search dispatch.
	 *
	 * See the file-level docblock for the dispatch decision tree
	 * (single-provider propagation vs. cross-provider partial-
	 * success capture) and the §8.1 `meta.warnings` mapping the
	 * returned bundle's `providerWarnings` map drives.
	 *
	 * @param SearchQuery $query          Normalized search query.
	 * @param string      $providerToken  §4 provider token, or
	 *                                    {@see self::PROVIDER_TOKEN_ALL}.
	 *
	 * @return SearchResultBundle Aggregated results plus captured
	 *                            warnings.
	 *
	 * @throws InvalidArgumentException When `$providerToken` is
	 *                                  empty after trimming, when
	 *                                  it is not a registered
	 *                                  provider key (and not
	 *                                  {@see self::PROVIDER_TOKEN_ALL}),
	 *                                  or — single-provider mode
	 *                                  only — any §10.2
	 *                                  ProviderException raised by
	 *                                  the addressed provider
	 *                                  propagates verbatim.
	 */
	public function execute( SearchQuery $query, string $providerToken ): SearchResultBundle {
		$normalizedToken = trim( $providerToken );
		if ( $normalizedToken === '' ) {
			$message = 'SearchProductsUseCase: providerToken must be non-empty.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $normalizedToken === self::PROVIDER_TOKEN_ALL ) {
			return $this->dispatchAll( $query );
		}

		if ( ! array_key_exists( $normalizedToken, $this->providersByKey ) ) {
			$message = sprintf(
				'SearchProductsUseCase: unknown provider token "%s".',
				$normalizedToken
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$result = $this->providersByKey[ $normalizedToken ]->searchProducts( $query );

		return new SearchResultBundle( array( $result ), array() );
	}

	/**
	 * Cross-provider dispatch path.
	 *
	 * Invokes every registered provider in registration order,
	 * capturing {@see ProviderException} subclasses into the
	 * warnings map and appending successful results to the
	 * bundle's results list. Non-{@see ProviderException}
	 * throwables propagate verbatim per the file-level docblock.
	 *
	 * @param SearchQuery $query Normalized search query.
	 */
	private function dispatchAll( SearchQuery $query ): SearchResultBundle {
		/** @var list<SearchResult> $results */
		$results = array();
		/** @var array<string, ProviderException> $warnings */
		$warnings = array();

		foreach ( $this->providersByKey as $token => $provider ) {
			try {
				$results[] = $provider->searchProducts( $query );
			} catch ( ProviderException $exception ) {
				$warnings[ $token ] = $exception;
			}
		}

		return new SearchResultBundle( $results, $warnings );
	}
}
