<?php
/**
 * HTTP response value object.
 *
 * Immutable value object that captures the result of a single
 * outbound HTTP call: status code, headers, and body. The type
 * unifies the cross-provider response shape so {@see HttpClient}
 * implementations and the provider layer exchange a single,
 * validated value rather than agreeing on driver-specific shapes
 * (`wp_remote_*` arrays, PSR-7 message objects, raw cURL handles).
 *
 * Design notes:
 *
 *   - The DTO is deliberately driver-agnostic. WordPress's
 *     `wp_remote_*` functions, PSR-18 implementations, and pure-PHP
 *     test doubles all produce values that round-trip through this
 *     type; concrete clients translate from their native response
 *     shape on the boundary.
 *
 *   - `statusCode` is the integer HTTP status (200, 401, 429, ...).
 *     Validated as `>= 100 && <= 599` because that is the full HTTP
 *     status-code range; the DTO does NOT close the set further
 *     because RFC 9110 explicitly allows experimental codes and
 *     unknown providers may emit them. Provider error mappers branch
 *     on the value, not on a closed enum.
 *
 *   - Headers are held as an associative `array<string, string>`
 *     keyed by header name. Names are trimmed but case is preserved
 *     so fixture-based testing stays trivial — call sites that need
 *     case-insensitive lookup go through {@see self::header()}.
 *     Multi-value headers are NOT modeled at this layer; the rare
 *     `Set-Cookie` case is a session concern that the existing
 *     provider contracts do not need to cover.
 *
 *   - The body is held as a non-null string. An empty-body response
 *     is encoded as `body() === ''` rather than null so callers do
 *     not have to coalesce on every read; `Content-Length: 0`
 *     responses are legitimate and distinct from "no body
 *     transferred at all" only in pathological cases the provider
 *     layer does not need to distinguish.
 *
 *   - Convenience accessors ({@see self::isSuccessful()},
 *     {@see self::isClientError()}, {@see self::isServerError()})
 *     branch on the status-code class without forcing every call
 *     site to remember the 2xx / 4xx / 5xx ranges. The methods
 *     follow RFC 9110 §15: 200-299 inclusive is success, 400-499 is
 *     client error, 500-599 is server error. The 1xx and 3xx ranges
 *     are not exposed as helpers because the existing provider
 *     contracts do not branch on them — `wp_remote_*` follows
 *     redirects automatically and 1xx informational responses are
 *     never user-visible.
 *
 *   - JSON decoding is exposed as a dedicated helper
 *     ({@see self::jsonBody()}) because every cross-provider
 *     adapter in Phase 4-12 deals with JSON payloads. The helper
 *     returns the decoded value or raises
 *     {@see InvalidArgumentException} carrying the JSON parse error
 *     verbatim, so provider implementations can let the failure
 *     propagate to a {@see \Affilun\Domain\Provider\Exception\ProviderResponseException}
 *     without re-implementing the decode-and-validate dance.
 *
 * Instances are immutable. There are no `with*()` mutation helpers
 * because a response represents a completed HTTP call: any change
 * would conflate "this is what the upstream returned" with "this is
 * what we wish it had returned", which is exactly the seam the
 * provider error mapper is meant to close.
 *
 * @package Affilun\Domain\Http
 */

declare(strict_types=1);

namespace Affilun\Domain\Http;

use InvalidArgumentException;

/**
 * Immutable description of a single completed HTTP response.
 *
 * See the file-level docblock for the driver-agnostic field design,
 * the per-field validation rules, and the rationale for keeping the
 * type free of mutation helpers.
 */
final class HttpResponse {

	/**
	 * Inclusive lower bound of the HTTP status-code range.
	 *
	 * Per RFC 9110 §15, status codes below 100 are not assigned and
	 * SHOULD NOT appear in a real response.
	 */
	private const STATUS_MIN = 100;

	/**
	 * Inclusive upper bound of the HTTP status-code range.
	 *
	 * Per RFC 9110 §15, status codes above 599 are reserved and
	 * SHOULD NOT appear in a real response.
	 */
	private const STATUS_MAX = 599;

	/**
	 * Integer HTTP status code in the range [100, 599].
	 */
	private int $statusCode;

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

	/**
	 * Response body, possibly empty but never null.
	 */
	private string $body;

	/**
	 * Construct a response with explicit values for every field.
	 *
	 * Validation summary:
	 *
	 *   - `statusCode` is clamped to [100, 599] inclusive; values
	 *     outside that range raise {@see InvalidArgumentException}.
	 *   - `headers` keys are trimmed and rejected when empty;
	 *     values are trimmed and rejected when empty (use the
	 *     "no header" form by simply omitting the entry).
	 *   - `body` is preserved verbatim including the empty string.
	 *
	 * @param int                   $statusCode Integer HTTP status code.
	 * @param array<string, string> $headers    Header map.
	 * @param string                $body       Response body.
	 *
	 * @throws InvalidArgumentException When any field violates the
	 *                                  per-field rules summarized
	 *                                  above.
	 */
	public function __construct(
		int $statusCode,
		array $headers = array(),
		string $body = ''
	) {
		if ( $statusCode < self::STATUS_MIN || $statusCode > self::STATUS_MAX ) {
			$message = sprintf(
				'HttpResponse statusCode must be in the range [%d, %d]; got %d.',
				self::STATUS_MIN,
				self::STATUS_MAX,
				$statusCode
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		$this->statusCode = $statusCode;
		$this->headers    = self::normalizeHeaders( $headers );
		$this->body       = $body;
	}

	/**
	 * Integer HTTP status code.
	 */
	public function statusCode(): int {
		return $this->statusCode;
	}

	/**
	 * 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 `Content-Type` regardless of the casing the
	 * upstream used. 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;
	}

	/**
	 * Response body, possibly empty but never null.
	 */
	public function body(): string {
		return $this->body;
	}

	/**
	 * Report whether the response carries a 2xx success status.
	 *
	 * Per RFC 9110 §15.3, the 2xx range indicates the request was
	 * successfully received, understood, and accepted. Provider
	 * implementations branch on this helper to decide whether to
	 * decode the response body or to map the status to one of the
	 * §10.2 exceptions.
	 */
	public function isSuccessful(): bool {
		return $this->statusCode >= 200 && $this->statusCode <= 299;
	}

	/**
	 * Report whether the response carries a 4xx client-error status.
	 *
	 * Per RFC 9110 §15.5, the 4xx range indicates the request
	 * itself was rejected. Provider error mappers typically branch
	 * on the specific code (401 → AuthenticationException, 404 →
	 * ProductNotFoundException, 429 → RateLimitExceededException)
	 * but this helper is useful for the "any client error" guard.
	 */
	public function isClientError(): bool {
		return $this->statusCode >= 400 && $this->statusCode <= 499;
	}

	/**
	 * Report whether the response carries a 5xx server-error status.
	 *
	 * Per RFC 9110 §15.6, the 5xx range indicates the upstream
	 * failed to fulfill the request. Provider error mappers
	 * typically translate the entire range to
	 * {@see \Affilun\Domain\Provider\Exception\TemporaryUnavailableException}
	 * because retry is reasonable for every 5xx code.
	 */
	public function isServerError(): bool {
		return $this->statusCode >= 500 && $this->statusCode <= 599;
	}

	/**
	 * Decode the response body as JSON and return the resulting
	 * value.
	 *
	 * The helper uses associative arrays (not stdClass objects) so
	 * downstream provider normalizers can use `array_key_exists()`
	 * and `??` without re-shaping the decoded value. The depth limit
	 * is left at PHP's default (512) because cross-provider response
	 * payloads are nowhere near that deep; constraining further
	 * would only mask genuine contract drift.
	 *
	 * Provider implementations typically wrap the
	 * {@see InvalidArgumentException} this method raises into a
	 * {@see \Affilun\Domain\Provider\Exception\ProviderResponseException}
	 * carrying the response status / digest in the exception
	 * context, per the §10.2 error contract.
	 *
	 * @return mixed Decoded JSON value (typically an associative
	 *               array, but a JSON `null` / scalar / list is
	 *               also valid per RFC 8259).
	 *
	 * @throws InvalidArgumentException When the body cannot be
	 *                                  decoded as JSON. The
	 *                                  exception message carries
	 *                                  the underlying parse error
	 *                                  verbatim for diagnosis.
	 */
	public function jsonBody() {
		if ( $this->body === '' ) {
			$message = 'HttpResponse jsonBody() cannot decode an empty body.';
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		/** @var mixed $decoded */
		$decoded = json_decode( $this->body, true );
		if ( $decoded === null && json_last_error() !== JSON_ERROR_NONE ) {
			$message = sprintf(
				'HttpResponse jsonBody() failed to decode body: %s',
				json_last_error_msg()
			);
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Developer-facing message, never rendered to HTML.
			throw new InvalidArgumentException( $message );
		}

		return $decoded;
	}

	/**
	 * Validate and normalize the headers map at construction.
	 *
	 * Mirrors the {@see HttpRequest} header-normalization rules so
	 * the two DTOs stay symmetric: 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(
					'HttpResponse 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 = 'HttpResponse 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(
					'HttpResponse 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(
					'HttpResponse 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;
	}
}
