<?php
/**
 * WordPress-backed HTTP client.
 *
 * Production {@see HttpClient} implementation that dispatches
 * {@see HttpRequest} values through WordPress's `wp_remote_request()`
 * function and translates the resulting `wp_remote_*` response array
 * back into {@see HttpResponse} values.
 *
 * Design notes:
 *
 *   - The adapter is the single seam where the Domain-layer port
 *     contract meets WordPress's HTTP API. Provider implementations
 *     that consume {@see HttpClient} stay free of `wp_remote_*`
 *     symbols, so they remain unit-testable in pure-PHP mode and
 *     portable to non-WordPress hosts (CLI workers, future PSR-18
 *     adapters) without rewriting the call sites.
 *
 *   - Outbound requests are routed through `wp_remote_request()`
 *     rather than the per-method helpers (`wp_remote_get` /
 *     `wp_remote_post` / ...) because the Domain-layer port already
 *     carries the method as data on {@see HttpRequest::method()}.
 *     A single dispatch path keeps the translation logic auditable
 *     at a glance.
 *
 *   - Default headers can be supplied at construction time so the
 *     adapter stamps a stable `User-Agent` (per the §provider-api-
 *     contracts §13 hygiene guidance) on every request without
 *     requiring every provider implementation to remember to set
 *     it. Headers explicitly supplied on the {@see HttpRequest}
 *     override the defaults — the request author always wins —
 *     but are appended on top of the defaults rather than replacing
 *     them wholesale, so adapters can layer signing headers without
 *     fighting the defaults.
 *
 *   - The adapter resolves the actual URL by combining
 *     {@see HttpRequest::url()} with
 *     {@see HttpRequest::queryParameters()}; the request DTO
 *     deliberately holds query parameters as a structured map so
 *     adapters can render them via `add_query_arg()` /
 *     `http_build_query()` rather than asking provider
 *     implementations to pre-serialize. The adapter prefers
 *     `add_query_arg()` when available because it correctly merges
 *     with any pre-existing query string on the URL; the
 *     `http_build_query()` fallback handles pure-unit test
 *     environments where the WordPress utility is not loaded.
 *
 *   - The adapter routes per-call timeout values through
 *     `wp_remote_request()`'s `timeout` argument when supplied and
 *     leaves the WordPress default in place otherwise. The
 *     {@see HttpRequest::timeoutSeconds()} contract guarantees the
 *     value is `>= 1` when non-null, so the integer round-trip is
 *     safe.
 *
 *   - Transport-level failures (`is_wp_error()` true on the response,
 *     non-array response shape, missing status code) are translated
 *     into {@see HttpClientException} carrying the originating
 *     {@see HttpRequest}. Application-level failures (non-2xx status
 *     codes, malformed bodies) round-trip to {@see HttpResponse}
 *     values per the {@see HttpClient} contract — the provider
 *     error mapper handles them downstream, not this adapter.
 *
 *   - `wp_remote_*` is typed indirectly because this file must stay
 *     loadable in pure-unit tests that run outside a WordPress
 *     environment. Calls go through `function_exists()` guards so
 *     the test bootstrap can stub `wp_remote_request()` without
 *     having to register WordPress's full HTTP API.
 *
 * @package Affilun\Infrastructure\Http
 */

declare(strict_types=1);

// Exception messages constructed in this adapter are developer-facing
// transport-failure summaries surfaced through HttpClientException;
// they never reach an HTML rendering path. Suppress the EscapeOutput
// sniff at the file level rather than peppering every throw site
// with a per-line ignore.
//
// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped

namespace Affilun\Infrastructure\Http;

use Affilun\Domain\Http\HttpClient;
use Affilun\Domain\Http\HttpClientException;
use Affilun\Domain\Http\HttpRequest;
use Affilun\Domain\Http\HttpResponse;
use Throwable;

/**
 * {@see HttpClient} implementation backed by WordPress's
 * `wp_remote_request()` function.
 *
 * See the file-level docblock for the dispatch strategy, the
 * default-headers layering rule, and the boundary between transport
 * failures (translated to {@see HttpClientException}) and
 * application-level failures (returned as non-2xx
 * {@see HttpResponse} values).
 */
final class WpRemoteHttpClient implements HttpClient {

	/**
	 * Default `User-Agent` token applied to every request unless
	 * the caller's {@see HttpRequest} overrides it.
	 *
	 * The token follows the RFC 9110 "product/version" shape and
	 * intentionally identifies the plugin so upstream rate limiters
	 * can attribute traffic correctly. Provider implementations that
	 * want a more specific identifier (Amazon's Associates Program
	 * recommends including the partner tag, for example) can stamp
	 * it via {@see HttpRequest::withHeader()} on top of the request.
	 */
	public const DEFAULT_USER_AGENT = 'Affilun/0.1';

	/**
	 * Header map merged into every dispatched request before the
	 * caller's headers are applied.
	 *
	 * Held verbatim with whatever casing the caller (typically the
	 * service-provider boot path) supplied; lookup is case-
	 * insensitive on the {@see HttpRequest} accessor side, so the
	 * casing only affects how the headers render on the wire.
	 *
	 * @var array<string, string>
	 */
	private array $defaultHeaders;

	/**
	 * Construct the adapter.
	 *
	 * @param array<string, string> $defaultHeaders Header map merged
	 *                                              into every request.
	 *                                              Defaults to a single
	 *                                              `User-Agent` entry
	 *                                              carrying
	 *                                              {@see self::DEFAULT_USER_AGENT}.
	 */
	public function __construct( array $defaultHeaders = array() ) {
		if ( $defaultHeaders === array() ) {
			$defaultHeaders = array( 'User-Agent' => self::DEFAULT_USER_AGENT );
		}

		$this->defaultHeaders = $defaultHeaders;
	}

	/**
	 * Dispatch the supplied request through `wp_remote_request()`
	 * and translate the result back into an {@see HttpResponse}.
	 *
	 * See {@see HttpClient::send()} for the cross-implementation
	 * contract. This implementation:
	 *
	 *   - Merges the adapter's default headers with the request's
	 *     own headers, with the request author always winning when
	 *     both supply a value for the same header (case-insensitive
	 *     on the comparison, casing preserved on the wire).
	 *
	 *   - Renders the request URL by combining
	 *     {@see HttpRequest::url()} with
	 *     {@see HttpRequest::queryParameters()} via WordPress's
	 *     `add_query_arg()` when available, falling back to a
	 *     manual `http_build_query()` join for pure-unit test
	 *     contexts where `add_query_arg()` is not loaded.
	 *
	 *   - Routes the call through `wp_remote_request()` and inspects
	 *     the result with `is_wp_error()`. A `WP_Error` result is a
	 *     transport-level failure — the adapter raises
	 *     {@see HttpClientException} carrying the original request.
	 *     A non-array success shape, or one missing the response
	 *     status code, is treated the same way.
	 *
	 *   - On a successful round-trip, extracts the integer status
	 *     code via `wp_remote_retrieve_response_code()`, the headers
	 *     via `wp_remote_retrieve_headers()`, and the body via
	 *     `wp_remote_retrieve_body()`, and constructs an
	 *     {@see HttpResponse} from them.
	 *
	 * @param HttpRequest $request Request to dispatch.
	 *
	 * @throws HttpClientException When `wp_remote_request()` reports
	 *                             a transport-level failure or
	 *                             returns a response shape this
	 *                             adapter cannot translate.
	 */
	public function send( HttpRequest $request ): HttpResponse {
		if ( ! function_exists( 'wp_remote_request' ) ) {
			throw new HttpClientException(
				$request,
				'WpRemoteHttpClient requires WordPress\'s wp_remote_request() function.'
			);
		}

		$url     = $this->renderUrl( $request );
		$args    = $this->buildArgs( $request );
		$result  = null;
		$failure = null;

		try {
			/** @var array<string, mixed>|\WP_Error $result */
			$result = wp_remote_request( $url, $args );
		} catch ( Throwable $error ) {
			$failure = $error;
		}

		if ( $failure !== null ) {
			throw new HttpClientException(
				$request,
				sprintf(
					'wp_remote_request() raised %s: %s',
					get_class( $failure ),
					$failure->getMessage()
				),
				0,
				$failure
			);
		}

		if ( function_exists( 'is_wp_error' ) && is_wp_error( $result ) ) {
			$message = function_exists( 'method_exists' ) && method_exists( $result, 'get_error_message' )
				? (string) $result->get_error_message()
				: 'wp_remote_request() returned a WP_Error.';

			throw new HttpClientException(
				$request,
				sprintf( 'wp_remote_request() failed: %s', $message )
			);
		}

		if ( ! is_array( $result ) ) {
			throw new HttpClientException(
				$request,
				sprintf(
					'wp_remote_request() returned an unexpected shape: %s',
					gettype( $result )
				)
			);
		}

		$statusCode = $this->extractStatusCode( $request, $result );
		$headers    = $this->extractHeaders( $result );
		$body       = $this->extractBody( $result );

		return new HttpResponse( $statusCode, $headers, $body );
	}

	/**
	 * Render the final request URL by combining
	 * {@see HttpRequest::url()} with
	 * {@see HttpRequest::queryParameters()}.
	 *
	 * Uses WordPress's `add_query_arg()` when available so any
	 * pre-existing query string on the URL is merged correctly
	 * (`add_query_arg()` knows how to detect duplicate keys and
	 * preserve URL fragments). Falls back to `http_build_query()`
	 * when the WordPress helper is not loaded — typical for
	 * pure-unit test contexts that stub only `wp_remote_request()`.
	 *
	 * @param HttpRequest $request Request to render.
	 */
	private function renderUrl( HttpRequest $request ): string {
		$base   = $request->url();
		$params = $request->queryParameters();

		if ( $params === array() ) {
			return $base;
		}

		if ( function_exists( 'add_query_arg' ) ) {
			/** @var string $merged */
			$merged = add_query_arg( $params, $base );
			return $merged;
		}

		$query     = http_build_query( $params );
		$separator = strpos( $base, '?' ) === false ? '?' : '&';

		return $base . $separator . $query;
	}

	/**
	 * Build the `wp_remote_request()` arguments array from the
	 * supplied {@see HttpRequest}.
	 *
	 * Forwards the method verbatim, merges the adapter's default
	 * headers under the caller's headers (case-insensitive
	 * comparison; the caller always wins when both supply the same
	 * header), preserves the body verbatim including the empty
	 * string, and forwards the timeout when set.
	 *
	 * @param HttpRequest $request Request to translate.
	 *
	 * @return array<string, mixed>
	 */
	private function buildArgs( HttpRequest $request ): array {
		$args = array(
			'method'  => $request->method(),
			'headers' => $this->mergeHeaders( $request ),
		);

		$body = $request->body();
		if ( $body !== null ) {
			$args['body'] = $body;
		}

		$timeout = $request->timeoutSeconds();
		if ( $timeout !== null ) {
			$args['timeout'] = $timeout;
		}

		return $args;
	}

	/**
	 * Merge the adapter's default headers with the caller's headers.
	 *
	 * The caller's headers always win when both supply a value for
	 * the same header. Comparison is case-insensitive on the header
	 * name (HTTP header names are case-insensitive on the wire) but
	 * the resulting map preserves the caller's casing because some
	 * upstreams inspect their own header echoes verbatim.
	 *
	 * @param HttpRequest $request Request whose headers to merge.
	 *
	 * @return array<string, string>
	 */
	private function mergeHeaders( HttpRequest $request ): array {
		$callerHeaders = $request->headers();
		if ( $callerHeaders === array() ) {
			return $this->defaultHeaders;
		}

		$callerKeys = array();
		foreach ( $callerHeaders as $name => $unused ) {
			unset( $unused );
			$callerKeys[ strtolower( $name ) ] = true;
		}

		$merged = array();
		foreach ( $this->defaultHeaders as $name => $value ) {
			if ( ! array_key_exists( strtolower( $name ), $callerKeys ) ) {
				$merged[ $name ] = $value;
			}
		}

		foreach ( $callerHeaders as $name => $value ) {
			$merged[ $name ] = $value;
		}

		return $merged;
	}

	/**
	 * Extract the integer status code from a `wp_remote_request()`
	 * result array.
	 *
	 * Uses `wp_remote_retrieve_response_code()` when available so
	 * the result honors WordPress's normal extraction shape. Falls
	 * back to a direct array probe for pure-unit test contexts
	 * where the helper is not loaded.
	 *
	 * @param HttpRequest          $request Originating request, preserved
	 *                                      on the failure path.
	 * @param array<string, mixed> $result  `wp_remote_request()` result.
	 *
	 * @throws HttpClientException When the result does not carry a
	 *                             positive integer status code.
	 */
	private function extractStatusCode( HttpRequest $request, array $result ): int {
		if ( function_exists( 'wp_remote_retrieve_response_code' ) ) {
			$code = wp_remote_retrieve_response_code( $result );
			if ( is_int( $code ) && $code > 0 ) {
				return $code;
			}

			if ( is_string( $code ) && ctype_digit( $code ) && $code !== '' ) {
				return (int) $code;
			}
		}

		if ( isset( $result['response']['code'] ) ) {
			$code = $result['response']['code'];
			if ( is_int( $code ) && $code > 0 ) {
				return $code;
			}
			if ( is_string( $code ) && ctype_digit( $code ) && $code !== '' ) {
				return (int) $code;
			}
		}

		throw new HttpClientException(
			$request,
			'wp_remote_request() result is missing a usable response status code.'
		);
	}

	/**
	 * Extract the headers map from a `wp_remote_request()` result
	 * array.
	 *
	 * Uses `wp_remote_retrieve_headers()` when available; falls back
	 * to a direct array probe for pure-unit test contexts. The
	 * helper may return either a plain associative array or a
	 * `Requests_Utility_CaseInsensitiveDictionary` (older WordPress)
	 * / `WpOrg\Requests\Utility\CaseInsensitiveDictionary`
	 * (newer WordPress) — both implement `IteratorAggregate` so we
	 * can iterate without binding to a specific class.
	 *
	 * Header values that are not strings (e.g. arrays, when the
	 * underlying upstream emitted multi-value headers) are folded
	 * with `, ` per RFC 9110 §5.3 so the {@see HttpResponse}
	 * constructor's `array<string, string>` invariant holds.
	 *
	 * @param array<string, mixed> $result `wp_remote_request()` result.
	 *
	 * @return array<string, string>
	 */
	private function extractHeaders( array $result ): array {
		$source = null;
		if ( function_exists( 'wp_remote_retrieve_headers' ) ) {
			$source = wp_remote_retrieve_headers( $result );
		} elseif ( isset( $result['headers'] ) ) {
			$source = $result['headers'];
		}

		if ( $source === null ) {
			return array();
		}

		$entries = array();
		if ( is_array( $source ) ) {
			$entries = $source;
		} elseif ( is_iterable( $source ) ) {
			foreach ( $source as $name => $value ) {
				$entries[ $name ] = $value;
			}
		} else {
			return array();
		}

		$normalized = array();
		foreach ( $entries as $name => $value ) {
			if ( ! is_string( $name ) ) {
				continue;
			}

			$trimmedName = trim( $name );
			if ( $trimmedName === '' ) {
				continue;
			}

			if ( is_array( $value ) ) {
				$flat = array();
				foreach ( $value as $entry ) {
					if ( is_scalar( $entry ) ) {
						$flat[] = (string) $entry;
					}
				}
				$value = implode( ', ', $flat );
			} elseif ( is_scalar( $value ) ) {
				$value = (string) $value;
			} else {
				continue;
			}

			$trimmedValue = trim( $value );
			if ( $trimmedValue === '' ) {
				continue;
			}

			$normalized[ $trimmedName ] = $trimmedValue;
		}

		return $normalized;
	}

	/**
	 * Extract the body string from a `wp_remote_request()` result
	 * array.
	 *
	 * Uses `wp_remote_retrieve_body()` when available; falls back
	 * to a direct array probe for pure-unit test contexts. A
	 * missing body is normalized to the empty string so the
	 * {@see HttpResponse} constructor's "empty-body responses are
	 * encoded as `''`, not null" invariant holds.
	 *
	 * @param array<string, mixed> $result `wp_remote_request()` result.
	 */
	private function extractBody( array $result ): string {
		if ( function_exists( 'wp_remote_retrieve_body' ) ) {
			$body = wp_remote_retrieve_body( $result );
			if ( is_string( $body ) ) {
				return $body;
			}
		}

		if ( isset( $result['body'] ) && is_string( $result['body'] ) ) {
			return $result['body'];
		}

		return '';
	}
}
