<?php
/**
 * HTTP request value object.
 *
 * Immutable value object that captures the inputs to a single
 * outbound HTTP call: method, URL, headers, query string, and an
 * optional body. The type unifies the cross-provider request shape
 * so {@see HttpClient} implementations and the provider layer
 * exchange a single, validated value rather than agreeing on
 * positional argument lists or driver-specific shapes.
 *
 * Design notes:
 *
 *   - The DTO is deliberately driver-agnostic. WordPress's
 *     `wp_remote_*` functions, PSR-18 implementations, and pure-PHP
 *     test doubles all consume the same fields; concrete clients
 *     translate to their native call shape on the boundary.
 *
 *   - Headers are held as an associative `array<string, string>`
 *     keyed by header name. Names are normalized to a canonical
 *     form (trimmed, with surrounding whitespace removed) so
 *     header lookups stay consistent regardless of the casing the
 *     caller used. The DTO does NOT lowercase header names because
 *     some upstreams (notably 楽天 / Yahoo) inspect their own
 *     header echoes verbatim; preserving the supplied casing keeps
 *     fixture-based testing trivial. Duplicate header names are
 *     not supported at this layer — multi-value headers are
 *     uncommon outside of `Set-Cookie` (a response-only concern)
 *     and the existing provider contracts do not require them.
 *
 *   - Query parameters are held as an associative
 *     `array<string, scalar|null>` keyed by parameter name. Values
 *     are NOT URL-encoded at construction time — encoding happens
 *     when the request is rendered into a URL by
 *     {@see self::url()} consumers (typically the HTTP client
 *     adapter). Holding raw values keeps the DTO comparable in
 *     tests without fighting `http_build_query()` ordering.
 *
 *   - The body is held as a nullable string. Provider
 *     implementations that need to send JSON or form-encoded
 *     payloads serialize before constructing the request; this
 *     layer does not parse, mutate, or re-encode the body.
 *
 *   - The HTTP method is held as an uppercase string and validated
 *     against a closed set of canonical methods. The set is
 *     deliberately narrow (GET / POST / PUT / PATCH / DELETE /
 *     HEAD) because the provider contracts in Phase 4-12 do not
 *     need broader coverage; future provider work can extend the
 *     set with a deliberate code change.
 *
 * Instances are immutable. Two narrow `with*()` mutation helpers
 * are provided because they map to frequent provider-implementation
 * flows:
 *
 *   - {@see self::withHeader()} adds or replaces a single header
 *     without re-spelling the rest of the headers map. Used by
 *     adapters that need to stamp `User-Agent` or signing headers
 *     on top of a request the provider built.
 *
 *   - {@see self::withQueryParameter()} adds or replaces a single
 *     query parameter. Used by adapters that need to attach
 *     credentials (`applicationId`, `affiliateId`, ...) on top of
 *     a request the provider built.
 *
 * Other fields (method, URL base, body) are not mutable at this
 * layer — changing them would constitute a different request.
 *
 * @package CommerceLinkBuilder\Domain\Http
 */

declare(strict_types=1);

namespace CommerceLinkBuilder\Domain\Http;

use InvalidArgumentException;

/**
 * Immutable description of a single outbound HTTP request.
 *
 * See the file-level docblock for the driver-agnostic field design,
 * the per-field validation rules, and the rationale for the two
 * `with*()` mutation helpers exposed here.
 */
final class HttpRequest {

	/**
	 * Canonical HTTP GET method token.
	 *
	 * Exposed as a class constant so call sites can reference the
	 * exact token without duplicating the literal, and so a future
	 * spec change has a single location to update.
	 */
	public const METHOD_GET = 'GET';

	/**
	 * Canonical HTTP POST method token.
	 */
	public const METHOD_POST = 'POST';

	/**
	 * Canonical HTTP PUT method token.
	 */
	public const METHOD_PUT = 'PUT';

	/**
	 * Canonical HTTP PATCH method token.
	 */
	public const METHOD_PATCH = 'PATCH';

	/**
	 * Canonical HTTP DELETE method token.
	 */
	public const METHOD_DELETE = 'DELETE';

	/**
	 * Canonical HTTP HEAD method token.
	 */
	public const METHOD_HEAD = 'HEAD';

	/**
	 * Closed set of accepted HTTP method tokens, in declaration
	 * order. Maintained as a class-level constant so the validator
	 * and the test suite can iterate the same source of truth.
	 *
	 * @var list<string>
	 */
	private const VALID_METHODS = array(
		self::METHOD_GET,
		self::METHOD_POST,
		self::METHOD_PUT,
		self::METHOD_PATCH,
		self::METHOD_DELETE,
		self::METHOD_HEAD,
	);

	/**
	 * HTTP method, in uppercase, drawn from {@see self::VALID_METHODS}.
	 */
	private string $method;

	/**
	 * Target URL. Validated as a non-empty `http(s)://` URL at
	 * construction time, matching the same scheme guard the
	 * Domain-layer Product / Affiliate URL fields apply.
	 */
	private string $url;

	/**
	 * Header map keyed by header name (verbatim casing preserved).
	 *
	 * @var array<string, string>
	 */
	private array $headers;

	/**
	 * Query parameter map keyed by parameter name. Values are held
	 * as scalars or null; URL encoding happens when the request is
	 * rendered into a URL by the consumer.
	 *
	 * @var array<string, scalar|null>
	 */
	private array $queryParameters;

	/**
	 * Optional request body, or null when the request has no body
	 * (typical for GET / HEAD).
	 */
	private ?string $body;

	/**
	 * Per-call timeout in seconds, or null to defer to the
	 * adapter's default. Held as a positive integer so the field
	 * round-trips cleanly through `wp_remote_*` and PSR-18
	 * implementations alike (both expect integer seconds, not
	 * floats).
	 */
	private ?int $timeoutSeconds;

	/**
	 * Construct an HTTP request with explicit values for every
	 * field.
	 *
	 * The constructor is exhaustive so a programming error
	 * (forgetting to declare a header on a write, swapping URL /
	 * body) surfaces at the type system boundary rather than at the
	 * HTTP client adapter.
	 *
	 * Validation summary:
	 *
	 *   - `method` is uppercased and matched against
	 *     {@see self::VALID_METHODS}; any other token raises
	 *     {@see InvalidArgumentException} carrying the offending
	 *     value verbatim and a comma-separated list of the
	 *     accepted methods for diagnosis.
	 *   - `url` is trimmed and validated as a `http(s)://...` URL.
	 *   - `headers` keys are trimmed and rejected when empty;
	 *     values are trimmed and rejected when empty (use the
	 *     null-style "no header" by simply omitting the entry).
	 *   - `queryParameters` keys are trimmed and rejected when
	 *     empty; values must be scalar or null.
	 *   - `body` is preserved verbatim including empty strings —
	 *     an empty body string is meaningful for some providers
	 *     and MUST NOT be coerced to null.
	 *   - `timeoutSeconds`, when non-null, must be `>= 1`.
	 *
	 * @param string                     $method          HTTP method.
	 * @param string                     $url             Target URL.
	 * @param array<string, string>      $headers         Header map.
	 * @param array<string, scalar|null> $queryParameters Query param map.
	 * @param string|null                $body            Optional request body.
	 * @param int|null                   $timeoutSeconds  Per-call timeout in seconds.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct(
		string $method,
		string $url,
		array $headers = array(),
		array $queryParameters = array(),
		?string $body = null,
		?int $timeoutSeconds = null
	) {
		$this->method          = self::normalizeMethod( $method );
		$this->url             = self::normalizeUrl( $url );
		$this->headers         = self::normalizeHeaders( $headers );
		$this->queryParameters = self::normalizeQueryParameters( $queryParameters );
		$this->body            = $body;
		$this->timeoutSeconds  = self::normalizeTimeout( $timeoutSeconds );
	}

	/**
	 * Construct a GET request — the common case for provider
	 * search and lookup calls.
	 *
	 * Convenience factory so call sites do not have to spell out
	 * {@see self::METHOD_GET} every time. The remaining arguments
	 * follow the primary constructor's contract verbatim.
	 *
	 * @param string                     $url             Target URL.
	 * @param array<string, scalar|null> $queryParameters Query param map.
	 * @param array<string, string>      $headers         Header map.
	 * @param int|null                   $timeoutSeconds  Per-call timeout.
	 */
	public static function get(
		string $url,
		array $queryParameters = array(),
		array $headers = array(),
		?int $timeoutSeconds = null
	): self {
		return new self(
			self::METHOD_GET,
			$url,
			$headers,
			$queryParameters,
			null,
			$timeoutSeconds
		);
	}

	/**
	 * Closed set of accepted HTTP method tokens, in declaration
	 * order.
	 *
	 * Exposed for tests and for diagnostics surfaces that want to
	 * enumerate the valid tokens without duplicating the list.
	 *
	 * @return list<string>
	 */
	public static function validMethods(): array {
		return self::VALID_METHODS;
	}

	/**
	 * HTTP method in uppercase.
	 */
	public function method(): string {
		return $this->method;
	}

	/**
	 * Target URL.
	 */
	public function url(): string {
		return $this->url;
	}

	/**
	 * Header map keyed by header name (verbatim casing preserved).
	 *
	 * @return array<string, string>
	 */
	public function headers(): array {
		return $this->headers;
	}

	/**
	 * Look up a single header by name.
	 *
	 * Lookup is case-insensitive on the supplied name so call sites
	 * can ask for `Authorization` regardless of the casing the
	 * provider used when constructing the request. Returns null
	 * when no matching header is present.
	 *
	 * @param string $name Header name to look up.
	 */
	public function header( string $name ): ?string {
		$needle = strtolower( trim( $name ) );
		if ( $needle === '' ) {
			return null;
		}

		foreach ( $this->headers as $key => $value ) {
			if ( strtolower( $key ) === $needle ) {
				return $value;
			}
		}

		return null;
	}

	/**
	 * Query parameter map.
	 *
	 * @return array<string, scalar|null>
	 */
	public function queryParameters(): array {
		return $this->queryParameters;
	}

	/**
	 * Optional request body, or null.
	 */
	public function body(): ?string {
		return $this->body;
	}

	/**
	 * Per-call timeout in seconds, or null to defer to the
	 * adapter's default.
	 */
	public function timeoutSeconds(): ?int {
		return $this->timeoutSeconds;
	}

	/**
	 * Produce a copy of this request with a single header added or
	 * replaced.
	 *
	 * Used by adapters that need to stamp a header (`User-Agent`,
	 * signing headers, `Accept`) on top of a request the provider
	 * built. Header lookup for replacement is case-insensitive so a
	 * caller's `Authorization` overrides a provider's `authorization`
	 * regardless of casing; the supplied casing is preserved on
	 * storage.
	 *
	 * @param string $name  Header name.
	 * @param string $value Header value.
	 *
	 * @throws InvalidArgumentException When `$name` is empty after
	 *                                  trimming or `$value` is the
	 *                                  empty string after trimming.
	 */
	public function withHeader( string $name, string $value ): self {
		$normalizedName  = trim( $name );
		$normalizedValue = trim( $value );

		if ( $normalizedName === '' ) {
			$message = 'HttpRequest::withHeader() requires a non-empty header name.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( $normalizedValue === '' ) {
			$message = sprintf(
				'HttpRequest::withHeader() requires a non-empty value for header "%s".',
				$normalizedName
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$clone   = clone $this;
		$needle  = strtolower( $normalizedName );
		$updated = array();
		foreach ( $clone->headers as $key => $existing ) {
			if ( strtolower( $key ) !== $needle ) {
				$updated[ $key ] = $existing;
			}
		}
		$updated[ $normalizedName ] = $normalizedValue;
		$clone->headers             = $updated;

		return $clone;
	}

	/**
	 * Produce a copy of this request with a single query parameter
	 * added or replaced.
	 *
	 * Used by adapters that need to attach credentials
	 * (`applicationId`, `affiliateId`, ...) on top of a request the
	 * provider built. Replacement is case-sensitive on the parameter
	 * name because URL query parameters are case-sensitive on the
	 * wire and a "case-insensitive replace" would silently shadow a
	 * legitimately-distinct parameter.
	 *
	 * @param string      $name  Parameter name.
	 * @param scalar|null $value Parameter value.
	 *
	 * @throws InvalidArgumentException When `$name` is empty after
	 *                                  trimming or `$value` is not
	 *                                  scalar / null.
	 */
	public function withQueryParameter( string $name, $value ): self {
		$normalizedName = trim( $name );
		if ( $normalizedName === '' ) {
			$message = 'HttpRequest::withQueryParameter() requires a non-empty parameter name.';
			// 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(
				'HttpRequest::withQueryParameter() value for "%s" must be scalar or null; got %s.',
				$normalizedName,
				gettype( $value )
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$clone                                     = clone $this;
		$clone->queryParameters[ $normalizedName ] = $value;

		return $clone;
	}

	/**
	 * Validate and normalize the HTTP method at construction.
	 *
	 * The method is trimmed and uppercased so a carelessly-decoded
	 * payload (`get`, `  post  `) is normalized to the same
	 * canonical form a freshly-constructed method would carry. The
	 * result MUST appear in {@see self::VALID_METHODS}.
	 *
	 * @param string $method Raw method as supplied by the caller.
	 *
	 * @throws InvalidArgumentException When `$method` is not a
	 *                                  member of the closed set.
	 */
	private static function normalizeMethod( string $method ): string {
		$trimmed = strtoupper( trim( $method ) );
		if ( ! in_array( $trimmed, self::VALID_METHODS, true ) ) {
			$message = sprintf(
				'HttpRequest method must be one of [%s]; got "%s".',
				implode( ', ', self::VALID_METHODS ),
				$method
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize the target URL at construction.
	 *
	 * Trims surrounding whitespace, rejects empty / non-`http(s)://`
	 * URLs using the same scheme guard the Domain-layer Product /
	 * Affiliate URL fields apply.
	 *
	 * @param string $url Raw URL.
	 *
	 * @throws InvalidArgumentException When `$url` is empty after
	 *                                  trimming, fails URL
	 *                                  validation, or uses a
	 *                                  non-http scheme.
	 */
	private static function normalizeUrl( string $url ): string {
		$trimmed = trim( $url );
		if ( $trimmed === '' ) {
			$message = 'HttpRequest url must be a non-empty URL.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		if ( filter_var( $trimmed, FILTER_VALIDATE_URL ) === false ) {
			$message = sprintf(
				'HttpRequest url must be a valid http(s):// URL; got "%s".',
				$url
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Domain layer is WordPress-independent per coding-standards.md §1.3 B; wp_parse_url() would introduce a forbidden dependency.
		$scheme = strtolower( (string) parse_url( $trimmed, PHP_URL_SCHEME ) );
		if ( $scheme !== 'http' && $scheme !== 'https' ) {
			$message = sprintf(
				'HttpRequest url must use the http or https scheme; got "%s".',
				$url
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $trimmed;
	}

	/**
	 * Validate and normalize the headers map.
	 *
	 * Keys are trimmed (preserving casing) and rejected when empty;
	 * values are trimmed and rejected when empty. Non-string entries
	 * raise {@see InvalidArgumentException}.
	 *
	 * @param array<int|string, mixed> $headers Raw headers map.
	 *
	 * @return array<string, string>
	 *
	 * @throws InvalidArgumentException When any entry violates the
	 *                                  per-entry rules.
	 */
	private static function normalizeHeaders( array $headers ): array {
		$normalized = array();
		foreach ( $headers as $key => $value ) {
			if ( ! is_string( $key ) ) {
				$message = sprintf(
					'HttpRequest header names 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 = 'HttpRequest header names must be non-empty strings.';
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			if ( ! is_string( $value ) ) {
				$message = sprintf(
					'HttpRequest header "%s" value must be a string; got %s.',
					$trimmedKey,
					gettype( $value )
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$trimmedValue = trim( $value );
			if ( $trimmedValue === '' ) {
				$message = sprintf(
					'HttpRequest header "%s" must carry a non-empty value.',
					$trimmedKey
				);
				// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
				throw new InvalidArgumentException( $message );
			}

			$normalized[ $trimmedKey ] = $trimmedValue;
		}

		return $normalized;
	}

	/**
	 * Validate and normalize the query-parameters map.
	 *
	 * Keys are trimmed and rejected when empty; values must be
	 * scalar or null. Nested arrays / objects are rejected because
	 * the cross-provider contract expects a flat parameter surface
	 * that adapters can render via `http_build_query()`.
	 *
	 * @param array<int|string, mixed> $queryParameters Raw map.
	 *
	 * @return array<string, scalar|null>
	 *
	 * @throws InvalidArgumentException When any entry violates the
	 *                                  per-entry rules.
	 */
	private static function normalizeQueryParameters( array $queryParameters ): array {
		$normalized = array();
		foreach ( $queryParameters as $key => $value ) {
			if ( ! is_string( $key ) ) {
				$message = sprintf(
					'HttpRequest query parameter names 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 = 'HttpRequest query parameter names 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(
					'HttpRequest query parameter "%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;
	}

	/**
	 * Validate the optional timeout value.
	 *
	 * @param int|null $timeoutSeconds Raw timeout.
	 *
	 * @throws InvalidArgumentException When `$timeoutSeconds` is
	 *                                  non-null and `< 1`.
	 */
	private static function normalizeTimeout( ?int $timeoutSeconds ): ?int {
		if ( $timeoutSeconds === null ) {
			return null;
		}

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

		return $timeoutSeconds;
	}
}
