<?php
namespace AIOSEO\Plugin\Common\Api;

// Exit if accessed directly.
if ( ! defined( 'ABSPATH' ) ) {
	exit;
}

use AIOSEO\Plugin\Common\Models;

/**
 * AI route class for the API.
 *
 * @since 4.8.4
 */
class Ai {
	/**
	 * Stores the access token.
	 *
	 * @since 4.8.4
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function storeAccessToken( $request ) {
		$body        = $request->get_json_params();
		$accessToken = sanitize_text_field( $body['accessToken'] );
		if ( ! $accessToken ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Missing access token.'
			], 400 );
		}

		aioseo()->sensitiveOptions->set( 'aiAccessToken', $accessToken );
		aioseo()->internalOptions->internal->ai->isTrialAccessToken  = false;
		aioseo()->internalOptions->internal->ai->isManuallyConnected = true;

		aioseo()->ai->updateCredits( true );

		// Build response manually since we know we just set a valid access token.
		$aiOptions                   = self::getAiOptionsPayload();
		$aiOptions['hasAccessToken'] = true;

		return new \WP_REST_Response( [
			'success'   => true,
			'aiOptions' => $aiOptions
		], 200 );
	}

	/**
	 * Fetches the current balance of AI credits.
	 *
	 * @since 4.8.8
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function getCredits( $request ) {
		$body    = $request->get_json_params();
		$refresh = isset( $body['refresh'] ) ? boolval( $body['refresh'] ) : false;

		aioseo()->ai->getAccessToken( $refresh );
		aioseo()->ai->updateCredits( $refresh );

		return new \WP_REST_Response( [
			'success'   => true,
			'aiOptions' => self::getAiOptionsPayload()
		], 200 );
	}

	/**
	 * Generates title suggestions based on the provided content and options.
	 *
	 * @since 4.8.4
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function generateTitles( $request ) {
		try {
			$body         = $request->get_json_params();
			$postId       = ! empty( $body['postId'] ) ? (int) $body['postId'] : 0;
			$postContent  = ! empty( $body['postContent'] ) ? $body['postContent'] : '';
			$focusKeyword = ! empty( $body['focusKeyword'] ) ? sanitize_text_field( $body['focusKeyword'] ) : '';
			$rephrase     = isset( $body['rephrase'] ) ? boolval( $body['rephrase'] ) : false;
			$titles       = ! empty( $body['titles'] ) ? $body['titles'] : [];
			$options      = $body['options'] ?? [];

			if ( ! current_user_can( 'edit_post', $postId ) ) {
				throw new ApiException( 'unauthorized', 'Unauthorized.', 401 );
			}

			$wpObject = $postId ? aioseo()->helpers->getPost( $postId ) : null;

			if ( empty( $postContent ) && $postId ) {
				if ( ! $wpObject ) {
					throw new ApiException( 'post_not_found', 'Post not found.' );
				}

				$postContent = aioseo()->helpers->getPostContent( $wpObject );

				// Bulk generate has no frontend validation, so we gate content length here to avoid wasting AI credits.
				if ( strlen( wp_strip_all_tags( $postContent ) ) < aioseo()->ai->options['minContentLength'] ) {
					throw new ApiException( 'content_too_short', 'Post content is too short.' );
				}
			}

			if ( empty( $focusKeyword ) && $postId ) {
				$aioseoPost   = Models\Post::getPost( $postId );
				$focusKeyword = Models\Post::getKeyphrasesDefaults( $aioseoPost->keyphrases )->focus->keyphrase;
			}

			if ( empty( $postContent ) ) {
				throw new ApiException( 'no_content', 'Missing post content.' );
			}

			if ( empty( $options ) ) {
				throw new ApiException( 'missing_options', 'Missing options.' );
			}

			$options = array_map( [ aioseo()->helpers, 'sanitizeOption' ], $options );
			$titles  = array_map( 'sanitize_text_field', $titles );

			$result = aioseo()->ai->generateTitles( [
				'postId'       => $postId,
				'postContent'  => $postContent,
				'focusKeyword' => $focusKeyword,
				'rephrase'     => $rephrase,
				'titles'       => $titles,
				'options'      => $options
			] );

			if ( ! $result['success'] ) {
				throw new ApiException( 'generation_failed', esc_html( $result['message'] ) );
			}

			return new \WP_REST_Response( [
				'success'   => true,
				'titles'    => $result['titles'],
				'aiOptions' => self::getAiOptionsPayload()
			], 200 );
		} catch ( ApiException $e ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => $e->getMessage(),
				'code'    => $e->getErrorCode()
			], $e->getCode() );
		}
	}

	/**
	 * Generates description suggestions based on the provided content and options.
	 *
	 * @since 4.8.4
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function generateDescriptions( $request ) {
		try {
			$body         = $request->get_json_params();
			$postId       = ! empty( $body['postId'] ) ? (int) $body['postId'] : 0;
			$postContent  = ! empty( $body['postContent'] ) ? $body['postContent'] : '';
			$focusKeyword = ! empty( $body['focusKeyword'] ) ? sanitize_text_field( $body['focusKeyword'] ) : '';
			$rephrase     = isset( $body['rephrase'] ) ? boolval( $body['rephrase'] ) : false;
			$descriptions = ! empty( $body['descriptions'] ) ? $body['descriptions'] : [];
			$options      = $body['options'] ?? [];

			if ( ! current_user_can( 'edit_post', $postId ) ) {
				throw new ApiException( 'unauthorized', 'Unauthorized.', 401 );
			}

			$wpObject = $postId ? aioseo()->helpers->getPost( $postId ) : null;

			if ( empty( $postContent ) && $postId ) {
				if ( ! $wpObject ) {
					throw new ApiException( 'post_not_found', 'Post not found.' );
				}

				$postContent = aioseo()->helpers->getPostContent( $wpObject );

				// Bulk generate has no frontend validation, so we gate content length here to avoid wasting AI credits.
				if ( strlen( wp_strip_all_tags( $postContent ) ) < aioseo()->ai->options['minContentLength'] ) {
					throw new ApiException( 'content_too_short', 'Post content is too short.' );
				}
			}

			if ( empty( $focusKeyword ) && $postId ) {
				$aioseoPost   = Models\Post::getPost( $postId );
				$focusKeyword = Models\Post::getKeyphrasesDefaults( $aioseoPost->keyphrases )->focus->keyphrase;
			}

			if ( empty( $postContent ) ) {
				throw new ApiException( 'no_content', 'Missing post content.' );
			}

			if ( empty( $options ) ) {
				throw new ApiException( 'missing_options', 'Missing options.' );
			}

			$options      = array_map( [ aioseo()->helpers, 'sanitizeOption' ], $options );
			$descriptions = array_map( 'sanitize_text_field', $descriptions );

			$result = aioseo()->ai->generateDescriptions( [
				'postId'       => $postId,
				'postContent'  => $postContent,
				'focusKeyword' => $focusKeyword,
				'rephrase'     => $rephrase,
				'descriptions' => $descriptions,
				'options'      => $options
			] );

			if ( ! $result['success'] ) {
				throw new ApiException( 'generation_failed', esc_html( $result['message'] ) );
			}

			return new \WP_REST_Response( [
				'success'      => true,
				'descriptions' => $result['descriptions'],
				'aiOptions'    => self::getAiOptionsPayload()
			], 200 );
		} catch ( ApiException $e ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => $e->getMessage(),
				'code'    => $e->getErrorCode()
			], $e->getCode() );
		}
	}

	/**
	 * Generates ALT text for an image attachment.
	 *
	 * @since 4.9.6
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function generateImageAlt( $request ) {
		try {
			$body         = $request->get_json_params();
			$attachmentId = ! empty( $body['attachmentId'] ) ? (int) $body['attachmentId'] : 0;

			if ( ! $attachmentId ) {
				throw new ApiException( 'missing_attachment_id', 'Missing attachment ID.' );
			}

			if ( ! current_user_can( 'edit_post', $attachmentId ) ) {
				throw new ApiException( 'unauthorized', 'Unauthorized.', 401 );
			}

			$attachment = get_post( $attachmentId );
			if ( ! $attachment || 'attachment' !== $attachment->post_type ) {
				throw new ApiException( 'attachment_not_found', 'Attachment not found.' );
			}

			$result = aioseo()->ai->generateImageAlt( [
				'attachmentId' => $attachmentId
			] );

			if ( ! $result['success'] ) {
				throw new ApiException( $result['code'] ?? 'generation_failed', esc_html( $result['message'] ) );
			}

			return new \WP_REST_Response( [
				'success'   => true,
				'altTexts'  => $result['altTexts'],
				'aiOptions' => self::getAiOptionsPayload()
			], 200 );
		} catch ( ApiException $e ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => $e->getMessage(),
				'code'    => $e->getErrorCode()
			], $e->getCode() );
		}
	}

	/**
	 * Generates social posts based on the provided content and options.
	 *
	 * @since 4.8.4
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function generateSocialPosts( $request ) {
		$body        = $request->get_json_params();
		$postId      = ! empty( $body['postId'] ) ? (int) $body['postId'] : 0;
		$postContent = ! empty( $body['postContent'] ) ? $body['postContent'] : '';
		$permalink   = ! empty( $body['permalink'] ) ? esc_url_raw( urldecode( $body['permalink'] ) ) : '';
		$options     = $body['options'] ?? [];

		if ( ! $postContent || ! $permalink || empty( $options['media'] ) ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Missing post content, permalink, or media options.'
			], 400 );
		}

		if ( strlen( $postContent ) < aioseo()->ai->options['minContentLength'] ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Post content is too short to generate AI content.'
			], 400 );
		}

		if ( ! current_user_can( 'edit_post', $postId ) ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Unauthorized.'
			], 401 );
		}

		$options = array_map( [ aioseo()->helpers, 'sanitizeOption' ], $options );

		$response = aioseo()->helpers->wpRemotePost( aioseo()->ai->getAiGeneratorApiUrl() . 'social-posts/', [
			'timeout' => 60,
			'headers' => aioseo()->ai->getRequestHeaders(),
			'body'    => wp_json_encode( [
				'postContent' => $postContent,
				'url'         => $permalink,
				'tone'        => $options['tone'],
				'audience'    => $options['audience'],
				'media'       => $options['media']
			] )
		] );

		$responseBody = json_decode( wp_remote_retrieve_body( $response ) );
		$responseCode = wp_remote_retrieve_response_code( $response );

		// Only trust the message if `success` was explicitly set to `false` — this confirms the response came from our API's error contract.
		$serviceError = isset( $responseBody->success ) && false === $responseBody->success && ! empty( $responseBody->message ) ? 'Service error: ' . $responseBody->message : null;
		$errorDetails = array_filter( [ "Service response code: $responseCode", $serviceError ] );

		if ( 200 !== $responseCode ) {
			$errorDetails[] = 'The AI service returned an unexpected response';

			return new \WP_REST_Response( [
				'success' => false,
				'message' => implode( ' | ', $errorDetails )
			], 400 );
		}

		if ( empty( $responseBody->success ) || empty( $responseBody->snippets ) ) {
			if ( ! empty( $responseBody->code ) && 'insufficient_credits' === $responseBody->code ) {
				aioseo()->internalOptions->internal->ai->credits->remaining = $responseBody->remaining ?? 0;

				$errorDetails[] = 'Not enough credits';

				return new \WP_REST_Response( [
					'success' => false,
					'message' => implode( ' | ', $errorDetails )
				], 400 );
			}

			$errorDetails[] = 'The AI service did not return any social post suggestions';

			return new \WP_REST_Response( [
				'success' => false,
				'message' => implode( ' | ', $errorDetails )
			], 400 );
		}

		$socialPosts = [];
		foreach ( $responseBody->snippets as $type => $content ) {
			if ( 'email' === $type ) {
				$socialPosts[ $type ] = [
					'subject' => aioseo()->helpers->decodeHtmlEntities( sanitize_text_field( $content->subject ) ),
					'preview' => aioseo()->helpers->decodeHtmlEntities( sanitize_text_field( $content->preview ) ),
					'content' => aioseo()->helpers->decodeHtmlEntities( strip_tags( $content->content, '<a>' ) )
				];

				continue;
			}

			// Strip all tags except <a>.
			$socialPosts[ $type ] = aioseo()->helpers->decodeHtmlEntities( strip_tags( $content, '<a>' ) );
		}

		aioseo()->ai->updateAiOptions( $responseBody );

		// Get the post and save the data.
		$aioseoPost     = Models\Post::getPost( $postId );
		$aioseoPost->ai = Models\Post::getDefaultAiOptions( $aioseoPost->ai );

		// Replace the social posts with the new ones, but don't overwrite the existing ones that weren't regenerated.
		foreach ( $socialPosts as $type => $content ) {
			$aioseoPost->ai->socialPosts->{ $type } = $content;
		}

		$aioseoPost->save();

		return new \WP_REST_Response( [
			'success'   => true,
			'snippets'  => $aioseoPost->ai->socialPosts, // Return all the social posts, not just the new ones.
			'aiOptions' => self::getAiOptionsPayload()
		], 200 );
	}

	/**
	 * Generates a completion for the assistant.
	 *
	 * @since 4.8.8
	 *
	 * @param  \WP_REST_Request $request The REST Request
	 * @return void
	 */
	public static function generateAssistantCompletion( $request ) {
		header( 'Content-Type: text/event-stream' );
		header( 'X-Accel-Buffering: no' );

		while ( ob_get_level() > 0 ) {
			ob_end_flush();
		}

		$body          = $request->get_json_params();
		$postId        = ! empty( $body['postId'] ) ? (int) $body['postId'] : 0;
		$sseDataPrefix = 'data: ';

		if ( ! current_user_can( 'edit_post', $postId ) ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE format with JSON-encoded data.
			echo $sseDataPrefix . wp_json_encode( [ 'error' => 'Unauthorized.' ] ) . "\n\n";
			flush();
			exit;
		}

		$requestHeaders = aioseo()->ai->getRequestHeaders();

		// phpcs:disable WordPress.WP.AlternativeFunctions
		$ch = curl_init();

		curl_setopt_array( $ch, [
			CURLOPT_URL            => aioseo()->ai->getAiGeneratorApiUrl( 'v2' ) . 'text/',
			CURLOPT_POST           => true,
			CURLOPT_POSTFIELDS     => wp_json_encode( $body ),
			CURLOPT_TIMEOUT        => 180,
			CURLOPT_CONNECTTIMEOUT => 15,
			CURLOPT_FOLLOWLOCATION => true,
			CURLOPT_USERAGENT      => aioseo()->helpers->getApiUserAgent(),
			CURLOPT_ENCODING       => '',
			CURLOPT_HTTPHEADER     => array_map(
				function ( $key, $value ) {
					return $key . ': ' . $value;
				},
				array_keys( $requestHeaders ),
				$requestHeaders
			),
			CURLOPT_WRITEFUNCTION  => function ( $ch, $data ) use ( $sseDataPrefix ) {
				$lines = explode( "\n", $data );
				foreach ( $lines as $line ) {
					if ( strpos( $line, $sseDataPrefix ) !== 0 ) {
						continue;
					}

					$json = json_decode( substr( $line, strlen( $sseDataPrefix ) ), true );

					$content = $json['content'] ?? null;
					$content = $content ? strip_tags( $content ) : null;

					// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE format with JSON-encoded data.
					echo $sseDataPrefix . wp_json_encode( [
						'content' => $content,
						'error'   => $json['error'] ?? null
					] ) . "\n\n";
					flush();

					if ( connection_aborted() ) {
						break;
					}
				}

				return strlen( $data );
			}
		] );

		$result = curl_exec( $ch );
		$error  = curl_error( $ch );
		// phpcs:enable WordPress.WP.AlternativeFunctions

		if ( false === $result || ! empty( $error ) ) {
			// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- SSE format with JSON-encoded data.
			echo $sseDataPrefix . wp_json_encode( [ 'error' => 'Connection error: ' . $error ] ) . "\n\n";
			flush();
		}

		// Exit to prevent WordPress from adding any additional output.
		exit;
	}

	/**
	 * Generates an image based on the provided prompt and other options.
	 *
	 * @since 4.8.8
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function generateImage( $request ) {
		$body            = $request->get_json_params();
		$prompt          = ! empty( $body['prompt'] ) ? sanitize_textarea_field( wp_unslash( $body['prompt'] ) ) : '';
		$quality         = ! empty( $body['quality'] ) ? sanitize_text_field( $body['quality'] ) : '';
		$style           = ! empty( $body['style'] ) ? sanitize_text_field( $body['style'] ) : '';
		$aspectRatio     = ! empty( $body['aspectRatio'] ) ? sanitize_text_field( $body['aspectRatio'] ) : '';
		$postId          = ! empty( $body['postId'] ) ? (int) $body['postId'] : 0;
		$selectedImageId = ! empty( $body['selectedImageId'] ) ? (int) $body['selectedImageId'] : 0;

		$allowedModels = [ 'gemini-3.1-flash-image', 'gpt-image-2' ];
		$model         = ! empty( $body['model'] ) ? sanitize_text_field( $body['model'] ) : 'gemini-3.1-flash-image';
		if ( ! in_array( $model, $allowedModels, true ) ) {
			$model = 'gemini-3.1-flash-image';
		}

		if ( ! current_user_can( 'edit_post', $postId ) ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Unauthorized.'
			], 401 );
		}

		try {
			if ( ! $prompt || ! $postId ) {
				throw new \Exception( 'Missing prompt or post ID.' );
			}

			$postImages         = aioseo()->ai->image->getByPostId( $postId );
			$foundSelectedImage = [];

			if ( ! empty( $selectedImageId ) ) {
				$foundSelectedImage = wp_list_filter( $postImages, [ 'id' => $selectedImageId ] )[0] ?? $foundSelectedImage;
			}

			$response = aioseo()->helpers->wpRemotePost( aioseo()->ai->getAiGeneratorApiUrl() . 'image/', [
				'timeout' => 180,
				'headers' => aioseo()->ai->getRequestHeaders(),
				'body'    => wp_json_encode( [
					'prompt'      => $prompt,
					'quality'     => $quality,
					'style'       => $style,
					'aspectRatio' => $aspectRatio,
					'model'       => $model,
					'image'       => aioseo()->helpers->getBase64FromAttachment( $selectedImageId )
				] )
			] );

			// If for any reason the response is not a correctly formatted JSON, then `json_decode` returns `null`.
			$responseBody = json_decode( wp_remote_retrieve_body( $response ) );
			if ( empty( $responseBody ) ) {
				throw new \Exception( is_wp_error( $response ) ? $response->get_error_message() : 'Empty response body.' );
			}

			if ( empty( $responseBody->success ) || empty( $responseBody->data ) ) {
				if ( 'insufficient_credits' === ( $responseBody->code ?? '' ) ) {
					aioseo()->internalOptions->internal->ai->credits->remaining = $responseBody->remaining ?? 0;
				}

				// Only trust the message if `success` was explicitly set to `false` — this confirms the response came from our API's error contract.
				$message = isset( $responseBody->success ) && false === $responseBody->success && ! empty( $responseBody->message )
					? $responseBody->message
					: 'The AI service did not return image data';

				throw new \Exception( $message );
			}

			try {
				$attachment = aioseo()->ai->image->createAttachment( $responseBody->data->encodedImage, $prompt, $responseBody->data->outputFormat, $postId, [
					'quality'       => $quality,
					'style'         => $style,
					'aspectRatio'   => $aspectRatio,
					'model'         => $model,
					'parentImageId' => $foundSelectedImage['id'] ?? 0
				] );
			} catch ( \Exception $e ) {
				throw new \Exception( $e->getMessage() );
			}

			// At this point a new image was generated and saved as an attachment.
			// So if the selected image already has a parent, then remove it by simply deleting the parent meta.
			if ( ! empty( $foundSelectedImage['parentImageId'] ) ) {
				delete_post_meta( $foundSelectedImage['id'], '_aioseo_ai_parent' );
			}

			return new \WP_REST_Response( [
				'success' => true,
				'data'    => $attachment
			], 200 );
		} catch ( \Exception $e ) {
			$responseCode = isset( $response ) ? wp_remote_retrieve_response_code( $response ) : null;

			return new \WP_REST_Response( [
				'success'      => false,
				'message'      => $e->getMessage(),
				'responseCode' => $responseCode
			], 400 );
		}
	}

	/**
	 * Fetch the images generated for a post.
	 *
	 * @since 4.8.8
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function fetchImages( $request ) {
		$params = $request->get_params();
		$postId = ! empty( $params['postId'] ) ? (int) $params['postId'] : 0;

		if ( ! current_user_can( 'edit_post', $postId ) ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Unauthorized.'
			], 401 );
		}

		$images = aioseo()->ai->image->getByPostId( $postId );

		return new \WP_REST_Response( [
			'success' => true,
			'all'     => [
				'rows' => $images
			],
			'count'   => count( $images )
		], 200 );
	}

	/**
	 * Deletes the images generated for a post.
	 *
	 * @since 4.8.8
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function deleteImages( $request ) {
		$params = $request->get_params();
		$ids    = (array) ( $params['ids'] ?? [] );

		if ( empty( $ids ) ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Missing IDs.'
			], 400 );
		}

		// Filter to only IDs the user can delete.
		$authorizedIds   = [];
		$unauthorizedIds = [];
		foreach ( $ids as $id ) {
			$id = (int) $id;
			if ( current_user_can( 'delete_post', $id ) ) {
				$authorizedIds[] = $id;
			} else {
				$unauthorizedIds[] = $id;
			}
		}

		if ( empty( $authorizedIds ) ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Unauthorized.'
			], 401 );
		}

		aioseo()->ai->image->deleteImages( $authorizedIds );

		return new \WP_REST_Response( [
			'success'   => true,
			'failedIds' => $unauthorizedIds
		], 200 );
	}

	/**
	 * Generates FAQs based on the provided content and options.
	 *
	 * @since 4.8.4
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function generateFaqs( $request ) {
		$body        = $request->get_json_params();
		$postId      = ! empty( $body['postId'] ) ? (int) $body['postId'] : 0;
		$postContent = ! empty( $body['postContent'] ) ? $body['postContent'] : '';
		$rephrase    = isset( $body['rephrase'] ) ? boolval( $body['rephrase'] ) : false;
		$faqs        = ! empty( $body['faqs'] ) ? $body['faqs'] : [];
		$options     = $body['options'] ?? [];

		if ( ! $postContent || empty( $options ) ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Missing post content or options.'
			], 400 );
		}

		if ( strlen( $postContent ) < aioseo()->ai->options['minContentLength'] ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Post content is too short to generate AI content.'
			], 400 );
		}

		if ( ! current_user_can( 'edit_post', $postId ) ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Unauthorized.'
			], 401 );
		}

		foreach ( $options as $k => $option ) {
			$options[ $k ] = aioseo()->helpers->sanitizeOption( $option );
		}

		foreach ( $faqs as $k => $faq ) {
			$faqs[ $k ]['question'] = sanitize_text_field( $faq['question'] );
			$faqs[ $k ]['answer']   = sanitize_text_field( $faq['answer'] );
		}

		$response = aioseo()->helpers->wpRemotePost( aioseo()->ai->getAiGeneratorApiUrl() . 'faqs/', [
			'timeout' => 60,
			'headers' => aioseo()->ai->getRequestHeaders(),
			'body'    => wp_json_encode( [
				'postContent' => $postContent,
				'tone'        => $options['tone'],
				'audience'    => $options['audience'],
				'rephrase'    => $rephrase,
				'faqs'        => $faqs
			] ),
		] );

		$responseBody = json_decode( wp_remote_retrieve_body( $response ) );
		$responseCode = wp_remote_retrieve_response_code( $response );

		// Only trust the message if `success` was explicitly set to `false` — this confirms the response came from our API's error contract.
		$serviceError = isset( $responseBody->success ) && false === $responseBody->success && ! empty( $responseBody->message ) ? 'Service error: ' . $responseBody->message : null;
		$errorDetails = array_filter( [ "Service response code: $responseCode", $serviceError ] );

		if ( 200 !== $responseCode ) {
			$errorDetails[] = 'The AI service returned an unexpected response';

			return new \WP_REST_Response( [
				'success' => false,
				'message' => implode( ' | ', $errorDetails )
			], 400 );
		}

		$faqs = ! empty( $responseBody->faqs ) ? aioseo()->helpers->sanitizeOption( $responseBody->faqs ) : [];
		if ( empty( $responseBody->success ) || empty( $faqs ) ) {
			if ( ! empty( $responseBody->code ) && 'insufficient_credits' === $responseBody->code ) {
				aioseo()->internalOptions->internal->ai->credits->remaining = $responseBody->remaining ?? 0;

				$errorDetails[] = 'Not enough credits';

				return new \WP_REST_Response( [
					'success' => false,
					'message' => implode( ' | ', $errorDetails )
				], 400 );
			}

			$errorDetails[] = 'The AI service did not return any FAQ suggestions';

			return new \WP_REST_Response( [
				'success' => false,
				'message' => implode( ' | ', $errorDetails )
			], 400 );
		}

		aioseo()->ai->updateAiOptions( $responseBody );

		// Decode HTML entities again. Vue will escape data if needed.
		foreach ( $faqs as $k => $faq ) {
			$faqs[ $k ]['question'] = aioseo()->helpers->decodeHtmlEntities( $faq['question'] );
			$faqs[ $k ]['answer']   = aioseo()->helpers->decodeHtmlEntities( $faq['answer'] );
		}

		// Get the post and save the data.
		$aioseoPost           = Models\Post::getPost( $postId );
		$aioseoPost->ai       = Models\Post::getDefaultAiOptions( $aioseoPost->ai );
		$aioseoPost->ai->faqs = $faqs;
		$aioseoPost->save();

		return new \WP_REST_Response( [
			'success'   => true,
			'faqs'      => $faqs,
			'aiOptions' => self::getAiOptionsPayload()
		], 200 );
	}

	/**
	 * Generates schema markup based on the provided content.
	 *
	 * @since 4.9.6
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function generateSchemas( $request ) {
		try {
			$body        = $request->get_json_params();
			$postId      = ! empty( $body['postId'] ) ? (int) $body['postId'] : 0;
			$postContent = ! empty( $body['postContent'] ) ? $body['postContent'] : '';

			if ( ! current_user_can( 'edit_post', $postId ) ) {
				throw new ApiException( 'unauthorized', 'Unauthorized.', 401 );
			}

			$wpObject = $postId ? aioseo()->helpers->getPost( $postId ) : null;

			if ( empty( $postContent ) && $postId ) {
				if ( ! $wpObject ) {
					throw new ApiException( 'post_not_found', 'Post not found.' );
				}

				$postContent = aioseo()->helpers->getPostContent( $wpObject );
			}

			if ( empty( $postContent ) ) {
				throw new ApiException( 'no_content', 'Missing post content.' );
			}

			$result = aioseo()->ai->generateSchemas( $body );

			if ( ! $result['success'] ) {
				throw new ApiException( 'generation_failed', esc_html( $result['message'] ) );
			}

			return new \WP_REST_Response( [
				'success'   => true,
				'schemas'   => $result['schemas'],
				'aiOptions' => self::getAiOptionsPayload()
			], 200 );
		} catch ( ApiException $e ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => $e->getMessage(),
				'code'    => $e->getErrorCode()
			], $e->getCode() );
		}
	}

	/**
	 * Generates key points based on the provided content and options.
	 *
	 * @since 4.8.4
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function generateKeyPoints( $request ) {
		$body        = $request->get_json_params();
		$postId      = ! empty( $body['postId'] ) ? (int) $body['postId'] : 0;
		$postContent = ! empty( $body['postContent'] ) ? $body['postContent'] : '';
		$rephrase    = isset( $body['rephrase'] ) ? boolval( $body['rephrase'] ) : false;
		$keyPoints   = ! empty( $body['keyPoints'] ) ? $body['keyPoints'] : [];
		$options     = $body['options'] ?? [];

		if ( ! $postContent || empty( $options ) ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Missing post content or options.'
			], 400 );
		}

		if ( strlen( $postContent ) < aioseo()->ai->options['minContentLength'] ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Post content is too short to generate AI content.'
			], 400 );
		}

		if ( ! current_user_can( 'edit_post', $postId ) ) {
			return new \WP_REST_Response( [
				'success' => false,
				'message' => 'Unauthorized.'
			], 401 );
		}

		foreach ( $options as $k => $option ) {
			$options[ $k ] = aioseo()->helpers->sanitizeOption( $option );
		}

		foreach ( $keyPoints as $k => $keyPoint ) {
			$keyPoints[ $k ]['title']       = sanitize_text_field( $keyPoint['title'] );
			$keyPoints[ $k ]['explanation'] = sanitize_text_field( $keyPoint['explanation'] );
		}

		$response = aioseo()->helpers->wpRemotePost( aioseo()->ai->getAiGeneratorApiUrl() . 'key-points/', [
			'timeout' => 60,
			'headers' => aioseo()->ai->getRequestHeaders(),
			'body'    => wp_json_encode( [
				'postContent' => $postContent,
				'tone'        => $options['tone'],
				'audience'    => $options['audience'],
				'rephrase'    => $rephrase,
				'keyPoints'   => $keyPoints
			] ),
		] );

		$responseBody = json_decode( wp_remote_retrieve_body( $response ) );
		$responseCode = wp_remote_retrieve_response_code( $response );

		// Only trust the message if `success` was explicitly set to `false` — this confirms the response came from our API's error contract.
		$serviceError = isset( $responseBody->success ) && false === $responseBody->success && ! empty( $responseBody->message ) ? 'Service error: ' . $responseBody->message : null;
		$errorDetails = array_filter( [ "Service response code: $responseCode", $serviceError ] );

		if ( 200 !== $responseCode ) {
			$errorDetails[] = 'The AI service returned an unexpected response';

			return new \WP_REST_Response( [
				'success' => false,
				'message' => implode( ' | ', $errorDetails )
			], 400 );
		}

		$keyPoints = ! empty( $responseBody->keyPoints ) ? aioseo()->helpers->sanitizeOption( $responseBody->keyPoints ) : [];
		if ( empty( $responseBody->success ) || empty( $keyPoints ) ) {
			if ( ! empty( $responseBody->code ) && 'insufficient_credits' === $responseBody->code ) {
				aioseo()->internalOptions->internal->ai->credits->remaining = $responseBody->remaining ?? 0;

				$errorDetails[] = 'Not enough credits';

				return new \WP_REST_Response( [
					'success' => false,
					'message' => implode( ' | ', $errorDetails )
				], 400 );
			}

			$errorDetails[] = 'The AI service did not return any key point suggestions';

			return new \WP_REST_Response( [
				'success' => false,
				'message' => implode( ' | ', $errorDetails )
			], 400 );
		}

		aioseo()->ai->updateAiOptions( $responseBody );

		// Decode HTML entities again. Vue will escape data if needed.
		foreach ( $keyPoints as $k => $keyPoint ) {
			$keyPoints[ $k ]['title']       = aioseo()->helpers->decodeHtmlEntities( $keyPoint['title'] );
			$keyPoints[ $k ]['explanation'] = aioseo()->helpers->decodeHtmlEntities( $keyPoint['explanation'] );
		}

		// Get the post and save the data.
		$aioseoPost                = Models\Post::getPost( $postId );
		$aioseoPost->ai            = Models\Post::getDefaultAiOptions( $aioseoPost->ai );
		$aioseoPost->ai->keyPoints = $keyPoints;
		$aioseoPost->save();

		return new \WP_REST_Response( [
			'success'   => true,
			'keyPoints' => $keyPoints,
			'aiOptions' => self::getAiOptionsPayload()
		], 200 );
	}

	/**
	 * Deactivates the access token.
	 *
	 * @since 4.8.4
	 *
	 * @param  \WP_REST_Request  $request The REST Request
	 * @return \WP_REST_Response          The response.
	 */
	public static function deactivate( $request ) {
		$body    = $request->get_json_params();
		$network = is_multisite() && ! empty( $body['network'] ) ? (bool) $body['network'] : false;

		$internalOptions = aioseo()->internalOptions;
		if ( $network ) {
			$internalOptions = aioseo()->internalNetworkOptions;
		}

		$internalOptions->internal->ai->reset();

		// Reset the manually connected flag when disconnecting.
		$internalOptions->internal->ai->isManuallyConnected = false;

		aioseo()->ai->getAccessToken( true );

		return new \WP_REST_Response( [
			'success' => true,
			'aiData'  => self::getAiOptionsPayload()
		], 200 );
	}

	/**
	 * Returns the AI options payload for API responses.
	 *
	 * This helper ensures we never accidentally expose the access token
	 * and maintains consistency across all AI API endpoints.
	 *
	 * @since 4.9.4
	 *
	 * @return array The AI options payload.
	 */
	public static function getAiOptionsPayload() {
		return [
			'hasAccessToken'      => aioseo()->sensitiveOptions->hasValue( 'aiAccessToken' ),
			'isTrialAccessToken'  => aioseo()->internalOptions->internal->ai->isTrialAccessToken,
			'isManuallyConnected' => aioseo()->internalOptions->internal->ai->isManuallyConnected,
			'credits'             => aioseo()->internalOptions->internal->ai->credits->all(),
			'costPerFeature'      => aioseo()->internalOptions->internal->ai->costPerFeature
		];
	}
}