<?php
/**
 * Search query value object.
 *
 * Immutable value object that captures the input to a provider
 * search. The type unifies the cross-provider parameters
 * `specifications/provider-api-contracts.md` §7.1 enumerates so
 * REST controllers, admin search forms, and the sync layer can
 * pass a single, validated value through to whatever
 * {@see \Affilun\Domain\Provider\SearchProviderInterface}
 * implementation is wired in — without each call site having to
 * re-validate the same fields.
 *
 * Field semantics match `provider-api-contracts.md` §7.1 exactly:
 *
 *   - `keyword` is the user-supplied search keyword. Required and
 *     rejected when empty after trimming. Held verbatim (after
 *     trim) at this layer; provider-specific tokenization /
 *     URL-encoding happens inside the provider implementation, not
 *     here, because each provider has its own escaping rules.
 *
 *   - `page` is the 1-based page number. Required and validated as
 *     `>= 1`. The DTO does not enforce a per-provider upper bound
 *     because the bound is provider-specific (PA-API caps differ
 *     from 楽天 / Yahoo!ショッピング caps); the §7.1
 *     "provider 非対応の条件は validation error" rule binds the
 *     provider implementation, not this DTO.
 *
 *   - `perPage` is the page size. Required and validated as
 *     `>= 1`. The same per-provider cap argument applies as for
 *     `page`: providers reject out-of-range values themselves.
 *
 *   - `sort` is the sort-order discriminator (`relevance`,
 *     `price_asc`, `price_desc`, `popularity`, ...). Held as a
 *     plain string at this layer; the closed set is enforced by
 *     the provider implementation that knows which sort orders
 *     it supports. Nullable because most surfaces accept "let
 *     the provider pick the default".
 *
 *   - `category` is the provider-native category identifier when
 *     the caller wants to scope the search. Nullable.
 *
 *   - `minPrice` / `maxPrice` are the price-range bounds, as the
 *     same canonical decimal-string form
 *     {@see \Affilun\Domain\Pricing\PriceInfo::amount}
 *     uses. Nullable (either bound or both). When both are
 *     supplied, `minPrice <= maxPrice` is enforced per §7.1.
 *
 *   - `filters` is the structured filter map (brand, condition,
 *     prime-only, ...). Held as `array<string, scalar|null>` —
 *     the same shape `ProductSnapshot::attributes` uses — so the
 *     rendering / mapping layers do not have to special-case
 *     search filters separately from product attributes. Empty
 *     map by default.
 *
 *   - `locale` is the BCP-47 language / region tag for the
 *     search (`ja-JP`, `en-US`, ...) when the caller wants to
 *     pin it. Nullable; providers fall back to their own default
 *     when null.
 *
 *   - `currency` is the ISO 4217 alphabetic code the caller
 *     prefers for prices in the response (`JPY`, `USD`). Held
 *     uppercase per the same convention {@see PriceInfo::currency}
 *     uses. Nullable.
 *
 *   - `context` is the free-form contextual tag the application
 *     layer uses to mark the request's origin
 *     ("admin_search", "block_picker", "cron_resync", ...).
 *     Nullable. Held verbatim; consuming surfaces are expected
 *     to use it as an opaque correlation hint, not as a
 *     branching key.
 *
 * Instances are immutable. Two mutation helpers are provided
 * because they map to distinct, frequent application-layer flows:
 *
 *   - {@see self::withPage()} produces a new query with the page
 *     number advanced — used by paginated REST endpoints and by
 *     the sync layer's "iterate every page" loop.
 *
 *   - {@see self::withPerPage()} produces a new query with the
 *     page size adjusted — used when a caller wants to retry a
 *     query with a smaller batch after hitting a provider's
 *     cap, without rewriting the rest of the parameters.
 *
 * Other fields have no in-place edit path because changing them
 * (different keyword, different price range) is a different
 * search and should be modeled as a fresh {@see SearchQuery}.
 *
 * @package Affilun\Domain\Search
 */

declare(strict_types=1);

namespace Affilun\Domain\Search;

use InvalidArgumentException;

/**
 * Immutable normalized search-query input.
 *
 * See the file-level docblock for the mapping to
 * `provider-api-contracts.md` §7.1, the per-field validation
 * rules, and the rationale for the two `with*()` mutation
 * helpers exposed here.
 */
final class SearchQuery {

	/**
	 * User-supplied search keyword per §7.1. Required.
	 */
	private string $keyword;

	/**
	 * 1-based page number per §7.1.
	 */
	private int $page;

	/**
	 * Page size per §7.1.
	 */
	private int $perPage;

	/**
	 * Sort-order discriminator, or null when the caller wants
	 * the provider's default ordering.
	 */
	private ?string $sort;

	/**
	 * Provider-native category identifier, or null.
	 */
	private ?string $category;

	/**
	 * Price-range minimum as a canonical decimal string, or null.
	 */
	private ?string $minPrice;

	/**
	 * Price-range maximum as a canonical decimal string, or null.
	 */
	private ?string $maxPrice;

	/**
	 * Structured filter map keyed by canonical filter name.
	 *
	 * @var array<string, scalar|null>
	 */
	private array $filters;

	/**
	 * BCP-47 locale tag, or null when the caller does not pin one.
	 */
	private ?string $locale;

	/**
	 * ISO 4217 currency code in uppercase, or null when the caller
	 * does not pin one.
	 */
	private ?string $currency;

	/**
	 * Free-form contextual tag for correlation, or null.
	 */
	private ?string $context;

	/**
	 * Construct a query with explicit values for every field.
	 *
	 * The constructor is exhaustive so a programming error
	 * (missing required field on a write, swapped min / max
	 * price) surfaces at the type system boundary rather than at
	 * the provider implementation.
	 *
	 * Validation summary:
	 *
	 *   - `keyword` is trimmed and rejected when empty.
	 *   - `page` and `perPage` are rejected for `< 1` values.
	 *   - `sort`, `category`, `locale`, `context`, when non-null,
	 *     are trimmed and rejected when empty after trimming.
	 *   - `minPrice`, `maxPrice`, when non-null, are validated as
	 *     non-negative decimal strings using the same grammar
	 *     {@see \Affilun\Domain\Pricing\PriceInfo}
	 *     accepts. When both are non-null, `minPrice <=
	 *     maxPrice` is enforced via numeric comparison on the
	 *     canonical strings.
	 *   - `currency`, when non-null, is trimmed and uppercased;
	 *     an empty result raises {@see InvalidArgumentException}.
	 *   - `filters` keys MUST be non-empty strings; values MUST
	 *     be scalar or null.
	 *
	 * @param string                     $keyword  User-supplied keyword.
	 * @param int                        $page     1-based page number.
	 * @param int                        $perPage  Page size.
	 * @param string|null                $sort     Sort-order discriminator.
	 * @param string|null                $category Provider-native category id.
	 * @param string|null                $minPrice Price-range minimum.
	 * @param string|null                $maxPrice Price-range maximum.
	 * @param array<string, scalar|null> $filters Structured filters.
	 * @param string|null                $locale   BCP-47 locale tag.
	 * @param string|null                $currency ISO 4217 currency code.
	 * @param string|null                $context  Free-form correlation tag.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  §7.1 contract or the
	 *                                  cross-field validation rules
	 *                                  summarized above.
	 */
	public function __construct(
		string $keyword,
		int $page,
		int $perPage,
		?string $sort,
		?string $category,
		?string $minPrice,
		?string $maxPrice,
		array $filters,
		?string $locale,
		?string $currency,
		?string $context
	) {
		$normalizedKeyword = trim( $keyword );
		if ( $normalizedKeyword === '' ) {
			$message = 'SearchQuery keyword must be a non-empty string.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

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

		$normalizedMin = self::normalizePriceBound( 'minPrice', $minPrice );
		$normalizedMax = self::normalizePriceBound( 'maxPrice', $maxPrice );

		if ( $normalizedMin !== null && $normalizedMax !== null ) {
			if ( self::comparePriceStrings( $normalizedMin, $normalizedMax ) > 0 ) {
				$message = sprintf(
					'SearchQuery minPrice (%s) must be <= maxPrice (%s).',
					$normalizedMin,
					$normalizedMax
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}
		}

		$this->keyword  = $normalizedKeyword;
		$this->page     = $page;
		$this->perPage  = $perPage;
		$this->sort     = self::normalizeOptionalString( 'sort', $sort );
		$this->category = self::normalizeOptionalString( 'category', $category );
		$this->minPrice = $normalizedMin;
		$this->maxPrice = $normalizedMax;
		$this->filters  = self::normalizeFilters( $filters );
		$this->locale   = self::normalizeOptionalString( 'locale', $locale );
		$this->currency = self::normalizeCurrency( $currency );
		$this->context  = self::normalizeOptionalString( 'context', $context );
	}

	/**
	 * User-supplied search keyword.
	 */
	public function keyword(): string {
		return $this->keyword;
	}

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

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

	/**
	 * Sort-order discriminator, or null.
	 */
	public function sort(): ?string {
		return $this->sort;
	}

	/**
	 * Provider-native category identifier, or null.
	 */
	public function category(): ?string {
		return $this->category;
	}

	/**
	 * Price-range minimum as a canonical decimal string, or null.
	 */
	public function minPrice(): ?string {
		return $this->minPrice;
	}

	/**
	 * Price-range maximum as a canonical decimal string, or null.
	 */
	public function maxPrice(): ?string {
		return $this->maxPrice;
	}

	/**
	 * Structured filter map keyed by canonical filter name.
	 *
	 * @return array<string, scalar|null>
	 */
	public function filters(): array {
		return $this->filters;
	}

	/**
	 * BCP-47 locale tag, or null.
	 */
	public function locale(): ?string {
		return $this->locale;
	}

	/**
	 * ISO 4217 currency code in uppercase, or null.
	 */
	public function currency(): ?string {
		return $this->currency;
	}

	/**
	 * Free-form correlation tag, or null.
	 */
	public function context(): ?string {
		return $this->context;
	}

	/**
	 * Produce a copy of this query with `page` set to the supplied
	 * value.
	 *
	 * Used by paginated REST endpoints and by the sync layer's
	 * "iterate every page" loop. Other fields are preserved
	 * verbatim.
	 *
	 * @param int $page New 1-based page number.
	 *
	 * @throws InvalidArgumentException When `$page` is `< 1`.
	 */
	public function withPage( int $page ): self {
		if ( $page < 1 ) {
			$message = sprintf(
				'SearchQuery::withPage() requires page >= 1; got %d.',
				$page
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$clone       = clone $this;
		$clone->page = $page;
		return $clone;
	}

	/**
	 * Produce a copy of this query with `perPage` set to the
	 * supplied value.
	 *
	 * Used when a caller wants to retry a query with a smaller
	 * batch after hitting a provider's cap. Other fields are
	 * preserved verbatim.
	 *
	 * @param int $perPage New page size.
	 *
	 * @throws InvalidArgumentException When `$perPage` is `< 1`.
	 */
	public function withPerPage( int $perPage ): self {
		if ( $perPage < 1 ) {
			$message = sprintf(
				'SearchQuery::withPerPage() requires perPage >= 1; got %d.',
				$perPage
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$clone          = clone $this;
		$clone->perPage = $perPage;
		return $clone;
	}

	/**
	 * Validate and normalize a nullable string field that, when
	 * non-null, must carry non-empty content.
	 *
	 * Used for `sort`, `category`, `locale`, and `context` where
	 * "present but empty" is a programming error rather than a
	 * meaningful state.
	 *
	 * @param string      $field Field name for the error message.
	 * @param string|null $value Raw value, or null.
	 *
	 * @throws InvalidArgumentException When `$value` is the empty
	 *                                  string after trimming.
	 */
	private static function normalizeOptionalString( string $field, ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'SearchQuery %s must be null or a non-empty string.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize a nullable price-bound field.
	 *
	 * Accepts the same decimal-string grammar
	 * {@see \Affilun\Domain\Pricing\PriceInfo::amount}
	 * accepts: optional leading `+`, optional fractional part
	 * separated by a literal `.`, no scientific notation, no
	 * thousands separators, no embedded whitespace, no negative
	 * values.
	 *
	 * @param string      $field Field name for the error message.
	 * @param string|null $value Raw value, or null.
	 *
	 * @throws InvalidArgumentException When `$value` does not
	 *                                  match the accepted decimal
	 *                                  grammar.
	 */
	private static function normalizePriceBound( string $field, ?string $value ): ?string {
		if ( $value === null ) {
			return null;
		}

		$trimmed = trim( $value );
		if ( $trimmed === '' ) {
			$message = sprintf(
				'SearchQuery %s must be null or a non-empty decimal string.',
				$field
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $trimmed[0] === '+' ) {
			$trimmed = substr( $trimmed, 1 );
		}

		if ( preg_match( '/\A(?:\d+|\d+\.\d+|\.\d+)\z/', $trimmed ) !== 1 ) {
			$message = sprintf(
				'SearchQuery %s must be a non-negative decimal string; got "%s".',
				$field,
				$value
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize the `currency` field at construction.
	 *
	 * Null passes through unchanged. A non-null value is trimmed
	 * and uppercased; an empty result is rejected.
	 *
	 * @param string|null $currency Raw currency code, or null.
	 *
	 * @throws InvalidArgumentException When `$currency` is the
	 *                                  empty string after trimming.
	 */
	private static function normalizeCurrency( ?string $currency ): ?string {
		if ( $currency === null ) {
			return null;
		}

		$trimmed = trim( $currency );
		if ( $trimmed === '' ) {
			$message = 'SearchQuery currency must be null or a non-empty ISO 4217 code.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return strtoupper( $trimmed );
	}

	/**
	 * Validate and normalize the filters map.
	 *
	 * Keys MUST be non-empty strings (after trimming) and values
	 * MUST be scalar or null. Nested arrays / objects are rejected
	 * because §7.1 expects a flat filter surface that providers
	 * can map to query parameters.
	 *
	 * @param array<int|string, mixed> $filters Raw filter map.
	 *
	 * @return array<string, scalar|null>
	 *
	 * @throws InvalidArgumentException When any key is not a
	 *                                  non-empty string or any
	 *                                  value is not scalar / null.
	 */
	private static function normalizeFilters( array $filters ): array {
		$normalized = array();
		foreach ( $filters as $key => $value ) {
			if ( ! is_string( $key ) ) {
				$message = sprintf(
					'SearchQuery filters 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 );
			}

			$trimmedKey = trim( $key );
			if ( $trimmedKey === '' ) {
				$message = 'SearchQuery filters 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 !== null && ! is_scalar( $value ) ) {
				$message = sprintf(
					'SearchQuery filters["%s"] must be scalar or null; got %s.',
					$trimmedKey,
					gettype( $value )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[ $trimmedKey ] = $value;
		}

		return $normalized;
	}

	/**
	 * Compare two canonical decimal strings as numeric values.
	 *
	 * Used to enforce the `minPrice <= maxPrice` invariant. PHP's
	 * `bccomp()` is used when available so the comparison stays
	 * exact even for high-precision decimals; otherwise a simple
	 * float comparison falls through. In practice the bcmath
	 * extension ships with every modern PHP build the project
	 * targets (PHP 8.1+, see specifications/coding-standards.md
	 * §3.1) so the fallback is purely defensive.
	 *
	 * @param string $left  Canonical decimal-string value.
	 * @param string $right Canonical decimal-string value.
	 *
	 * @return int Negative when `$left < $right`, zero when equal,
	 *             positive when `$left > $right`.
	 */
	private static function comparePriceStrings( string $left, string $right ): int {
		if ( function_exists( 'bccomp' ) ) {
			return bccomp( $left, $right, 8 );
		}

		$leftNum  = (float) $left;
		$rightNum = (float) $right;
		if ( $leftNum < $rightNum ) {
			return -1;
		}
		if ( $leftNum > $rightNum ) {
			return 1;
		}
		return 0;
	}
}
