<?php
/**
 * Search result bundle value object.
 *
 * Immutable value object that aggregates the response from a
 * multi-provider search dispatch — typically the §8.1
 * `provider=all` mode of `GET /clb/v1/products/search`. The bundle
 * carries every provider's successful {@see SearchResult} alongside
 * a parallel map of provider tokens that raised a §10.2
 * {@see ProviderException} mid-dispatch, so the REST layer can
 * render the §8.1 `meta.warnings` envelope without losing track of
 * which providers contributed which slice of items.
 *
 * Field semantics match `specifications/rest-api-spec.md` §8.1
 * exactly:
 *
 *   - `results` is a `list<SearchResult>` of providers that
 *     answered the dispatch successfully, in registration order.
 *     Each entry's {@see SearchResult::provider()} identifies
 *     which provider produced the slice; the §8.1 partial-success
 *     example surfaces them merged into a flat `items` array, but
 *     this VO preserves the per-provider grouping so the REST
 *     mapper can decide whether to merge or render side-by-side.
 *
 *   - `providerWarnings` is an `array<string, ProviderException>`
 *     keyed by the §4 provider token (`amazon`, `rakuten`,
 *     `yahoo`). Each entry records the captured exception so the
 *     REST mapper can surface the §10.2 error code, the §10.3
 *     human-readable label, and the §10 structured context fields
 *     onto `meta.warnings[]`. Successful providers are NOT
 *     represented in this map; absence of a token from the map
 *     means "this provider succeeded".
 *
 * Design notes:
 *
 *   - The two parallel surfaces (`results` and `providerWarnings`)
 *     are deliberately not collapsed into a single
 *     "per-provider outcome" type because the §8.1 response shape
 *     emits them as distinct envelopes (`data.items` vs.
 *     `meta.warnings`). Keeping them separate at the Application
 *     layer matches the wire shape one-for-one.
 *
 *   - Single-provider dispatch (`provider=amazon`, etc.) does NOT
 *     populate `providerWarnings`. In single-provider mode a
 *     provider exception propagates up to the REST controller
 *     verbatim and surfaces as the request-level error envelope,
 *     so any bundle the controller observes from a single-provider
 *     dispatch carries an empty warnings map.
 *
 *   - The constructor validates that every warning key is a
 *     non-empty string and every value is a
 *     {@see ProviderException}; the exception's own
 *     {@see ProviderException::provider()} is NOT cross-checked
 *     against the map key because the dispatcher may key the map
 *     by the requested token (which can differ from the raised
 *     provider's canonical key in misconfigured registries) and
 *     the REST mapper relies on the requested-token form.
 *
 * @package CommerceLinkBuilder\Application\Product\UseCase
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Application\Product\UseCase;

use CommerceLinkBuilder\Domain\Provider\Exception\ProviderException;
use CommerceLinkBuilder\Domain\Search\SearchResult;
use InvalidArgumentException;

/**
 * Immutable multi-provider search aggregation envelope.
 *
 * See the file-level docblock for the §8.1 `meta.warnings`
 * mapping, the registration-order invariant on `results`, and the
 * rationale for keeping single-provider dispatch's exception
 * propagation outside this VO.
 */
final class SearchResultBundle {

	/**
	 * Successful per-provider results in registration order.
	 *
	 * @var list<SearchResult>
	 */
	private array $results;

	/**
	 * Captured provider exceptions keyed by provider token.
	 *
	 * @var array<string, ProviderException>
	 */
	private array $providerWarnings;

	/**
	 * Construct a bundle with explicit values for every field.
	 *
	 * Validation:
	 *
	 *   - `results` MUST be a `list<>` (0-indexed, dense) of
	 *     {@see SearchResult} instances. Non-{@see SearchResult}
	 *     entries and non-list keys raise
	 *     {@see InvalidArgumentException} carrying the offending
	 *     index for diagnosis.
	 *
	 *   - `providerWarnings` keys MUST be non-empty strings;
	 *     values MUST be {@see ProviderException} instances.
	 *     Non-string keys, empty-string keys, and non-exception
	 *     values raise {@see InvalidArgumentException}.
	 *
	 * @param list<SearchResult>               $results          Successful results.
	 * @param array<string, ProviderException> $providerWarnings Captured exceptions.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  cross-field validation rules
	 *                                  summarized above.
	 */
	public function __construct( array $results, array $providerWarnings ) {
		$expectedIndex = 0;
		foreach ( $results as $key => $result ) {
			if ( $key !== $expectedIndex ) {
				$message = 'SearchResultBundle: results must be a 0-indexed list.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			if ( ! $result instanceof SearchResult ) {
				$message = sprintf(
					'SearchResultBundle: results[%d] must be a SearchResult; got %s.',
					$expectedIndex,
					is_object( $result ) ? get_class( $result ) : gettype( $result )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			++$expectedIndex;
		}

		$normalizedWarnings = array();
		foreach ( $providerWarnings as $key => $value ) {
			if ( ! is_string( $key ) ) {
				$message = sprintf(
					'SearchResultBundle: providerWarnings 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 = 'SearchResultBundle: providerWarnings keys must be non-empty strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
			if ( ! $value instanceof ProviderException ) {
				$message = sprintf(
					'SearchResultBundle: providerWarnings[%s] must be a ProviderException; got %s.',
					$key,
					is_object( $value ) ? get_class( $value ) : gettype( $value )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalizedWarnings[ $key ] = $value;
		}

		/** @var list<SearchResult> $results */
		$this->results          = $results;
		$this->providerWarnings = $normalizedWarnings;
	}

	/**
	 * Successful per-provider results in registration order.
	 *
	 * @return list<SearchResult>
	 */
	public function results(): array {
		return $this->results;
	}

	/**
	 * Captured provider exceptions keyed by provider token.
	 *
	 * @return array<string, ProviderException>
	 */
	public function providerWarnings(): array {
		return $this->providerWarnings;
	}

	/**
	 * Whether this bundle carries any captured provider warnings.
	 */
	public function hasWarnings(): bool {
		return $this->providerWarnings !== array();
	}

	/**
	 * Total number of items aggregated across every successful
	 * result.
	 *
	 * Equivalent to `array_sum(array_map(fn($r) => $r->count(),
	 * $results))`; exposed as a dedicated helper so REST mappers
	 * can render the §8.1 partial-success envelope without
	 * recomputing the sum from the items list.
	 */
	public function totalItems(): int {
		$sum = 0;
		foreach ( $this->results as $result ) {
			$sum += $result->count();
		}

		return $sum;
	}

	/**
	 * Equality against another bundle.
	 *
	 * Results and warnings are both compared key-by-key on
	 * instance identity ({@see SearchResult} and
	 * {@see ProviderException} are reference types and the
	 * Domain layer does not expose value-equal `equals()` helpers
	 * for either; a bundle's "same shape, different instance"
	 * peer is uncommon enough in practice that callers can build
	 * their own deep comparison when they actually need one).
	 *
	 * @param self $other Bundle to compare.
	 */
	public function equals( self $other ): bool {
		if ( count( $this->results ) !== count( $other->results ) ) {
			return false;
		}
		foreach ( $this->results as $index => $result ) {
			if ( $result !== $other->results[ $index ] ) {
				return false;
			}
		}

		if ( count( $this->providerWarnings ) !== count( $other->providerWarnings ) ) {
			return false;
		}
		foreach ( $this->providerWarnings as $key => $exception ) {
			if ( ! array_key_exists( $key, $other->providerWarnings ) ) {
				return false;
			}
			if ( $exception !== $other->providerWarnings[ $key ] ) {
				return false;
			}
		}

		return true;
	}
}
