<?php
/**
 * affilun Cloud article suggestion provider.
 *
 * @package Affilun\Application\Ai
 */

declare(strict_types=1);

namespace Affilun\Application\Ai;

use Affilun\Application\Cloud\CloudHttpOptions;
use Affilun\Application\Cloud\CloudRequestSigner;
use Affilun\Application\Settings\CloudSettings;

/**
 * Calls affilun Cloud managed AI for article suggestions.
 */
final class CloudArticleSuggestionProvider implements ArticleSuggestionProvider {

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

	/**
	 * @param CloudSettings      $settings  Cloud settings.
	 * @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 CloudSettings $settings,
		private CloudRequestSigner $signer = new CloudRequestSigner(),
		?callable $transport = null
	) {
		$this->transport = $transport ?? self::defaultTransport();
	}

	/**
	 * Suggest article-context product keywords and copy.
	 *
	 * @param string $title   Article title.
	 * @param string $content Article content.
	 *
	 * @throws AiSuggestionException When Cloud suggestion fails.
	 * @throws \Exception When nonce generation fails.
	 */
	public function suggest( string $title, string $content ): ArticleSuggestionResult {
		if ( ! $this->settings->isConfigured() ) {
			throw new AiSuggestionException( 'affilun_cloud_not_configured', 'affilun Cloud is not configured.', 400 );
		}

		$path    = '/ai/extract-keywords';
		$payload = array(
			'title'   => self::truncate( trim( $title ), 300 ),
			'content' => self::truncate( trim( self::stripTags( $content ) ), 6000 ),
			'locale'  => 'ja-JP',
		);
		$body    = wp_json_encode( $payload );
		if ( ! is_string( $body ) ) {
			throw new AiSuggestionException(
				'affilun_cloud_invalid_request',
				'affilun Cloud request could not be encoded.',
				400
			);
		}

		$headers                 = $this->signer->sign(
			$this->settings->siteId(),
			$this->settings->apiKey(),
			'POST',
			$path,
			$body,
			time(),
			bin2hex( random_bytes( 12 ) )
		);
		$headers['Content-Type'] = 'application/json';

		$response = ( $this->transport )( $this->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 || $data === null ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Status is not output; it selects a typed exception.
			throw self::exceptionForResponse( $status, $data );
		}

		$result = ArticleSuggestionResult::fromArray( $data, 'cloud' );
		if ( $result->searchKeywords() === '' ) {
			throw new AiSuggestionException(
				'affilun_cloud_invalid_response',
				'affilun Cloud response did not include search keywords.',
				503,
				array(),
				true
			);
		}

		return $result;
	}

	/**
	 * Map Cloud HTTP status and error envelope to an editor-facing exception.
	 *
	 * @param int                      $status HTTP status.
	 * @param array<string,mixed>|null $data   Decoded response body, when available.
	 */
	private static function exceptionForResponse( int $status, ?array $data ): AiSuggestionException {
		$cloudCode = self::cloudErrorCode( $data );
		$details   = $cloudCode !== '' ? array( 'cloud_error_code' => $cloudCode ) : array();

		if ( $status === 402 || $cloudCode === 'affilun_cloud_plan_required' ) {
			return new AiSuggestionException( 'affilun_cloud_plan_required', 'affilun Cloud plan upgrade is required.', 402, $details );
		}
		if ( $status === 401 || $status === 403 ) {
			return new AiSuggestionException( 'affilun_cloud_site_mismatch', 'affilun Cloud site verification failed.', 403, $details );
		}
		if ( $status === 429 || $cloudCode === 'affilun_cloud_quota_exhausted' ) {
			return new AiSuggestionException(
				'affilun_cloud_quota_exhausted',
				'affilun Cloud quota exhausted.',
				429,
				$details,
				true
			);
		}
		return new AiSuggestionException(
			'affilun_cloud_unavailable',
			'affilun Cloud is unavailable.',
			503,
			$details,
			true
		);
	}

	/**
	 * Extract an AFFILUN Cloud error code from the standard API envelope.
	 *
	 * @param array<string,mixed>|null $data Decoded response body.
	 */
	private static function cloudErrorCode( ?array $data ): string {
		$error = is_array( $data['error'] ?? null ) ? $data['error'] : array();
		$code  = $error['code'] ?? '';
		return is_scalar( $code ) ? (string) $code : '';
	}

	/**
	 * 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 {
			$response = CloudHttpOptions::safePost(
				$url,
				array(
					'timeout' => 15,
					'headers' => $headers,
					'body'    => $body,
				)
			);
			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,
			);
		};
	}

	/**
	 * Strip HTML for Cloud request minimization.
	 *
	 * @param string $value Raw value.
	 */
	private static function stripTags( string $value ): string {
		if ( function_exists( 'wp_strip_all_tags' ) ) {
			return (string) wp_strip_all_tags( $value );
		}
		// phpcs:ignore WordPress.WP.AlternativeFunctions.strip_tags_strip_tags -- Pure-unit fallback when WordPress is unavailable.
		return strip_tags( $value );
	}

	/**
	 * Truncate value.
	 *
	 * @param string $value     Value.
	 * @param int    $maxLength Max length.
	 */
	private static function truncate( string $value, int $maxLength ): string {
		if ( function_exists( 'mb_strlen' ) && function_exists( 'mb_substr' ) ) {
			return mb_strlen( $value ) > $maxLength ? mb_substr( $value, 0, $maxLength ) : $value;
		}
		return strlen( $value ) > $maxLength ? substr( $value, 0, $maxLength ) : $value;
	}
}
