<?php

// phpcs:disable Yoast.NamingConventions.NamespaceName.TooLong -- Needed in the folder structure.

namespace Yoast\WP\SEO\AI\Authentication\Application;

use WP_User;
use Yoast\WP\SEO\AI\Authentication\Domain\Exceptions\Auth_Strategy_Unavailable_Exception;
use Yoast\WP\SEO\AI\Content_Planner\Domain\Content_Outline_Parameters;
use Yoast\WP\SEO\AI\Content_Planner\Domain\Content_Suggestion_Parameters;
use Yoast\WP\SEO\AI\Generator\Domain\Suggestions_Parameters;
use Yoast\WP\SEO\AI\Generator\Domain\Usage_Parameters;
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Remote_Request_Exception;
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Unauthorized_Exception;
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\WP_Request_Exception;
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Request;
use Yoast\WP\SEO\AI\HTTP_Request\Domain\Response;
use YoastSEO_Vendor\Psr\Log\LoggerAwareInterface;
use YoastSEO_Vendor\Psr\Log\LoggerAwareTrait;
use YoastSEO_Vendor\Psr\Log\NullLogger;

/**
 * Sends an authenticated AI request using a primary strategy, with an optional fallback.
 */
class AI_Request_Sender implements LoggerAwareInterface {

	use LoggerAwareTrait;

	/**
	 * The primary strategy.
	 *
	 * @var Auth_Strategy_Interface
	 */
	private $primary;

	/**
	 * The fallback strategy, or null when no fallback should be tried on persistent failure.
	 *
	 * @var Auth_Strategy_Interface|null
	 */
	private $fallback;

	/**
	 * Constructor.
	 *
	 * @param Auth_Strategy_Interface      $primary  The primary strategy.
	 * @param Auth_Strategy_Interface|null $fallback The fallback strategy, or null for no fallback.
	 */
	public function __construct( Auth_Strategy_Interface $primary, ?Auth_Strategy_Interface $fallback = null ) {
		$this->primary  = $primary;
		$this->fallback = $fallback;
		$this->logger   = new NullLogger();
	}

	// phpcs:disable Squiz.Commenting.FunctionCommentThrowTag.Missing -- Strategies throw typed exceptions that propagate out.

	/**
	 * Requests a content outline from the AI service.
	 *
	 * @param Content_Outline_Parameters $parameters The outline parameters.
	 *
	 * @return Response The parsed response.
	 */
	public function get_content_outline_suggestions( Content_Outline_Parameters $parameters ): Response {
		$request = new Request(
			'/content-planner/next-post-outline',
			[
				'subject' => [
					'language' => $parameters->get_language(),
					'content'  => $parameters->get_content(),
				],
			],
			[ 'X-Yst-Cohort' => $parameters->get_editor() ],
		);

		return $this->send( $request, $parameters->get_user() );
	}

	/**
	 * Requests next-post content suggestions from the AI service.
	 *
	 * @param Content_Suggestion_Parameters $parameters The suggestion parameters.
	 *
	 * @return Response The parsed response.
	 */
	public function get_content_suggestions( Content_Suggestion_Parameters $parameters ): Response {
		$request = new Request(
			'/content-planner/next-post-suggestions',
			[
				'subject' => [
					'language' => $parameters->get_language(),
					'content'  => $parameters->get_content(),
				],
			],
			[ 'X-Yst-Cohort' => $parameters->get_editor() ],
		);

		return $this->send( $request, $parameters->get_user() );
	}

	/**
	 * Requests suggestions for the given suggestion type.
	 *
	 * @param Suggestions_Parameters $parameters The suggestions parameters.
	 *
	 * @return Response The parsed response.
	 */
	public function get_suggestions( Suggestions_Parameters $parameters ): Response {
		$user    = $parameters->get_user();
		$request = new Request(
			'/openai/suggestions/' . $parameters->get_suggestion_type(),
			[
				'service' => 'openai',
				'user_id' => (string) $user->ID,
				'subject' => [
					'content'         => $parameters->get_prompt_content(),
					'focus_keyphrase' => $parameters->get_focus_keyphrase(),
					'language'        => $parameters->get_language(),
					'platform'        => $parameters->get_platform(),
				],
			],
			[ 'X-Yst-Cohort' => $parameters->get_editor() ],
		);

		return $this->send( $request, $user );
	}

	/**
	 * Requests the user's current usage.
	 *
	 * @param Usage_Parameters $parameters The usage parameters.
	 *
	 * @return Response The parsed response.
	 */
	public function get_usage( Usage_Parameters $parameters ): Response {
		$action_path = $parameters->is_free() ? '/usage/free-usages' : '/usage/' . $parameters->get_period();
		$request     = new Request( $action_path, [], [], Request::METHOD_GET );

		return $this->send( $request, $parameters->get_user() );
	}

	/**
	 * Records the user's consent on the AI service.
	 *
	 * The strategy identifies the WP user to the service (the OAuth path injects `user_id` into the
	 * POST body), so no body is built here.
	 *
	 * @param WP_User $user The WP user granting consent.
	 *
	 * @return Response The parsed response.
	 */
	public function grant_consent( WP_User $user ): Response {
		return $this->send( new Request( '/user/consent', [], [], Request::METHOD_POST ), $user );
	}

	/**
	 * Revokes the user's consent on the AI service via `DELETE /user/consent`.
	 *
	 * The strategy identifies the WP user to the service (the OAuth path appends the `user_id`
	 * query parameter to the DELETE), so no body is built here. Note the legacy Token path may
	 * provision a fresh JWT to authenticate the DELETE — Consent_Handler::revoke_consent()
	 * invalidates any locally stored JWTs afterwards, so credentials never outlive consent.
	 *
	 * @param WP_User $user The WP user revoking consent.
	 *
	 * @return Response The parsed response.
	 */
	public function revoke_consent( WP_User $user ): Response {
		return $this->send( new Request( '/user/consent', [], [], Request::METHOD_DELETE ), $user );
	}

	/**
	 * Sends an authenticated AI request, falling back to the secondary strategy on persistent failure.
	 *
	 * The fallback is only tried for failures that mean the primary strategy could not authenticate
	 * or reach the service; every authoritative answer the service gave (a content-filter rejection,
	 * a missing license, a consent or scope 403, a rate limit, a server error) propagates to the
	 * caller instead — see {@see self::is_fallback_eligible()}.
	 *
	 * Kept public for backward compatibility — callers that have a pre-built Request may dispatch it
	 * directly. New call sites should prefer the named methods above.
	 *
	 * @param Request $request The base request, without auth headers.
	 * @param WP_User $user    The WP user the request is on behalf of.
	 *
	 * @return Response The parsed response.
	 */
	public function send( Request $request, WP_User $user ): Response {
		try {
			return $this->primary->send( $request, $user );
		}
		catch ( Remote_Request_Exception $exception ) {
			if ( ! $this->is_fallback_eligible( $exception ) ) {
				$this->logger->warning(
					'Primary AI auth strategy failed ({error_id}, HTTP {status}: {message}); the failure is not recoverable by the fallback, propagating to the caller.',
					$this->error_context( $exception ),
				);
				throw $exception;
			}
			if ( $this->fallback === null ) {
				$this->logger->warning(
					'Primary AI auth strategy failed ({error_id}, HTTP {status}: {message}); no fallback configured, giving up.',
					$this->error_context( $exception ),
				);
				throw $exception;
			}
			$this->logger->warning(
				'Primary AI auth strategy failed ({error_id}, HTTP {status}: {message}); falling back to the secondary strategy.',
				$this->error_context( $exception ),
			);

			try {
				$response = $this->fallback->send( $request, $user );
			}
			catch ( Remote_Request_Exception $fallback_exception ) {
				$this->logger->warning(
					'Secondary AI auth strategy also failed ({error_id}, HTTP {status}: {message}); giving up.',
					$this->error_context( $fallback_exception ),
				);
				throw $fallback_exception;
			}

			$this->logger->debug( 'Secondary AI auth strategy succeeded after primary failure.' );
			return $response;
		}
	}

	/**
	 * Decides whether a primary-strategy failure may be retried via the fallback strategy.
	 *
	 * The fallback exists to recover from a primary strategy that could not *authenticate* or *reach*
	 * the service. It must not fire on an authoritative answer the service gave about the request, the
	 * user, or the account, because the fallback talks to the same service for the same user and would
	 * either get the same answer or, worse, fail for an unrelated reason and mask the real one — for
	 * example a 400 content-filter rejection (profanity) would be silently turned into a repeat request.
	 *
	 * Only three failures clear this gate, all of them about the primary strategy itself rather than
	 * the service's verdict: {@see Auth_Strategy_Unavailable_Exception} (no OAuth site token could be
	 * acquired), {@see Unauthorized_Exception} (a 401 — the token is missing, invalid, or expired), and
	 * {@see WP_Request_Exception} (a transport failure reaching the service, the very case OAuth exists
	 * to survive — note it carries status 400 by convention but is a transport failure, not a service
	 * answer). Every other {@see Remote_Request_Exception} — a service-issued 400
	 * ({@see \Yoast\WP\SEO\AI\HTTP_Request\Domain\Exceptions\Bad_Request_Exception}, e.g. a content
	 * filter), 402, 403 (consent, scope, or any other forbidden), 404, 408, 429, 500, 503 — is an
	 * authoritative answer that propagates untouched.
	 *
	 * @param Remote_Request_Exception $exception The failure thrown by the primary strategy.
	 *
	 * @return bool True when the fallback may be tried, false when the failure must propagate.
	 */
	private function is_fallback_eligible( Remote_Request_Exception $exception ): bool {
		return $exception instanceof Auth_Strategy_Unavailable_Exception
			|| $exception instanceof Unauthorized_Exception
			|| $exception instanceof WP_Request_Exception;
	}

	/**
	 * Builds the PSR-3 log context for a failed remote request, defaulting a missing error identifier
	 * to `unknown` so the rendered message never has an empty slot.
	 *
	 * @param Remote_Request_Exception $exception The failed request exception.
	 *
	 * @return array<string, int|string> The log context: error_id, status, message.
	 */
	private function error_context( Remote_Request_Exception $exception ): array {
		$error_id = $exception->get_error_identifier();
		if ( $error_id === '' ) {
			$error_id = 'unknown';
		}

		return [
			'error_id' => $error_id,
			'status'   => $exception->getCode(),
			'message'  => $exception->getMessage(),
		];
	}

	// phpcs:enable Squiz.Commenting.FunctionCommentThrowTag.Missing
}
