<?php
/**
 * affilun Cloud link health client.
 *
 * @package Affilun\Application\Cloud
 */

declare(strict_types=1);

namespace Affilun\Application\Cloud;

use Affilun\Application\Settings\CloudSettings;
use InvalidArgumentException;
use RuntimeException;

/**
 * Sends opt-in link health batches to affilun Cloud.
 */
final class CloudLinkHealthClient {

	/**
	 * Transport callback.
	 *
	 * @var callable
	 */
	private $transport;

	/**
	 * @param CloudRequestSigner $signer    Request signer.
	 * @param callable|null      $transport Transport override.
	 *
	 * @phpstan-param (callable(string, array<string,string>, string): array<string,mixed>)|null $transport
	 */
	public function __construct(
		private CloudRequestSigner $signer = new CloudRequestSigner(),
		?callable $transport = null
	) {
		$this->transport = $transport ?? self::defaultTransport();
	}

	/**
	 * Check URLs through affilun Cloud.
	 *
	 * @param CloudSettings                     $settings  Cloud settings.
	 * @param list<string>                      $urls      URLs.
	 * @param int                               $timestamp Timestamp.
	 * @param string                            $nonce     Nonce.
	 * @param array<string,array<string,mixed>> $contexts Optional target context keyed by URL.
	 *
	 * @return list<CloudLinkHealthResult>
	 *
	 * @throws InvalidArgumentException When Cloud settings are incomplete.
	 * @throws RuntimeException When the Cloud response fails.
	 */
	public function check(
		CloudSettings $settings,
		array $urls,
		int $timestamp,
		string $nonce,
		array $contexts = array()
	): array {
		if ( ! $settings->isConfigured() ) {
			throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' );
		}
		$cleanUrls = self::validateUrls( $urls );
		if ( $cleanUrls === array() ) {
			return array();
		}

		$payload = array( 'urls' => $cleanUrls );
		$targets = self::targetPayloads( $cleanUrls, $contexts );
		if ( $targets !== array() ) {
			$payload['targets'] = $targets;
		}

		$path                    = '/link-health/check';
		$body                    = self::jsonEncode( $payload );
		$headers                 = $this->signer->sign(
			$settings->siteId(),
			$settings->apiKey(),
			'POST',
			$path,
			$body,
			$timestamp,
			$nonce
		);
		$headers['Content-Type'] = 'application/json';

		$response = ( $this->transport )( $settings->endpoint() . $path, $headers, $body );
		$status   = isset( $response['status'] ) ? (int) $response['status'] : 0;
		$payload  = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null;
		$data     = is_array( $payload['data'] ?? null ) ? $payload['data'] : $payload;
		$results  = is_array( $data ) ? ( $data['results'] ?? null ) : null;
		if ( $status < 200 || $status >= 300 || ! is_array( $results ) ) {
			$error = self::errorFromPayload( $payload );
			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Stored as exception metadata for controlled admin messages; not emitted here.
			throw new CloudApiException(
				'affilun Cloud link health check',
				$status,
				$error['code'],
				$error['message']
			);
			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}

		$mapped = array();
		foreach ( $results as $row ) {
			if ( is_array( $row ) ) {
				$mapped[] = CloudLinkHealthResult::fromArray( $row );
			}
		}
		return $mapped;
	}

	/**
	 * Create an asynchronous link-health job.
	 *
	 * @param CloudSettings                     $settings  Cloud settings.
	 * @param list<string>                      $urls      URLs.
	 * @param int                               $timestamp Timestamp.
	 * @param string                            $nonce     Nonce.
	 * @param array<string,array<string,mixed>> $contexts Optional target context keyed by URL.
	 *
	 * @return array<string,mixed>
	 */
	public function createJob(
		CloudSettings $settings,
		array $urls,
		int $timestamp,
		string $nonce,
		array $contexts = array()
	): array {
		if ( ! $settings->isConfigured() ) {
			throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' );
		}
		$cleanUrls = self::validateJobUrls( $urls );
		if ( $cleanUrls === array() ) {
			return array();
		}

		$payload = array( 'urls' => $cleanUrls );
		$targets = self::targetPayloads( $cleanUrls, $contexts );
		if ( $targets !== array() ) {
			$payload['targets'] = $targets;
		}

		$data = $this->requestJson(
			$settings,
			'POST',
			'/link-health/jobs',
			self::jsonEncode( $payload ),
			$timestamp,
			$nonce,
			'affilun Cloud link health job'
		);

		return is_array( $data['job'] ?? null ) ? $data['job'] : array();
	}

	/**
	 * Fetch an asynchronous link-health job.
	 *
	 * @param CloudSettings $settings  Cloud credentials.
	 * @param string        $jobId     Cloud job id.
	 * @param int           $timestamp Signature timestamp.
	 * @param string        $nonce     Signature nonce.
	 *
	 * @return array<string,mixed>
	 */
	public function fetchJob(
		CloudSettings $settings,
		string $jobId,
		int $timestamp,
		string $nonce
	): array {
		if ( ! $settings->isConfigured() ) {
			throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' );
		}
		$jobId = self::cleanJobId( $jobId );
		if ( $jobId === '' ) {
			return array();
		}
		$data = $this->requestJson(
			$settings,
			'GET',
			'/link-health/jobs/' . rawurlencode( $jobId ),
			'',
			$timestamp,
			$nonce,
			'affilun Cloud link health job status'
		);

		return is_array( $data['job'] ?? null ) ? $data['job'] : array();
	}

	/**
	 * Fetch asynchronous link-health job results.
	 *
	 * @param CloudSettings $settings  Cloud credentials.
	 * @param string        $jobId     Cloud job id.
	 * @param int           $timestamp Signature timestamp.
	 * @param string        $nonce     Signature nonce.
	 *
	 * @return array{job:array<string,mixed>,summary:array<string,mixed>,results:list<CloudLinkHealthResult>}
	 */
	public function fetchJobResults(
		CloudSettings $settings,
		string $jobId,
		int $timestamp,
		string $nonce
	): array {
		if ( ! $settings->isConfigured() ) {
			throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' );
		}
		$jobId = self::cleanJobId( $jobId );
		if ( $jobId === '' ) {
			return array(
				'job'     => array(),
				'summary' => array(),
				'results' => array(),
			);
		}
		$data    = $this->requestJson(
			$settings,
			'GET',
			'/link-health/jobs/' . rawurlencode( $jobId ) . '/results',
			'',
			$timestamp,
			$nonce,
			'affilun Cloud link health job results'
		);
		$results = array();
		if ( is_array( $data['results'] ?? null ) ) {
			foreach ( $data['results'] as $row ) {
				if ( is_array( $row ) ) {
					$results[] = CloudLinkHealthResult::fromArray( $row );
				}
			}
		}

		return array(
			'job'     => is_array( $data['job'] ?? null ) ? $data['job'] : array(),
			'summary' => is_array( $data['summary'] ?? null ) ? $data['summary'] : array(),
			'results' => $results,
		);
	}

	/**
	 * Build optional Cloud-side recurring target payloads.
	 *
	 * @param list<string>                      $urls     Submitted URLs.
	 * @param array<string,array<string,mixed>> $contexts Context keyed by URL.
	 *
	 * @return list<array<string,mixed>>
	 */
	private static function targetPayloads( array $urls, array $contexts ): array {
		$targets = array();
		foreach ( $urls as $url ) {
			$context = $contexts[ $url ] ?? null;
			if ( ! is_array( $context ) ) {
				continue;
			}
			$target = array( 'url' => $url );
			foreach ( array( 'label', 'product_id', 'provider', 'provider_item_id' ) as $key ) {
				if ( is_scalar( $context[ $key ] ?? null ) ) {
					$value = trim( (string) $context[ $key ] );
					if ( $value !== '' ) {
						$target[ $key ] = $value;
					}
				}
			}
			if ( is_array( $context['metadata'] ?? null ) ) {
				$target['metadata'] = $context['metadata'];
			}
			$targets[] = $target;
		}
		return $targets;
	}

	/**
	 * Validate URLs for Cloud submission.
	 *
	 * @param list<string> $urls Raw URLs.
	 *
	 * @return list<string>
	 */
	public static function validateUrls( array $urls ): array {
		return self::validateUrlsWithLimit( $urls, 50 );
	}

	/**
	 * Validate URLs for asynchronous Cloud job submission.
	 *
	 * @param list<string> $urls Raw URLs.
	 *
	 * @return list<string>
	 */
	public static function validateJobUrls( array $urls ): array {
		return self::validateUrlsWithLimit( $urls, 5000 );
	}

	/**
	 * Validate URLs for Cloud submission.
	 *
	 * @param list<string> $urls  Raw URLs.
	 * @param int          $limit Maximum accepted URLs.
	 *
	 * @return list<string>
	 */
	private static function validateUrlsWithLimit( array $urls, int $limit ): array {
		$valid = array();
		foreach ( $urls as $url ) {
			$url = trim( $url );
			if ( $url === '' || preg_match( '#\Ahttps?://#i', $url ) !== 1 ) {
				continue;
			}
			if ( function_exists( 'wp_parse_url' ) ) {
				$host = wp_parse_url( $url, PHP_URL_HOST );
			} else {
				// phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url -- Pure-unit fallback when WordPress is unavailable.
				$host = parse_url( $url, PHP_URL_HOST );
			}
			if ( ! is_string( $host ) || self::isBlockedHost( $host ) ) {
				continue;
			}
			if ( ! in_array( $url, $valid, true ) ) {
				$valid[] = $url;
			}
		}
		return array_slice( $valid, 0, $limit );
	}

	/**
	 * Clean a Cloud job identifier for path use.
	 *
	 * @param string $jobId Job id.
	 */
	private static function cleanJobId( string $jobId ): string {
		$jobId = trim( $jobId );
		return preg_match( '/\A[a-f0-9-]{1,64}\z/i', $jobId ) === 1 ? $jobId : '';
	}

	/**
	 * Execute a signed Cloud JSON request.
	 *
	 * @param CloudSettings $settings  Cloud credentials.
	 * @param string        $method    HTTP method.
	 * @param string        $path      API path.
	 * @param string        $body      JSON request body.
	 * @param int           $timestamp Signature timestamp.
	 * @param string        $nonce     Signature nonce.
	 * @param string        $operation Operation name for diagnostics.
	 *
	 * @return array<string,mixed>
	 */
	private function requestJson(
		CloudSettings $settings,
		string $method,
		string $path,
		string $body,
		int $timestamp,
		string $nonce,
		string $operation
	): array {
		$headers = $this->signer->sign(
			$settings->siteId(),
			$settings->apiKey(),
			$method,
			$path,
			$body,
			$timestamp,
			$nonce
		);
		if ( $body !== '' ) {
			$headers['Content-Type'] = 'application/json';
		}

		$response = ( $this->transport )( $settings->endpoint() . $path, $headers, $body );
		$status   = isset( $response['status'] ) ? (int) $response['status'] : 0;
		$payload  = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null;
		$data     = is_array( $payload['data'] ?? null ) ? $payload['data'] : $payload;
		if ( $status < 200 || $status >= 300 || ! is_array( $data ) ) {
			$error = self::errorFromPayload( $payload );
			// phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Stored as exception metadata for controlled admin messages; not emitted here.
			throw new CloudApiException(
				$operation,
				$status,
				$error['code'],
				$error['message']
			);
			// phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
		}

		return $data;
	}

	/**
	 * Whether a host is unsafe for Cloud-side checks.
	 *
	 * @param string $host Host.
	 */
	private static function isBlockedHost( string $host ): bool {
		$lower = strtolower( trim( $host, "[] \t\n\r\0\x0B" ) );
		if ( $lower === 'localhost' || str_ends_with( $lower, '.local' ) ) {
			return true;
		}
		if ( filter_var( $lower, FILTER_VALIDATE_IP ) === false ) {
			return false;
		}
		return filter_var(
			$lower,
			FILTER_VALIDATE_IP,
			FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
		) === false;
	}

	/**
	 * JSON encode a request payload.
	 *
	 * @param array<string,mixed> $payload Payload.
	 *
	 * @throws RuntimeException When encoding fails.
	 */
	private static function jsonEncode( array $payload ): string {
		if ( function_exists( 'wp_json_encode' ) ) {
			$body = wp_json_encode( $payload );
		} else {
			// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- Pure-unit fallback when WordPress is unavailable.
			$body = json_encode( $payload );
		}
		if ( ! is_string( $body ) ) {
			throw new RuntimeException( 'Failed to encode affilun Cloud request.' );
		}
		return $body;
	}

	/**
	 * Extract sanitized Cloud error metadata from a decoded response.
	 *
	 * @param array<string,mixed>|null $payload Decoded response payload.
	 *
	 * @return array{code:string,message:string}
	 */
	private static function errorFromPayload( ?array $payload ): array {
		$error = is_array( $payload['error'] ?? null ) ? $payload['error'] : array();
		return array(
			'code'    => is_scalar( $error['code'] ?? null ) ? (string) $error['code'] : '',
			'message' => is_scalar( $error['message'] ?? null ) ? (string) $error['message'] : '',
		);
	}

	/**
	 * Default WordPress HTTP transport.
	 *
	 * @return callable
	 *
	 * @phpstan-return callable(string, array<string,string>, string): array<string,mixed>
	 */
	private static function defaultTransport(): callable {
		return static function ( string $url, array $headers, string $body ): array {
			$args = array(
				'timeout' => 5,
				'headers' => $headers,
			);
			if ( $body === '' ) {
				$response = CloudHttpOptions::safeGet( $url, $args );
			} else {
				$args['body'] = $body;
				$response     = CloudHttpOptions::safePost( $url, $args );
			}
			if ( function_exists( 'is_wp_error' ) && is_wp_error( $response ) ) {
				return array(
					'status' => 503,
					'data'   => null,
				);
			}
			$raw     = is_array( $response ) ? (string) wp_remote_retrieve_body( $response ) : '';
			$decoded = json_decode( $raw, true );
			return array(
				'status' => is_array( $response ) ? (int) wp_remote_retrieve_response_code( $response ) : 0,
				'data'   => is_array( $decoded ) ? $decoded : null,
			);
		};
	}
}
