<?php
/**
 * Search result value object.
 *
 * Immutable value object that represents the full response from a
 * provider search call: the matched
 * {@see \CommerceLinkBuilder\Domain\Product\ProductSnapshot}
 * collection together with the pagination context the caller needs
 * to drive a follow-up request. The type unifies the cross-provider
 * response shape `specifications/provider-api-contracts.md` §7.2
 * enumerates so REST controllers, admin search surfaces, and the
 * sync layer can render results without having to re-derive the
 * pagination math from sibling fields.
 *
 * Field semantics match `provider-api-contracts.md` §7.2 exactly:
 *
 *   - `provider` is the stable provider key from §4 that produced
 *     this result. It MUST equal the `provider()` of every
 *     {@see ProductSnapshot} in {@see self::$items}; the
 *     constructor enforces the cross-check so a programming error
 *     that mixes results across providers (the §7.3 "provider 不
 *     一致" footgun, lifted to the result-set level) surfaces here
 *     rather than at the rendering layer.
 *
 *   - `items` is the matched product list per §7.2
 *     ("`items` は `ProductSnapshot[]`"). Held as a positional
 *     array of {@see ProductSnapshot} instances; the constructor
 *     rejects any entry that is not a `ProductSnapshot` and
 *     re-keys the array so callers always see a `list<>` shape
 *     (no gaps, no string keys) that JSON-encodes as a JSON array
 *     rather than as an object. May be empty when the search
 *     matched nothing.
 *
 *   - `page` is the 1-based page number of the page this result
 *     represents — typically the `page` value from the
 *     {@see SearchQuery} that produced it. Required and validated
 *     as `>= 1`.
 *
 *   - `perPage` is the page size requested when this page was
 *     fetched. Required and validated as `>= 1`. Carried alongside
 *     `total` / `hasMore` so consuming surfaces can answer "how
 *     many more pages are there?" without having to remember the
 *     original query.
 *
 *   - `total` is the total number of items across every page when
 *     the provider exposes that count, or null per §7.2
 *     ("`total` が不明な provider では `null` 可"). Some providers
 *     (notably PA-API on certain query shapes) deliberately do not
 *     return a total, and the rendering layer MUST tolerate the
 *     null case rather than asserting on the count.
 *
 *   - `hasMore` is the explicit "is there another page after this
 *     one?" flag per §7.2 ("`hasMore` は UI のページング判断に使
 *     えること"). It is the canonical answer to that question
 *     because:
 *
 *       * `total` may be null, so subtract-and-compare arithmetic
 *         from `(total, page, perPage)` is not always available.
 *       * Some providers return a smaller-than-`perPage` page
 *         even when more pages exist (cursor-style pagination),
 *         so `count($items) < $perPage` is not a reliable signal
 *         either.
 *
 *     Provider implementations are responsible for synthesizing
 *     this flag from whatever signal their underlying API exposes.
 *
 *   - `rawMeta` is the provider-native metadata blob preserved for
 *     debugging — request id, rate-limit headers, total-pages
 *     value, etc. Held as an associative array; per §7.2
 *     ("`rawMeta` は内部診断用の補助情報であり UI 直結ではない")
 *     consuming surfaces MUST NOT branch on its contents.
 *     Validated only to reject non-string keys, matching the
 *     {@see ProductSnapshot::rawMeta()} convention.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because a search result represents a single response from a
 * single page fetch: any change in the underlying data should be
 * modeled as a fresh provider call producing a fresh
 * {@see SearchResult}, not as an in-place edit of an existing one.
 *
 * @package CommerceLinkBuilder\Domain\Search
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Domain\Search;

use CommerceLinkBuilder\Domain\Product\ProductSnapshot;
use InvalidArgumentException;

/**
 * Immutable normalized search-response payload.
 *
 * See the file-level docblock for the mapping to
 * `provider-api-contracts.md` §7.2, the cross-provider field
 * conventions (notably the `(provider, items[].provider)`
 * cross-check and the `total` / `hasMore` independence), and the
 * rationale for keeping the type free of mutation helpers.
 */
final class SearchResult {

	/**
	 * Stable provider key per §4 that produced this result.
	 *
	 * Required to match every contained snapshot's
	 * {@see ProductSnapshot::provider()} so a programming error
	 * that mixes results across providers surfaces at construction
	 * time rather than at the rendering layer.
	 */
	private string $provider;

	/**
	 * Matched product snapshots, re-keyed to a positional list.
	 *
	 * @var list<ProductSnapshot>
	 */
	private array $items;

	/**
	 * 1-based page number this result represents.
	 */
	private int $page;

	/**
	 * Page size requested when this page was fetched.
	 */
	private int $perPage;

	/**
	 * Total number of items across every page, or null when the
	 * provider does not expose a count per §7.2.
	 */
	private ?int $total;

	/**
	 * Explicit "is there another page after this one?" flag per
	 * §7.2.
	 */
	private bool $hasMore;

	/**
	 * Provider-native metadata blob preserved for diagnostics.
	 *
	 * @var array<string, mixed>
	 */
	private array $rawMeta;

	/**
	 * Construct a result with explicit values for every field.
	 *
	 * The constructor is exhaustive so a programming error
	 * (missing required field on a write, swapped page / perPage)
	 * surfaces at the type system boundary rather than at the
	 * rendering layer. Named-argument construction is the
	 * ergonomic entry point for most call sites.
	 *
	 * Validation summary:
	 *
	 *   - `provider` is trimmed and rejected when empty.
	 *   - Every `items` entry MUST be a {@see ProductSnapshot}
	 *     whose `provider()` equals the supplied `$provider` key;
	 *     mismatched entries raise
	 *     {@see InvalidArgumentException} carrying the offending
	 *     entry's index and provider key for diagnosis.
	 *   - `items` is re-keyed to a positional list so callers
	 *     always see a `list<>` shape regardless of what the
	 *     caller passed in.
	 *   - `page` and `perPage` are rejected for `< 1` values.
	 *   - `total`, when non-null, is rejected for `< 0` values.
	 *   - `rawMeta` rejects non-string keys.
	 *
	 * @param string                $provider Stable provider key.
	 * @param list<ProductSnapshot> $items    Matched snapshots.
	 * @param int                   $page     1-based page number.
	 * @param int                   $perPage  Page size.
	 * @param int|null              $total    Cross-page total, or null.
	 * @param bool                  $hasMore  Whether another page exists.
	 * @param array<string, mixed>  $rawMeta  Raw provider metadata.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  §7.2 contract or the
	 *                                  cross-field validation rules
	 *                                  summarized above.
	 */
	public function __construct(
		string $provider,
		array $items,
		int $page,
		int $perPage,
		?int $total,
		bool $hasMore,
		array $rawMeta
	) {
		$normalizedProvider = trim( $provider );
		if ( $normalizedProvider === '' ) {
			$message = 'SearchResult requires a non-empty provider key.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $page < 1 ) {
			$message = sprintf(
				'SearchResult page must be >= 1; got %d.',
				$page
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $perPage < 1 ) {
			$message = sprintf(
				'SearchResult perPage must be >= 1; got %d.',
				$perPage
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $total !== null && $total < 0 ) {
			$message = sprintf(
				'SearchResult total must be null or >= 0; got %d.',
				$total
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->provider = $normalizedProvider;
		$this->items    = self::normalizeItems( $normalizedProvider, $items );
		$this->page     = $page;
		$this->perPage  = $perPage;
		$this->total    = $total;
		$this->hasMore  = $hasMore;
		$this->rawMeta  = self::normalizeRawMeta( $rawMeta );
	}

	/**
	 * Construct an empty result for a query that matched nothing.
	 *
	 * Convenience factory for the common "the provider returned
	 * zero items" case so call sites do not have to spell out the
	 * empty list, the implicit `hasMore = false`, and the empty
	 * raw-metadata map every time.
	 *
	 * @param string $provider Stable provider key.
	 * @param int    $page     1-based page number the empty result
	 *                         represents.
	 * @param int    $perPage  Page size that was requested.
	 *
	 * @throws InvalidArgumentException When any of the validation
	 *                                  rules on the primary
	 *                                  constructor would also fail
	 *                                  here.
	 */
	public static function empty( string $provider, int $page, int $perPage ): self {
		return new self(
			$provider,
			array(),
			$page,
			$perPage,
			0,
			false,
			array()
		);
	}

	/**
	 * Stable provider key per §4.
	 */
	public function provider(): string {
		return $this->provider;
	}

	/**
	 * Matched product snapshots in positional order.
	 *
	 * @return list<ProductSnapshot>
	 */
	public function items(): array {
		return $this->items;
	}

	/**
	 * Number of items contained in this page of results.
	 *
	 * Convenience accessor so consuming surfaces do not have to
	 * pull {@see self::items()} just to count it.
	 */
	public function count(): int {
		return count( $this->items );
	}

	/**
	 * Report whether this result page contains zero items.
	 *
	 * Exposed because the empty case is a frequent branching point
	 * for rendering surfaces ("show the no-results message") and a
	 * dedicated helper keeps those branches readable.
	 */
	public function isEmpty(): bool {
		return $this->items === array();
	}

	/**
	 * 1-based page number this result represents.
	 */
	public function page(): int {
		return $this->page;
	}

	/**
	 * Page size requested when this page was fetched.
	 */
	public function perPage(): int {
		return $this->perPage;
	}

	/**
	 * Total number of items across every page, or null.
	 */
	public function total(): ?int {
		return $this->total;
	}

	/**
	 * Whether another page exists after this one per §7.2.
	 */
	public function hasMore(): bool {
		return $this->hasMore;
	}

	/**
	 * Provider-native metadata blob.
	 *
	 * Per §7.2 the rendering layer MUST NOT branch on this map's
	 * contents; it is preserved purely for diagnostics and for
	 * provider-specific extensions outside the cross-provider
	 * contract.
	 *
	 * @return array<string, mixed>
	 */
	public function rawMeta(): array {
		return $this->rawMeta;
	}

	/**
	 * Validate and normalize the items list.
	 *
	 * Every entry MUST be a {@see ProductSnapshot} whose
	 * {@see ProductSnapshot::provider()} equals the supplied
	 * `$provider` key. The list is re-keyed to a positional
	 * array so callers always see a `list<>` shape regardless of
	 * what the caller passed in (PHP arrays preserve insertion
	 * order even when keys are mixed, and `array_values()` keeps
	 * that order while normalizing the keys).
	 *
	 * @param string                   $provider Provider key the
	 *                                           result is bound to.
	 * @param array<int|string, mixed> $items    Raw items list.
	 *
	 * @return list<ProductSnapshot>
	 *
	 * @throws InvalidArgumentException When any entry is not a
	 *                                  {@see ProductSnapshot} or
	 *                                  carries a different
	 *                                  provider key.
	 */
	private static function normalizeItems( string $provider, array $items ): array {
		$normalized = array();
		$index      = 0;
		foreach ( $items as $key => $entry ) {
			if ( ! $entry instanceof ProductSnapshot ) {
				$message = sprintf(
					'SearchResult items[%s] must be a ProductSnapshot; got %s.',
					(string) $key,
					is_object( $entry ) ? get_class( $entry ) : gettype( $entry )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( $entry->provider() !== $provider ) {
				$message = sprintf(
					'SearchResult items[%d] provider "%s" does not match result provider "%s"; '
					. 'see provider-api-contracts.md §7.3.',
					$index,
					$entry->provider(),
					$provider
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[] = $entry;
			++$index;
		}

		return $normalized;
	}

	/**
	 * Validate the rawMeta map's keys.
	 *
	 * Values are NOT validated because §7.2 expressly preserves
	 * provider-native data here for downstream debugging; the
	 * rendering layer is forbidden from branching on contents, so
	 * shape constraints would only obstruct legitimate use.
	 *
	 * @param array<int|string, mixed> $rawMeta Raw metadata map.
	 *
	 * @return array<string, mixed>
	 *
	 * @throws InvalidArgumentException When any key is not a string.
	 */
	private static function normalizeRawMeta( array $rawMeta ): array {
		$normalized = array();
		foreach ( $rawMeta as $key => $value ) {
			if ( ! is_string( $key ) ) {
				$message = sprintf(
					'SearchResult rawMeta 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 );
			}

			$normalized[ $key ] = $value;
		}

		return $normalized;
	}
}
