<?php
/**
 * affilun Cloud campaign feed client.
 *
 * @package Affilun\Application\Cloud
 */

declare(strict_types=1);

namespace Affilun\Application\Cloud;

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

/**
 * Fetches campaign suggestions from affilun Cloud.
 */
final class CloudCampaignFeedClient {

	/**
	 * 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();
	}

	/**
	 * Fetch campaign suggestions.
	 *
	 * @param CloudSettings $settings  Cloud settings.
	 * @param string        $provider  Provider.
	 * @param string        $date      Date.
	 * @param int           $timestamp Timestamp.
	 * @param string        $nonce     Nonce.
	 *
	 * @return list<CloudCampaignSuggestion>
	 *
	 * @throws InvalidArgumentException When Cloud settings are incomplete.
	 * @throws RuntimeException When Cloud response fails.
	 */
	public function fetch(
		CloudSettings $settings,
		string $provider,
		string $date,
		int $timestamp,
		string $nonce
	): array {
		if ( ! $settings->isConfigured() ) {
			throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' );
		}
		$provider = preg_replace( '/[^a-z0-9_]/', '', strtolower( $provider ) ) ?? '';
		$date     = preg_match( '/\A\d{4}-\d{2}-\d{2}\z/', $date ) === 1 ? $date : gmdate( 'Y-m-d' );
		$path     = '/campaigns?provider=' . rawurlencode( $provider ) . '&date=' . rawurlencode( $date );
		$headers  = $this->signer->sign(
			$settings->siteId(),
			$settings->apiKey(),
			'GET',
			$path,
			'',
			$timestamp,
			$nonce
		);

		$response = ( $this->transport )( $settings->endpoint() . $path, $headers, '' );
		$status   = isset( $response['status'] ) ? (int) $response['status'] : 0;
		$data     = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null;
		if ( $status < 200 || $status >= 300 || ! is_array( $data ) ) {
			throw new RuntimeException( 'affilun Cloud campaign feed failed.' );
		}

		return self::suggestionsFromPayload( $data );
	}

	/**
	 * Fetch shared campaigns plus product-specific opportunities.
	 *
	 * @param CloudSettings             $settings  Cloud settings.
	 * @param string                    $provider  Provider.
	 * @param string                    $date      Date.
	 * @param list<array<string,mixed>> $products  Saved products.
	 * @param int                       $timestamp Timestamp.
	 * @param string                    $nonce     Nonce.
	 *
	 * @return list<CloudCampaignSuggestion>
	 *
	 * @throws InvalidArgumentException When Cloud settings are incomplete.
	 * @throws RuntimeException When Cloud response fails.
	 */
	public function fetchOpportunities(
		CloudSettings $settings,
		string $provider,
		string $date,
		array $products,
		int $timestamp,
		string $nonce
	): array {
		if ( ! $settings->isConfigured() ) {
			throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' );
		}
		$provider                = preg_replace( '/[^a-z0-9_]/', '', strtolower( $provider ) ) ?? '';
		$date                    = preg_match( '/\A\d{4}-\d{2}-\d{2}\z/', $date ) === 1 ? $date : gmdate( 'Y-m-d' );
		$path                    = '/campaign-opportunities';
		$body                    = self::jsonEncode(
			array(
				'date'      => $date,
				'providers' => array( $provider ),
				'products'  => self::normalizeProducts( $products ),
			)
		);
		$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;
		$data     = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null;
		if ( $status < 200 || $status >= 300 || ! is_array( $data ) ) {
			throw new RuntimeException( 'affilun Cloud campaign opportunities failed.' );
		}

		return self::suggestionsFromPayload( $data );
	}

	/**
	 * Fetch prioritized revenue opportunities.
	 *
	 * @param CloudSettings             $settings  Cloud settings.
	 * @param string                    $provider  Provider.
	 * @param string                    $date      Date.
	 * @param list<array<string,mixed>> $products  Saved products.
	 * @param int                       $timestamp Timestamp.
	 * @param string                    $nonce     Nonce.
	 *
	 * @return list<CloudRevenueOpportunity>
	 */
	public function fetchRevenueOpportunities(
		CloudSettings $settings,
		string $provider,
		string $date,
		array $products,
		int $timestamp,
		string $nonce
	): array {
		if ( ! $settings->isConfigured() ) {
			throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' );
		}
		$provider                = preg_replace( '/[^a-z0-9_]/', '', strtolower( $provider ) ) ?? '';
		$date                    = preg_match( '/\A\d{4}-\d{2}-\d{2}\z/', $date ) === 1 ? $date : gmdate( 'Y-m-d' );
		$path                    = '/revenue-opportunities';
		$body                    = self::jsonEncode(
			array(
				'date'      => $date,
				'providers' => array( $provider ),
				'products'  => self::normalizeProducts( $products ),
			)
		);
		$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;
		$data     = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null;
		if ( $status < 200 || $status >= 300 || ! is_array( $data ) ) {
			throw new RuntimeException( 'affilun Cloud revenue opportunities failed.' );
		}
		if ( ! self::payloadHasRevenueOpportunities( $data ) ) {
			throw new RuntimeException( 'affilun Cloud revenue opportunities payload is missing opportunities.' );
		}

		return self::revenueOpportunitiesFromPayload( $data );
	}

	/**
	 * List persisted revenue opportunities.
	 *
	 * @param CloudSettings $settings  Cloud settings.
	 * @param string        $provider  Provider filter.
	 * @param string        $status    Status filter.
	 * @param int           $timestamp Timestamp.
	 * @param string        $nonce     Nonce.
	 *
	 * @return list<CloudRevenueOpportunity>
	 */
	public function listRevenueOpportunities(
		CloudSettings $settings,
		string $provider,
		string $status,
		int $timestamp,
		string $nonce
	): array {
		if ( ! $settings->isConfigured() ) {
			throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' );
		}
		$provider = preg_replace( '/[^a-z0-9_]/', '', strtolower( $provider ) ) ?? '';
		$status   = self::cleanOpportunityStatus( $status, true );
		$query    = array(
			'limit' => '100',
		);
		if ( $provider !== '' ) {
			$query['provider'] = $provider;
		}
		if ( $status !== '' ) {
			$query['status'] = $status;
		}
		$path    = '/revenue-opportunities?' . http_build_query( $query, '', '&', PHP_QUERY_RFC3986 );
		$headers = $this->signer->sign(
			$settings->siteId(),
			$settings->apiKey(),
			'GET',
			$path,
			'',
			$timestamp,
			$nonce
		);

		$response   = ( $this->transport )( $settings->endpoint() . $path, $headers, '' );
		$statusCode = isset( $response['status'] ) ? (int) $response['status'] : 0;
		$data       = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null;
		if ( $statusCode < 200 || $statusCode >= 300 || ! is_array( $data ) ) {
			throw new RuntimeException( 'affilun Cloud revenue opportunity list failed.' );
		}
		if ( ! self::payloadHasRevenueOpportunities( $data ) ) {
			throw new RuntimeException( 'affilun Cloud revenue opportunity list payload is missing opportunities.' );
		}

		return self::revenueOpportunitiesFromPayload( $data );
	}

	/**
	 * Update one persisted revenue opportunity status.
	 *
	 * @param CloudSettings $settings      Cloud settings.
	 * @param string        $opportunityId Opportunity id.
	 * @param string        $status        New status.
	 * @param int           $timestamp     Timestamp.
	 * @param string        $nonce         Nonce.
	 */
	public function updateRevenueOpportunityStatus(
		CloudSettings $settings,
		string $opportunityId,
		string $status,
		int $timestamp,
		string $nonce
	): CloudRevenueOpportunity {
		if ( ! $settings->isConfigured() ) {
			throw new InvalidArgumentException( 'affilun Cloud settings are not configured.' );
		}
		$opportunityId = trim( $opportunityId );
		$status        = self::cleanOpportunityStatus( $status, false );
		if ( $opportunityId === '' || preg_match( '/[\x00-\x1F\x7F]/', $opportunityId ) === 1 || $status === '' ) {
			throw new InvalidArgumentException( 'Invalid revenue opportunity status update.' );
		}
		$path                    = '/revenue-opportunities/' . rawurlencode( $opportunityId ) . '/status';
		$body                    = self::jsonEncode( array( 'status' => $status ) );
		$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 );
		$statusCode = isset( $response['status'] ) ? (int) $response['status'] : 0;
		$data       = isset( $response['data'] ) && is_array( $response['data'] ) ? $response['data'] : null;
		if ( $statusCode < 200 || $statusCode >= 300 || ! is_array( $data ) ) {
			throw new RuntimeException( 'affilun Cloud revenue opportunity status update failed.' );
		}

		$payload     = isset( $data['data'] ) && is_array( $data['data'] ) ? $data['data'] : $data;
		$opportunity = isset( $payload['opportunity'] ) && is_array( $payload['opportunity'] )
			? $payload['opportunity']
			: $payload;
		return CloudRevenueOpportunity::fromArray( $opportunity );
	}

	/**
	 * Normalize saved products for Cloud submission.
	 *
	 * @param list<array<string,mixed>> $products Raw products.
	 *
	 * @return list<array<string,mixed>>
	 */
	private static function normalizeProducts( array $products ): array {
		$normalized = array();
		foreach ( $products as $product ) {
			$provider       = is_scalar( $product['provider'] ?? null ) ? (string) $product['provider'] : '';
			$providerItemId = is_scalar( $product['provider_item_id'] ?? null ) ? (string) $product['provider_item_id'] : '';
			$provider       = preg_replace( '/[^a-z0-9_]/', '', strtolower( $provider ) ) ?? '';
			$providerItemId = trim( $providerItemId );
			if ( $provider === '' || $providerItemId === '' ) {
				continue;
			}
			$row = array(
				'provider'         => $provider,
				'provider_item_id' => $providerItemId,
			);
			foreach ( array( 'title', 'detail_url', 'affiliate_url' ) as $field ) {
				if ( is_scalar( $product[ $field ] ?? null ) ) {
					$value = trim( (string) $product[ $field ] );
					if ( $value !== '' ) {
						$row[ $field ] = $value;
					}
				}
			}
			if ( isset( $product['metadata'] ) && is_array( $product['metadata'] ) ) {
				$row['metadata'] = $product['metadata'];
			}
			$normalized[] = $row;
			if ( count( $normalized ) >= 50 ) {
				break;
			}
		}

		return $normalized;
	}

	/**
	 * Build suggestion rows from either legacy or opportunities responses.
	 *
	 * @param array<string,mixed> $payload Response payload.
	 *
	 * @return list<CloudCampaignSuggestion>
	 */
	private static function suggestionsFromPayload( array $payload ): array {
		$data          = isset( $payload['data'] ) && is_array( $payload['data'] ) ? $payload['data'] : $payload;
		$campaigns     = isset( $data['campaigns'] ) && is_array( $data['campaigns'] ) ? $data['campaigns'] : array();
		$opportunities = isset( $data['product_opportunities'] ) && is_array( $data['product_opportunities'] )
			? $data['product_opportunities']
			: array();

		$suggestions = array();
		foreach ( array_merge( $campaigns, $opportunities ) as $item ) {
			if ( is_array( $item ) ) {
				$suggestions[] = CloudCampaignSuggestion::fromArray( $item );
			}
		}

		return $suggestions;
	}

	/**
	 * Build revenue opportunity rows from a response payload.
	 *
	 * @param array<string,mixed> $payload Response payload.
	 *
	 * @return list<CloudRevenueOpportunity>
	 */
	private static function revenueOpportunitiesFromPayload( array $payload ): array {
		$data          = isset( $payload['data'] ) && is_array( $payload['data'] ) ? $payload['data'] : $payload;
		$opportunities = isset( $data['opportunities'] ) && is_array( $data['opportunities'] )
			? $data['opportunities']
			: array();
		$mapped        = array();
		foreach ( $opportunities as $item ) {
			if ( is_array( $item ) ) {
				$mapped[] = CloudRevenueOpportunity::fromArray( $item );
			}
		}

		return $mapped;
	}

	/**
	 * Whether a response payload contains a revenue opportunity list.
	 *
	 * @param array<string,mixed> $payload Response payload.
	 */
	private static function payloadHasRevenueOpportunities( array $payload ): bool {
		$data = isset( $payload['data'] ) && is_array( $payload['data'] ) ? $payload['data'] : $payload;
		return array_key_exists( 'opportunities', $data ) && is_array( $data['opportunities'] );
	}

	/**
	 * Clean opportunity status input.
	 *
	 * @param string $status      Raw status.
	 * @param bool   $allowEmpty  Whether an empty value is allowed.
	 */
	private static function cleanOpportunityStatus( string $status, bool $allowEmpty ): string {
		$status = strtolower( trim( $status ) );
		if ( $status === '' && $allowEmpty ) {
			return '';
		}
		return in_array( $status, array( 'new', 'seen', 'applied', 'dismissed' ), true ) ? $status : '';
	}

	/**
	 * 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;
	}

	/**
	 * 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,
			);
		};
	}
}
